用 Java 写一个植物大战僵尸简易版
(给ImportNew加星标,提高Java技能)
作者:林Lychee
游戏设计
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();}游戏内容
// 首先要有一个僵尸的集合// 僵尸集合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()); } }// 滚轮机上的植物,状态为stop和waitprivate List<Plant> plants = new ArrayList<Plant>();// 战场上的植物,状态为life和move -move为被鼠标选中移动的状态,这里设计不合理,会引发后面的一个BUGprivate 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(); 接下来该让对象们都动起来了。之前说到在父类中的移动方法是抽象方法,在各自的子类中都进行重写后,不同的对象移动方式就是各式各样的了。
// 子弹移动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(); } } } }// 子弹移动public void BulletStepAction() { bullets.forEach((b)->b.step()); .... }// 为了应对产品不断变更的需求,前辈们总结经验得出的设计模式已经能在一定程度上应对此问题// 设计模式,声明策略接口,在实现类中完成过滤逻辑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);}// 无需接口便可实现需求的快速变更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.放置植物的优化
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.添加游戏背景音乐
// 启动线程加载音乐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; } ......后续优化
Github源码地址:https://github.com/llx330441824/plant_vs_zombie_simple.git
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
好文章,我在看❤️