前言
有誰沒玩過植物大戰僵屍嗎?
小灰的一位讀者,用Java語言開發了自己的植物大戰僵屍遊戲。雖然系統相對簡單,但是麻雀雖小五髒俱全,對遊戲開發感興趣的小夥伴可以學習一下哦~~
遊戲設計
植物大戰僵屍中有一個小遊戲關卡,螢幕的正上方有一個滾輪機,會随機生成植物,玩家可以選中植物後自由選擇草坪來進行安放。基于此遊戲模式,我将該關卡抽取出來,單獨做成了一個簡易版的植物大戰僵屍。遊戲的畫面大概如下:

螢幕左側會自動生成植物的卡牌,單擊選中後可以放置在草坪上。右側會自動生成僵屍,不同的僵屍移動速度不同,血量不同,還有的僵屍有隐藏獎勵,比如:全屏僵屍靜止、全屏僵屍死亡等。當時竟然沒有做遊戲的暫停的功能,導緻現在截圖的時機很難把控,那這裡就先說一下遊戲暫停的功能應該怎麼做吧。
最簡單的一種暫停方式是滑鼠移出螢幕,遊戲暫停。是以這裡需要引入一個滑鼠監聽器事件。
public void mouseMoved(MouseEvent e) {
// 當遊戲處于運作狀态時
if (status == start) {
// 通過滑鼠移動事件的對象擷取目前滑鼠的位置
int x = e.getX();
int y = e.getY();
// 如果滑鼠超出了遊戲界面
if (x > Game.WIDTH || y > Game.HEIGHT) {
// 将遊戲的狀态改為暫停狀态
status = pause;
}
}
}
複制
當然,這隻是一個簡單的通過監聽滑鼠的位置來改變遊戲狀态方法。還可以使用鍵盤監聽器,當按下某個鍵時遊戲暫停,這樣的使用者體驗更好。但原理是一樣的,這裡就不展示代碼了。
遊戲對象
首先分析一下遊戲中有哪些對象。各式各樣的植物,各式各樣的僵屍,各式各樣的子彈。那麼這裡就可以抽出三個父類,分别是植物、僵屍、子彈。在面向對象中,子類将繼承父類所有的屬性和方法。是以可以将三大類中,共有的屬性和方法抽到各自的父類中。比如僵屍父類:
public abstract class Zombie {
// 僵屍父類
// 僵屍共有的屬性
protected int width;
protected int height;
protected int live;
protected int x;
protected int y;
......
// 僵屍的狀态
public static final int LIFE = 0;
public static final int ATTACK = 1;
public static final int DEAD = 2;
protected int state = LIFE;
/*
* 這裡補充一下為什麼父類是抽象類,比如每個僵屍都有移動方法,
* 但每個僵屍的移動方式是不同,是以該方法的方法體可能是不同的,
* 抽象方法沒有方法體,在子類中再去進行重寫就可以了,
* 但有抽象方法的類必須是抽象類,是以父類一般都是抽象類
*/
// 移動方式
public abstract void step();
....
}
複制
植物父類、子彈父類就同理可得了。
上面說到子類共有的方法需要抽到父類中,那麼部分子類共有的方法該如何處理呢?比如,豌豆射手、寒冰射手可以發射子彈,堅果牆就沒有射擊的這個行為。是以這裡就需要用到接口(Interface)。
public interface Shoot {
// 射擊接口 - 将部分子類共有的行為抽取到接口中
// 接口中的方法預設是public abstract的,規範的編碼應該将該字段舍去
public abstract Bullet[] shoot();
}
複制
到此為止,遊戲對象的屬性、方法基本都定義完了,至于圖檔的顯示以及如何将圖檔畫出來,隻需要使用相應的API即可,這裡就不做描述了。工作一年回過來看看,這裡能優化的地方還有很多,比如對象的血量、攻擊力、移動等都可以統統寫入到配置檔案中,這樣在做遊戲參數的調整時,不需要去修改代碼相關的内容,隻需要修改配置檔案裡面的參數即可。
遊戲内容
現在我們有了遊戲的對象,該開始讓對象加入到遊戲中來,接着讓他們動起來,最後還得讓他們打起來。首先,讓對象加入到遊戲中來我是這麼做的,這裡還是以僵屍為例:
// 首先要有一個僵屍的集合
// 僵屍集合
private List<Zombie> zombies = new ArrayList<Zombie>();
// 接着定義随機生成僵屍方法
public Zombie nextOneZombie() {
Random rand = new Random();
// 控制不同種類僵屍出現的機率
int type = rand.nextInt(20);
if(type<5) {
return new Zombie0();
}else if(type<10) {
return new Zombie1();
}else if(type<15) {
return new Zombie2();
}else {
return new Zombie3();
}
}
// 僵屍入場
// 設定進場間隔
/*
* 這裡補充一下為什麼要設定進場的間隔
* 因為遊戲的運作是基于定時器的,
* 每隔一段時間定時器就會執行一次你所加入定時器的方法,
* 是以這裡需要設定進場間隔來控制遊戲的速度。
*/
int zombieEnterTime = 0;
public void zombieEnterAction() {
zombieEnterTime++;
// 對自增量zombieEnterTime進行取餘計算
if(zombieEnterTime%300==0) {
// 滿足條件就調用随機生成僵屍方法,并将生成的僵屍加入到僵屍的集合中
zombies.add(nextOneZombie());
}
}
複制
最早時候我用的資料結構是數組,但在後續的編碼中發現,對僵屍對象有很多的周遊以及增删操作,數組的增删操作是十分麻煩複雜的,是以我就換成了集合。在工作中也一樣,先思考在編碼,選擇正确的資料結構往往能起到事半功倍的效果。
植物入場的設計,是我當時自認為很精妙的一個點。先說一下當時在編碼中發現的問題。首先植物入場時是在滾輪機上的,滾輪機上的移動就會涉及到追擊和停止的問題。追擊的方式當然是追前一個植物卡牌,但當第一個植物卡牌被選中放置到草地上後,那該如何追擊呢?
最開始我的做法是給植物多加幾個狀态來解決這個問題,但是發現狀态過多會導緻if判斷中的條件将大大增加,并且在嘗試後還是沒有實作想要的效果,于是我就将植物集合一分為二,在後面的遊戲功能設計中,回頭過來看才發現将植物集合分為滾輪機上的集合和戰場上的集合實在是太精妙了。請聽我娓娓道來:
// 滾輪機上的植物,狀态為stop和wait
private List<Plant> plants = new ArrayList<Plant>();
// 戰場上的植物,狀态為life和move -move為被滑鼠選中移動的狀态,這裡設計不合理,會引發後面的一個BUG
private List<Plant> plantsLife = new ArrayList<Plant>();
// 植物在滾輪機上的碰撞判定
public void plantBangAction() {
// 周遊滾輪機上植物集合,從第二個開始
for(int i=1;i<plants.size();i++) {
// 如果第一個植物的y大于0,并且是stop狀态,則狀态改為wait
if(plants.get(0).getY()>0&&plants.get(0).isStop()) {
plants.get(0).goWait();
}
// 如果第i個植物y小于i-1個植物的y+height,則說明碰到了,改變i的狀态為stop
if((plants.get(i).isStop()||plants.get(i).isWait())&&
(plants.get(i-1).isStop()||plants.get(i-1).isWait())&&
plants.get(i).getY()<=plants.get(i-1).getY()+plants.get(i-1).getHeight()
) {
plants.get(i).goStop();
}
/*
* 如果第i個植物y大于于i-1個植物的y+height,則說明還沒碰到或者第i-1個
* 植物被移走了,改變i的狀态為wait,可以繼續往上走
*/
if(plants.get(i).isStop()&&
plants.get(i).getY()>plants.get(i-1).getY()+plants.get(i-1).getHeight()) {
plants.get(i).goWait();
}
}
}
// 檢測滾輪機上的植物狀态
public void checkPlantAction1() {
// 疊代器
Iterator<Plant> it = plants.iterator();
while(it.hasNext()) {
Plant p = it.next();
/*
* 如果滾輪機集合裡有move或者life狀态的植物
* 則添加到戰場植物的集合中,并從原數組中删除
*/
/*
* 現在發現把滾輪機上move狀态的植物添加到
* 戰場上植物集合的最佳操作時間點應該是
* 等植物狀态變為life後再添加。
* /
if(p.isMove()||p.isLife()) {
plantsLife.add(p);
it.remove();
}
}
}
複制
當然,滾輪機上的對植物狀态判斷的代碼還是顯得生澀,也正是自己想優化這段代碼時萌生了分享遊戲設計過程和遊戲代碼的念頭。那麼下面就說說,這段代碼該如何優化:
// 先對狀态做下說明
// wait - 植物卡牌在滾輪機上移動狀态,因為是等着被滑鼠選中,是以取名為wait
// stop - 植物卡牌在滾輪機上停止狀态,有兩種情況,1 - 到頂了 2 - 撞到上一個卡牌了
// 開始對以下代碼進行優化
// 如果第i個植物y小于i-1個植物的y+height,則說明碰到了,改變i的狀态為stop
// if((plants.get(i).isStop()||plants.get(i).isWait())&&
// (plants.get(i-1).isStop()||plants.get(i-1).isWait())&&
// plants.get(i).getY()<=plants.get(i-1).getY()+plants.get(i-1).getHeight()
// ) {
// plants.get(i).goStop();
// }
// 優化後的代碼是這樣的
// 将一個複雜的boolean拆成多個if條件
if (!(plants.get(i).isStop()||plants.get(i).isWait()) {
break;
}
if (!(plants.get(i-1).isStop()||plants.get(i-1).isWait())) {
break;
}
if (!(plants.get(i).getY()<=plants.get(i-1).getY()+plants.get(i-1).getHeight())) {
break;
}
plants.get(i).goStop();
複制
boolean條件當然也可以進行優化,甚至還可以簡化一下植物的狀态。這裡因為遊戲的規則,僵屍隻能攻擊在草坪上的植物,是以把帶放置的植物和草坪上的植物分為兩個集合,是十分合理精妙的。在判斷僵屍是否攻擊植物,隻需要去周遊草坪上的植物集合即可。如果不拆分,當要判斷僵屍是否攻擊植物的時候,需要周遊的集合将是所有的植物集合,并且需要增加至少2個狀态來區分植物是在草坪上還是在滾輪機上,這段代碼想想就是又臭又長。
接下來該讓對象們都動起來了。之前說到在父類中的移動方法是抽象方法,在各自的子類中都進行重寫後,不同的對象移動方式就是各式各樣的了。
// 子彈移動
public void BulletStepAction() {
for(Bullet b:bullets) {
b.step();
}
}
//僵屍移動
//設定移動間隔
int zombieStepTime = 0;
public void zombieStepAction() {
if(zombieStepTime++%3==0) {
for(Zombie z:zombies) {
//隻有活着的僵屍會移動
if(z.isLife()) {
z.step();
}
}
}
}
複制
看着代碼中對集合複雜的周遊,不得不感概lambda表達式真是個好東西:
// 子彈移動
public void BulletStepAction() {
bullets.forEach((b)->b.step());
....
}
複制
這裡好像還是沒法展示lambda表達式強大的功能,請看下面的例子:
// 為了應對産品不斷變更的需求,前輩們總結經驗得出的設計模式已經能在一定程度上應對此問題
// 設計模式,聲明政策接口,在實作類中完成過濾邏輯
public List<Student> filterStudentByStrategy(List<Student> students, SimpleStrategy<Student> strategy){
List<Student> filterStudents = new ArrayList<>();
for (Student student : filterStudents) {
if(strategy.operate(student)){
filterStudents.add(student);
}
}
return filterStudents;
}
// 當需求變更時,隻需要在政策接口的實作類中,變更判斷邏輯即可
public interface SimpleStrategy<T> {
public boolean operate(T t);
}
複制
但好像還是有點麻煩,又要寫接口,又要寫實作類,後續的維護也是個頭疼問題,這個時候救世主lambda表達式就出現了:
// 無需接口便可實作需求的快速變更
List<Student> lambdaStudents =
students.stream().filter(student -> student.getGender()==1).collect(Collectors.toList());
複制
讓我們看看上面到底發生了啥。首先将資料的集合流化,接着調用過濾方法,強大lambda表達式讓代碼變得簡潔,并且判斷條件的修改可在代碼中直接維護無需在政策接口的實作類維護。最後在轉成集合,傳回一個滿足産品需求的集合。
回到正題,如何讓對象們打起來呢?下面以僵屍攻擊植物為例:
// 僵屍的超類中定義了僵屍的攻擊方法,
// 由于僵屍們的攻擊行為是相同,是以這裡是普通方法
// 僵屍攻擊植物
public boolean zombieHit(Plant p) {
int x1 = this.x-p.getWidth();
int x2 = this.x+this.width;
int y1 = this.y-p.getHeight();
int y2 = this.y+this.width;
int x = p.getX();
int y = p.getY();
return x>=x1 && x<=x2 && y>=y1 && y<=y2;
}
複制
結合圖檔來看,上述代碼應該就更好了解。黑框P代表植物,黑框Z代表植物,虛線是指兩者接觸的極限距離,當僵屍進入虛線内,就保證可以攻擊到植物。
複制
// 僵屍攻擊
// 設定攻擊間隔
int zombieHitTime = 0;
public void zombieHitAction() {
if(zombieHitTime++%100==0) {
for(Zombie z:zombies) {
// 如果戰場上沒有植物,則把所有僵屍的狀态改為life
/*
* 這裡補充一下為什麼要先将所有的僵屍的狀态先改成life狀态,也就是移動狀态
* 因為下面對僵屍是否攻擊的植物的判斷,是從周遊戰場上的植物集合開始的
* 假如有隻僵屍在吃植物,把戰場上唯一的一個植物吃掉了,
* 那麼僵屍的狀态将從攻擊改成移動呢?
* 是以這裡運用了逆向的思想,先将所有的僵屍改為移動狀态
* 如果符合攻擊的條件,那麼再改為攻擊狀态,
* 即便是戰場上沒有植物,那麼僵屍還依然是移動的狀态
*/
if(!z.isDead()) {
z.goLife();
}
// 這裡應該有個對戰場上植物集合的判斷在進行周遊
for(Plant p:plantsLife) {
// 如果僵屍是活的,并且植物是活的,并且僵屍進入攻擊植物的範圍
/*
* 這裡有個BUG,僵屍竟然會攻擊滑鼠選中還未放下的植物,
* 是以下面的判斷條件中應該還需要移除被滑鼠選中狀态下植物
*/
if(z.isLife()&&!p.isDead()&&z.zombieHit(p)&&!(p instanceof Spikerock)) {
// 僵屍狀态改為攻擊狀态
z.goAttack();
// 植物掉血
p.loseLive();
}
}
}
}
}
複制
如果出現了一些效果的偏移,造成的原因是圖檔大小不一造成的坐标偏移,因為圖檔都是網上找的,是以效果不是太理想。
至此,遊戲的基本功能基本實作了。Java是一門面向對象的語言,萬物皆對象,特征皆屬性,行為皆方法。肉眼能看到的僵屍、植物、草坪都是對象,對象的特性比如血量、移動速度都是屬性,對象的行為比如移動、攻擊、死亡都是方法。
下面說說對遊戲功能的優化。
遊戲優化
1.放置植物的優化
已經放置過植物的草地不能再放置植物了。之前是将草地設計成empty和hold兩種狀态,現在來看其實隻需要傳回一個true和false就行了,将整個植物集合定義成一個虛拟的boolean集合即可。
2.移除植物的優化
設計思路是新增一個鏟子對象:
複制
// 鏟子集合
private List<Shovel> shovels = new ArrayList<Shovel>();
// 鏟子入場
public void shovelEnterAction() {
// 鏟子隻有一把
if(shovels.size()==0) {
shovels.add(new Shovel());
}
}
// 使用鏟子
Iterator<Shovel> it = shovels.iterator();
Iterator<Plant> it2 = plantsLife.iterator();
while(it.hasNext()) {
Shovel s = it.next();
// 如果鏟子是移動狀态,就周遊植物集合
if(s.isMove()) {
while(it2.hasNext()) {
Plant p = it2.next();
int x1 = p.getX();
int x2 = p.getX()+p.getWidth();
int y1 = p.getY();
int y2 = p.getY()+p.getHeight();
if((p.isLife()||((Blover) p).isClick())&&Mx>x1&&Mx<x2&&My>y1&&My<y2&&shovelCheck) {
// 移除植物
it2.remove();
// 移除鏟子
it.remove();
shovelCheck = false;
}
}
}
}
複制
看着這極其複雜好像很厲害的代碼,我又萌生了痛下狠手的想法,但為了保持原生,我忍住。于是乎還發現了一個BUG。如果選中鏟子後,戰場上唯一的植物被僵屍吃掉了,那麼這個鏟子将一直跟随着滑鼠無法達到使用後消除的效果了。解決方案當然也很簡單,當戰場上植物集合的size為0時,清空鏟子集合即可。
3.遊戲可玩性的優化
上文在遊戲設計中提到的擊殺僵屍後可能随機獲得獎勵類型是這樣實作的。還是從設計分析開始,并非擊殺任何類型的僵屍都可以獲得獎勵,是以獎勵應該放在接口中:
複制
public interface Award {
// 獎勵接口
/*
* 這裡還是存在代碼不規範的問題
* 接口的方式預設是public abstract
* 接口中的變量預設是public static final
* 這些預設的字段應該舍去
*/
// 全屏靜止
public static final int CLEAR = 0;
// 全屏清除
public static final int STOP = 1;
public abstract int getAwardType();
}
複制
當僵屍死亡時,需要去判斷該僵屍是否有獎勵接口,如果有則執行相應獎勵的方法:
// 檢測僵屍狀态
public void checkZombieAction() {
// 疊代器
Iterator<Zombie> it = zombies.iterator();
while(it.hasNext()) {
Zombie z = it.next();
// 僵屍血量小于0則死亡,死亡的僵屍從集合中删除
if(z.getLive()<=0) {
// 判斷僵屍是否有獎勵的接口
if(z instanceof Award) {
Award a = (Award)z;
int type = a.getAwardType();
switch(type) {
case Award.CLEAR:
for(Zombie zo:zombies) {
zo.goDead();
}
break;
case Award.STOP:
for(Zombie zom:zombies) {
zom.goStop();
timeStop = 1;
//zombieGoLife();
}
break;
}
}
z.goDead();
it.remove();
}
// 僵屍跑進房子,而遊戲生命減一,并删除僵屍
if(z.OutOfBound()) {
gameLife--;
it.remove();
}
}
}
複制
4.添加遊戲背景音樂
bgm是一個遊戲的靈魂之一。這裡給遊戲添加背景音樂,我的選擇是建立一條線程專門用來執行音樂的解析和播放:
// 啟動線程加載音樂
Runnable r = new zombieAubio("bgm.wav");
Thread t = new Thread(r);
t.start();
public class zombieAubio implements Runnable{
// 讀音頻WAV格式專用線程
private String filename;
public zombieAubio(String wavfile){
filename=wavfile;
}
......
複制
這裡需要注意的是,Java中解析音樂的API隻支援WAV格式的檔案,檔案格式的轉換大多數音樂播放器都可以做到。
後續優化
1.植物種類的擴充及對應功能的實作
比如殺傷力最大的玉米加農炮。需要4個小玉米進行合成,那麼在判斷是否能夠合成玉米加農炮時,需要對植物集合進行周遊來做坐标的判斷,是以這邊建議最好把可合成的植物單獨放在一個集合中,這樣在做合成判斷的時候會簡單很多,當集合的size小于4時,就可以提示合成失敗了。冰凍西瓜的設計思路也是如此。
2.動作類僵屍的加入,如撐杆跳僵屍、跳舞僵屍等
說一下撐杆跳僵屍的設計思路,此類僵屍和其他僵屍相比,多了一種跳的行為,是以會有一個單獨的方法和單獨的狀态。并且,跳隻能觸發一次,是以撐杆跳僵屍的狀态變化應該是行走->遇到植物跳過去->再遇到植物就開始攻擊,在執行狀态變化的時候,應該要去考慮目前的狀态是否還可跳躍。
3.當植物攻擊範圍内不存在僵屍時,植物停止攻擊
這個就簡單拉,在植物執行攻擊方法時,校驗一下是否有Y坐标相同的僵屍即可。