☰
用Java Swing实现植物大战僵尸:游戏循环与碰撞检测实战
2026/10/7 20:14:34 网站建设 项目流程

简介:基于Java实现的《植物大战僵尸》课程设计完整资料包,适合Java初学者、游戏开发爱好者及期末课程设计参考。压缩包共209个文件,容量65.92MB,包含56个Java源码文件、完整课程论文Word文档,以及24个音频、18张PNG图片和100个GIF动画素材,其中GIF主要用作僵尸与植物的动态效果,便于直接运行与二次开发。游戏玩法为核心:玩家种植不同植物抵御僵尸,全灭僵尸即胜利,若僵尸越过地图右边界则失败,逻辑清晰,适合理解面向对象与多线程编程。资源已累计1801人学习下载。除源码与论文外,还提供素材分类目录、项目配置文件及使用说明,可帮助你快速搭建项目、对照论文理解设计思路、替换素材进行功能扩展,是一份可直接用于课程答辩与实战练手的完整方案。

1. 用Java写一株豌豆射手:这个zip里装着一个怎样的练手项目

把植物大战僵尸用Java重写一遍,听起来像是个自娱自乐的玩具项目,但它实际上把Java基础里最折磨人的几个点全串起来了:Swing的线程模型、事件分发、碰撞检测、对象生命周期管理。很多人在java面试题里背过"Swing不是线程安全的",但只有亲手写过一株豌豆射手,你才会真正理解这句话意味着什么——因为你会亲眼看到僵尸走到一半界面卡死。这个zip适合正在学Java基础、准备蓝桥杯或软考的人,也适合那些想拿一个不落俗套的java项目去面试的人。它不是那种管理后台或者CRUD接口,而是一个有实时渲染、有碰撞判定、有AI波次的小型游戏闭环,代码量不大,但每一行都在跟Java的运行时特性打交道。

拿到这个zip之后,你会发现它的价值不在于"能玩",而在于它把游戏循环、状态机、碰撞检测这些游戏开发的通用概念,用Java的Swing完整实现了一遍。下面我从架构到踩坑,把整个项目的拆解过程写清楚,你可以照着跑通,也可以把它改造成自己的面试作品。

2. 先把架构立住:Swing做主界面,游戏循环和线程模型怎么分工

2.1 为什么用Swing而不是JavaFX:选型要看你手里的环境

这个zip用Swing实现,是Java桌面GUI里最保守也最容易跑通的选择。Swing从JDK 1.2开始就是标准库的一部分,不管你是JDK 8还是JDK 17,javax.swing包都在那里躺着,不需要额外引入依赖、不需要配置模块路径,这对一个以学习为目的的项目来说是决定性优势。JavaFX虽然界面更现代,但它在JDK 11以后从JDK中剥离,变成了需要单独管理的SDK,光是一个"JavaFX环境和Java环境变量不一致导致启动失败"就能劝退一大批人。

从面向对象编程java的角度看,Swing的组件树结构天然适合游戏的实体管理:JFrame是根容器,JPanel是游戏画布,每个植物、僵尸、子弹都可以抽象成独立的类,放在一个ArrayList里统一更新。这种写法贴合Java的数据类型和容器习惯——用一个List<Zombie>管理场上所有僵尸,遍历时用迭代器安全删除死亡对象,这本身就是Java容器使用的最佳实践。

2.2 主循环:Timer回调里只做三件事

游戏循环是所有实时游戏的心脏。这个项目的核心循环用javax.swing.Timer实现,而不是while(true)加Thread.sleep,原因在于Swing的事件分发线程(EDT)模型:所有UI更新必须在EDT线程执行,用Timer的回调天然就在EDT上,避免了手动同步的麻烦。

