上班摸魚,使用 Java 寫一個植物大戰殭屍簡易版!

知道的越多,不知道的就越多,業餘的像一棵小草!

編輯:業餘草

來源:https://urlify.cn/byeEjy

推薦:https://www.xttblog.com/?p=5028

    源碼:github.com/llx330441824/plant_vs_zombie_simple.git

B 站:業餘草

有誰沒玩過植物大戰殭屍嗎?用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座標相同的殭屍即可。需要源碼的自己去下載,或加我V❤️:codedq

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章