
简介基于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 17javax.swing包都在那里躺着不需要额外引入依赖、不需要配置模块路径这对一个以学习为目的的项目来说是决定性优势。JavaFX虽然界面更现代但它在JDK 11以后从JDK中剥离变成了需要单独管理的SDK光是一个JavaFX环境和Java环境变量不一致导致启动失败就能劝退一大批人。从面向对象编程java的角度看Swing的组件树结构天然适合游戏的实体管理JFrame是根容器JPanel是游戏画布每个植物、僵尸、子弹都可以抽象成独立的类放在一个ArrayList里统一更新。这种写法贴合Java的数据类型和容器习惯——用一个ListZombie管理场上所有僵尸遍历时用迭代器安全删除死亡对象这本身就是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秒刷一只路障僵尸加一只普通僵尸。ListZombieWave 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 MapString, 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面试题都有说服力。希望帮到你动手跑一遍比看这篇文章收获大得多。本文还有配套的精品资源点击获取