Timer gameLoop = new Timer(16, e -> { update(); // 1. 更新所有实体状态:僵尸移动、子弹飞行、阳光飘落 checkCollisions(); // 2. 碰撞检测:子弹打僵尸、僵尸啃植物 gamePanel.repaint(); // 3. 请求重绘,paintComponent里做渲染 }); gameLoop.start();

这段代码的逻辑是典型的"update-render分离"模型:update()里修改所有游戏对象的位置和状态,checkCollisions()处理交互,最后调用repaint()触发Swing的绘制机制。注意Timer的延迟参数16毫秒,对应约60FPS的刷新率。这个值不是越小越好,CPU占用和画面流畅度要平衡,我一般建议16到25之间,如果你在低配机器上跑,25毫秒约40FPS也够用。还有一个关键点:Timer的精度受系统时钟影响,它不保证每次回调都准点到达,所以不要在update()里用固定步长累加位移,而是用System.nanoTime()计算真实的时间差,否则游戏会时快时慢。

long now = System.nanoTime(); double delta = (now - lastTime) / 1_000_000_000.0; // 秒 lastTime = now; zombie.x += zombie.speed * delta; // 用时间差驱动位移

2.3 线程模型:为什么不能自己new Thread去改UI

这个项目踩得最深的坑就是线程。Swing的组件不是线程安全的,所有对JPanel上组件状态(比如植物血量、僵尸位置)的修改都必须在EDT上完成。新手最常见的做法是给僵尸移动单独开一个Thread,然后在循环里直接改僵尸的坐标——这在低概率下能跑,但一旦界面重绘和线程写入同时发生,就会出现半渲染的画面或者ArrayIndexOutOfBoundsException。

// 错误示例:直接在非EDT线程修改UI状态 Thread t = new Thread(() -> { while (running) { zombie.x += 1; // 隐患:repaint()可能同时读这个坐标 Thread.sleep(16); } });

如果确实要在后台线程里做逻辑,比如加载资源、读存档,计算结果后必须通过SwingUtilities.invokeLater()或EventQueue.invokeLater()把UI更新塞回EDT队列。虽然Java游戏不一定要像服务器那样严格做并发控制,但理解这个模型,本身就是把java接口自动化测试框架里那套"异步结果回主线程"的思路复用到了游戏里。

3. 从零跑通最小demo:解压导入、田字格种植与阳光系统

3.1 工程结构与编码问题:GBK还是UTF-8

拿到zip解压后,第一件事不是急着点运行,而是先过一遍工程结构。一个标准的Swing游戏工程大概是这样的:src/main/java下按包分好model(实体类:植物、僵尸、子弹)、view(画布与渲染)、controller(游戏循环与事件监听),资源文件放在src/main/resources里。如果你的压缩包解压后是一片散落的.java文件,那就要自己补一个package声明并把目录结构建好。

编码问题是第一道坎。大部分Windows环境下打包的人会习惯用GBK保存源码,而你的IDE默认可能是UTF-8。直接导入会出现中文注释乱码、字符串资源读取异常,甚至编译失败。处理方式是在IDE里统一工程编码:IDEA在Settings -> Editor -> File Encodings里把Global Encoding和Project Encoding都设为UTF-8,再勾选Transparent native-to-ascii conversion。如果源码注释已经乱码了,那只能先看看原始文件的二进制内容,确认是GBK后用Convert to UTF-8批量转换。这一步不做干净,后面所有带中文的植物名称和提示文案都会让你怀疑人生。

3.2 田字格布局:把像素坐标换算成格子坐标

植物大战僵尸的种植逻辑是典型的格子系统:9行5列(或者10行5列,不同版本不一样),玩家点击某个格子,植物就种在那里。这里的关键不是"画一个网格",而是"把鼠标的像素坐标换算成格子坐标",再转回格子左上角的像素坐标用于渲染。

int col = (mouseX - boardOffsetX) / CELL_WIDTH; int row = (mouseY - boardOffsetY) / CELL_HEIGHT; // 边界检查:超出棋盘范围直接返回 if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return; // 判断该格子是否已被占用 if (grid[row][col] != null) { showMessage("这里已经有植物了"); return; } grid[row][col] = new Peashooter(row, col);

逻辑说明:mouseX和mouseY是鼠标事件里的坐标,boardOffsetX和boardOffsetY是棋盘区域在JPanel里的偏移量——如果你的棋盘不是从(0,0)开始的,这个偏移就是万恶之源。我见过有人在棋盘左边画了阳光值显示栏,然后忘了把偏移算进去,结果点阳光槽也会种出植物。格子的宽度和高度建议定义为常量,如果你要做响应式缩放,就计算出实际getWidth()后动态赋值,但项目初版用固定值最省心,800x600的窗口配80x100的格子刚好。

grid[row][col]这个二维数组是种植物资的核心数据结构。它的类型是自定义的Plant基类,子类有Peashooter、Sunflower、WallNut。Java面向对象编程java在这里体现得淋漓尽致:你不需要在grid里存一个String的植物名,再在渲染时用switch去匹配对应的图片,而是利用多态——存的是Plant引用,绘制时调用plant.getImage(),攻击时调用plant.fire(),每种植物自己覆写这些方法。这比用int type加switch高明得多,也是面试官喜欢追问的点。

3.3 阳光系统:生成、落下与收集

阳光是这个游戏的经济系统。向日葵每隔一段时间产出一个阳光,天空也会随机掉落阳光。阳光实体是一个带位置和状态的对象:生成时在天空或花盘上,然后以一定速度下落到地面,停留几秒后消失,玩家点击后收集并累加到阳光计数器。

public class Sun { int x, y; int targetY; // 目标y坐标 double speed; // 下落速度 px/s int lifeTime; // 落地后的存活时间(毫秒) boolean collected; public void update(double delta) { if (y < targetY) { y += speed * delta; if (y >= targetY) { y = targetY; lifeTime = 10000; // 落地后停留10秒 } } else if (lifeTime > 0) { lifeTime -= delta * 1000; if (lifeTime <= 0) { collected = true; // 标记为可移除 } } } }

参数说明:speed建议值在80到120像素每秒,太快玩家来不及点,太慢画面显得拖沓。lifeTime是落地后的存在时间,10秒是参考值,你可以在测试时调到15秒降低难度。阳光的坐标建议生成在向日葵附近或屏幕上半区域,完全随机到屏幕底部会让玩家频繁移动鼠标,体验很差。

收集逻辑用MouseListener的mouseClicked事件:判断点击点是否落在阳光的矩形范围内,命中则把collected置为true,同时给阳光计数器加25或50。注意这里要用Rectangle.contains(Point)来判断,而不是简单比较坐标相等,因为点击有误差,矩形判定比点判定友好得多。

4. 僵尸AI与碰撞检测:豌豆子弹为什么能打中僵尸

4.1 僵尸状态机:行走、啃食、死亡之间的转换

僵尸不是一张贴图平移那么简单。一个合格的僵尸实体至少有三个状态:WALKING(朝植物方向走)、EATING(遇到植物停下啃)、DEAD(死亡动画播放完毕)。用状态机管理这三个状态,比在update()里堆一堆if判断清晰得多。

enum ZombieState { WALKING, EATING, DEAD } public void update(double delta) { if (state == ZombieState.DEAD) return; if (state == ZombieState.EATING) { eatTimer -= delta; if (eatTimer <= 0) { eatTimer = 1.0; // 每1秒啃一次 targetPlant.health -= 20; if (targetPlant.health <= 0) { state = ZombieState.WALKING; // 植物被啃完,继续走 } } return; } x += moveSpeed * delta; // WALKING状态下的移动 }

状态转换的关键在于进入EATING状态的条件:僵尸的前进路径上有植物,且二者发生碰撞。这里的判定不是"植物占用了僵尸所在的格子",而是僵尸的碰撞矩形与植物的碰撞矩形相交。一旦进入EATING,僵尸的x坐标不再更新,它就去啃那个具体的targetPlant对象。targetPlant这个引用很重要——你不需要在EATING状态里每次遍历数组去找植物,而是在进入状态时把碰撞到的第一个植物对象存下来。

DEAD状态的处理容易踩坑:僵尸死亡后不能立即从列表里移除,否则死亡动画播到一半对象消失了,视觉上是"僵尸原地蒸发"。常见做法是给它一个死亡动画时长,比如1.2秒,期间update()里只更新时间不更新坐标,结束时通过迭代器安全移除。

4.2 碰撞检测:矩形相交的三种误用

这个游戏里的碰撞基本都可以用矩形相交来判定,java.awt.Rectangle自带intersects()方法,不需要自己写数学公式。但用不好会出现三种典型问题。

第一种是直接用格子的矩形参与碰撞。格子是80x100,僵尸的贴图可能只有40x80,用整个格子判定会导致"隔空啃植物"——僵尸还没碰到画面上植物边缘,就已经开始咀嚼了。解决方法是每个实体维护自己的碰撞盒(collisionBox),通常比贴图小一圈,给人更真实的打击感。第二种是忽略坐标基准点。Swing绘制时无论是drawImage还是fillRect,坐标都是左上角,而设计碰撞时有人习惯用中心点,换算错了就会差一个身位。我一般规定所有实体的x和y统一是左上角坐标,碰撞盒用(x + offsetX, y + offsetY, width, height)派生,避免存储两份坐标。

第三种是用了intersects()但没考虑高频移动导致的穿模问题。当子弹以每秒600像素的速度飞行,一帧(1/60秒)移动10像素,而僵尸的碰撞盒宽度只有30像素时,有可能出现子弹在某帧正好从僵尸右侧穿到左侧,导致两帧都没有检测到相交。这就是典型的"隧道效应"。解决方式有两种:如果你用Timer驱动固定步长,可以把步长调小(但性能贵);更好的是做"扫掠判定"——用上一帧的位置到这一帧的位置连成一条线段,再和僵尸的矩形做相交检测。不过在植物大战僵尸这种慢节奏游戏里,子弹速度一般不会快到产生隧道效应,我实测豌豆子弹速度在600px/s以内基本安全。

4.3 波次生成与难度曲线:用基础算法控制出怪节奏

僵尸不是一次性全部刷出来,而是按波次出场的。这背后是一个简单的难度曲线控制问题。最简单的实现是维护一个"波次时间表":在第30秒刷第一只普通僵尸,第45秒刷第二只,第60秒刷一只路障僵尸加一只普通僵尸。

List<ZombieWave> waves = new ArrayList<>(); // 每波定义:时间点、僵尸类型、数量 waves.add(new ZombieWave(30, ZombieType.NORMAL, 1)); waves.add(new ZombieWave(45, ZombieType.NORMAL, 1)); waves.add(new ZombieWave(60, ZombieType.CONEHEAD, 1)); waves.add(new ZombieWave(75, ZombieType.NORMAL, 2)); public void update(double delta) { gameTime += delta; for (ZombieWave wave : waves) { if (!wave.triggered && gameTime >= wave.triggerTime) { spawnZombies(wave.type, wave.count); wave.triggered = true; } } }

难度曲线的设计不需要复杂的机器学习或者公式拟合,关键是你自己玩一遍,记录"第几次波次时玩家感觉吃力"。常见的做法是前3波只出普通僵尸,第4波开始出带路障的,第6波开始加快僵尸的moveSpeed——从基础的20px/s提高到25px/s。我见过有人把僵尸速度调到60px/s,结果植物完全来不及反应,游戏变成了丧尸屠杀。血泪经验是:调参时一次只改一个变量,改完跑10分钟观察,不要同时改速度、血量和生成频率,否则你根本不知道是哪里破坏了体验。

5. 避坑指南:跑这种Java小游戏时最容易翻车的5个位置

5.1 现象:界面白屏,控制台没有任何报错

原因:JFrame的内容面板还没添加JPanel就调用了setVisible(true),或者添加了但忘记调用frame.setContentPane(gamePanel)。还有一个隐蔽原因:paintComponent方法写成了public void paintComponent(Graphics g),漏掉了@Override注解,导致Swing调用的还是父类的绘制方法,你自己的画图逻辑根本没执行。

解决:先检查setVisible(true)是否在所有组件添加之后调用;然后在paintComponent第一行写super.paintComponent(g),这是Swing绘制链路的起点,不调用父类方法会出现背景残留和组件重绘异常。再确认面板的setPreferredSize没有被设置为0x0——这是最容易被忽略的,BorderLayout在面板没有首选尺寸时会把面板压缩成零宽零高。

5.2 现象:植物种下去瞬间弹回原位,或者种到了旁边的格子里

原因:鼠标事件里用了getX()和getY()来获取坐标,但没有任何偏移量修正。如果你的棋盘面板上方有工具栏(阳光值显示、植物选择栏),那么点击坐标的起点是整个JPanel的左上角,而格子坐标的起点是棋盘区域的左上角,两者之间的高度差没有减掉。

解决:在棋盘绘制区域计算偏移量。我一般会把棋盘的左上角坐标存成两个常量BOARD_X和BOARD_Y,换算格子坐标时先减去偏移量再除以格子宽高。顺手做一个调试技巧:在paintComponent里临时画一条半透明的网格线,每次点击时打点输出col和row,肉眼就能看出换算是否准确。这个调试代码留在正式版也无妨,对以后的修改非常有帮助。

5.3 现象:豌豆子弹打中僵尸身后的一只,前面的僵尸毫发无伤

原因:碰撞检测遍历列表时,子弹在穿越第一只僵尸的那一帧没有检测到相交,而在下一帧子弹已经穿过僵尸身体并与后面的僵尸重叠了。上面提到过"隧道效应",在高速子弹或低帧率时尤其明显。

解决:不要靠增大碰撞盒来掩盖问题,那会让玩家觉得"子弹还没到就死了"。我一般会在update()里做"连续碰撞":记录子弹上一帧的位置,把从上一帧到当前帧的位移当作一根射线,用RayRectangleIntersect来判断是否与僵尸矩形相交。具体实现可以简化:把位移拆成多段小步进,每次步进4像素做一次矩形相交,相当于把一帧的移动细分。实测效果很好,代码也就多十行。

5.4 现象:游戏运行几分钟后越来越卡,最后OutOfMemoryError

原因:僵尸和子弹对象创建后,在列表里移除时没有置空引用,或者直接用list.remove(index)时索引越界导致异常后循环中断,对象全部泄漏。另一个常见原因是BufferedImage资源反复加载,每次渲染都从磁盘读图片而不是缓存到静态变量里。

解决:所有渲染用的图片资源用静态Map缓存,static Map<String, BufferedImage> IMAGE_CACHE = new HashMap<>(),首次加载后复用。对象移除用迭代器的iterator.remove()。如果你启动时通过IDE配置了较小的堆内存,可以在VM options里加-Xmx512m,游戏本身占用不大,512MB绰绰有余。如果还是出现java.lang.OutOfMemoryError,优先检查是不是每帧都new了某个对象,比如在paintComponent里创建字体或者Color对象——这些都是常驻永久代的资源,应该提前创建好。

5.5 现象:动画时快时慢,特别在窗口拖动或最小化时

原因:前面提到过Timer的精度问题。javax.swing.Timer底层依赖EDT的事件循环,当窗口被拖动或系统忙时,EDT的事件队列会堆积,Timer回调的频率就不稳定。如果你用固定步长位移(每秒60帧就移动速度的1/60),帧率一波动游戏速度就明显漂移。

解决:用delta时间差驱动,而不是固定步长。在update(GamePanel panel, double delta)方法签名里传入真实时间差,所有位移都用speed * delta计算。这个改动会让代码稍微复杂一点,但游戏手感会立刻变成"丝滑"。另外一个相关细节:repaint()不要和Timer的回调同步写,Swing的绘制有自己的优化策略,你只要在逻辑更新完后调一次,不要自己在循环里连续调十次。

6. 最后加一个存档功能,把项目做成能放进简历的完整作品

跑到这里,游戏已经能玩了,但如果你打算把它当作面试作品或者给朋友展示,一个纯打分的游戏其实缺了"体验闭环":退出再进去就要从头打。加一个简单的存档功能,技术上是序列化加I/O操作,正好补上了Java基础里频繁提的ObjectInputStream和ObjectOutputStream的实际用法。

public void saveGame(File file) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) { GameState state = new GameState(); state.sun = sun; state.gameTime = gameTime; state.gridPlants = serializePlants(); state.zombies = serializeZombies(); oos.writeObject(state); } catch (IOException e) { e.printStackTrace(); } }

逻辑说明:GameState是一个只存数据、不存渲染逻辑的POJO类,它必须实现Serializable接口,而且里面的字段也都要可序列化。这意味着你的Plant和Zombie实体类要么也实现Serializable,要么在存档时把它们的关键字段拷贝到轻量级DTO中——我建议用DTO,因为实体类里往往持有BufferedImage这种不可序列化的对象,直接序列化会炸。加载时反向读取GameState,然后根据数据重建实体并重新加载图片缓存。这里有一个细节:所有不参与存档的字段要用transient修饰,告诉Java虚拟机"这个字段别序列化"。

做完存档之后,你可以顺手做一次性能体检:打开任务管理器或者用JVisualVM观察程序运行时的CPU占用和堆内存曲线。如果CPU持续占用30%以上,检查是不是在paintComponent里做了太多绘制操作,比如重复创建Rectangle对象、在循环里调用Graphics.setColor。把这些对象提前创建为静态常量,是很容易见效的优化。

我在自己做这个项目时最深刻的教训是:千万不要在游戏里用System.out.println来调试碰撞检测,输出本身就影响帧率而让你误判逻辑问题。现在我会用画布上的调试信息,按下F3键显示FPS、对象数量、调试矩形,这比任何监控工具都直观。如果你把这个技巧也用进去,面试时顺手打开调试模式展示一下,比背十道java面试题都有说服力。

希望帮到你,动手跑一遍,比看这篇文章收获大得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询