简介:这套基于Java Swing开发的《植物大战僵尸》游戏项目,面向正在学习Java SE与桌面GUI开发的初中级开发者,完整演示了从界面搭建到游戏逻辑实现的全过程。资源内含155个文件,以20个java源文件、39个class编译文件为核心,配以44个png、24个gif、22个jpg等图像素材和2个wav音效,压缩包共19.15MB,另附docx格式文档及必要的项目配置文件。代码中加入了逐行注释,从JFrame主窗口、JPanel游戏面板、JButton控制按钮,到事件监听、Graphics2D绘图、多线程动画与MVC分层设计均有详细说明,适合对照源码梳理Swing游戏开发的常见技巧。文档部分还解释了植物、僵尸、子弹等核心类的交互逻辑,帮助读者快速掌握用Swing构建小型游戏的方法。目前已有437人学习下载,对想通过实战项目巩固Java GUI与设计模式知识的开发者来说,是一份不错的参考资料。
1. 为什么有人愿意用 Java Swing 做一版《植物大战僵尸》
如果只是把《植物大战僵尸》当作一个游戏,它最吸引人的是关卡、音乐和卡通画风;但如果把它拆成技术问题,它其实是 Java 基础语法、面向对象设计、GUI 事件模型、碰撞检测和游戏循环的一次综合演练。很多 Java 学习者学到 Swing 就卡住了:写几个按钮和文本框容易,但要维护一个每 16 毫秒刷新一次、同时存在几十个活动对象的游戏画面,难度立刻不一样。这个项目就是用 Java Swing 把《植物大战僵尸》的核心玩法完整实现,并且把每一处关键逻辑都写了注释,额外附一份文档解释设计思路。对打算做课设、准备 Java 面试、或者想看看别人怎么组织一个中型 Swing 项目的人来说,它是很好的参考样本。
我拿到这类项目的源码,一般不会先跑起来,而是先看它的类结构。因为 Swing 本身只是 UI 工具包,游戏能不能流畅跑、能不能扩展新植物和新僵尸,取决于背后的设计。接下来我要讲的,就是这个项目里最常见的实现路径:从游戏主循环到植物与僵尸的交互,从注释结构到二次开发时怎么改参数、加功能。你能跟着这份思路把代码读顺,也能动手改出一版属于自己的小游戏。
2. 先理解 Swing 游戏的主循环与绘图机制
2.1 为什么 Swing 能做游戏:双缓冲与重绘机制
Swing 组件默认有一套完整的绘制流程,组件在第一次显示时调用paintComponent,之后只有当repaint()被调用时才会重新绘制。游戏和普通界面程序的区别在于:游戏需要以稳定的帧率反复重绘整个画面,同时还要在两次绘制之间更新所有游戏对象的状态。
常见的实现方式有两种。第一种是用javax.swing.Timer,设置一个固定的延迟时间,比如 33 毫秒,对应约 30 FPS;第二种是用SwingWorker或独立线程配合Thread.sleep来控制循环。Timer的好处是它自动把事件回调调度到 EDT(事件分发线程)上,不需要程序员手动处理线程同步,这对 Swing 程序来说更安全。缺点是精度受 EDT 中其他任务影响,如果某个事件处理耗时过长,帧率会抖动。
我一般会选择Timer,因为它足够简单,而且这个项目里并没有需要极高频刷新的复杂物理计算。主循环的关键代码通常会写成类似下面的形式:
public void actionPerformed(ActionEvent e) { // 1. 更新所有游戏对象的状态 updateGameObjects(); // 2. 检查碰撞与胜负条件 checkCollisions(); // 3. 重新绘制画面 gamePanel.repaint(); }updateGameObjects里会遍历当前场景中的所有植物、僵尸、子弹和阳光,调用它们各自的step()方法,让它们移动或改变状态。checkCollisions负责判断子弹是否命中僵尸、僵尸是否咬到植物。最后调用repaint触发paintComponent,在那一帧里把所有对象画出来。
这里有个常见误区:不要在paintComponent里更新游戏状态。绘制方法应该只负责“把当前状态画出来”,如果在这里改对象的坐标,会造成一帧内多次状态变化,画面表现会变得不可预测,而且调试时很难定位问题。
2.2 核心类划分:从 GamePanel 到 GameObject 抽象
一个能跑通全流程的 Swing 游戏项目,类结构通常可以分成四层。第一层是入口类,负责创建主窗口JFrame并启动游戏;第二层是GamePanel,它是整个游戏的核心画布,继承自JPanel,承担主循环和绘制任务;第三层是游戏中的实体基类,比如GameObject或Plant、Zombie的父类;第四层是具体的植物和僵尸子类,以及子弹、阳光这类辅助对象。
阅读源码时先看基类会省很多力气。一个典型的GameObject会包含坐标、宽度、高度、存活状态和速度等字段,以及一个抽象的step()方法和一个draw(Graphics g)方法。所有游戏对象都继承了这套接口,GamePanel维护一个对象列表,每次主循环统一处理,而不需要为每种对象单独写一套更新逻辑。
public abstract class GameObject { protected int x, y; // 左上角坐标 protected int width, height; protected boolean alive; public abstract void step(); // 每帧更新逻辑 public abstract void draw(Graphics g); // 绘制自身 public Rectangle getBounds() { return new Rectangle(x, y, width, height); // 碰撞检测用矩形 } }GamePanel中通常会维护多个列表:植物列表、僵尸列表、子弹列表、阳光列表。step()方法遍历这些列表并调用各自逻辑,同时用迭代器安全地移除alive == false的对象。这种设计的可扩展性在于:新增一种植物时,你只需要写一个继承自Plant的新类,重写它的攻击方式或技能逻辑,然后在点击事件里把它加入植物列表,其他代码几乎不用改。
2.3 背景绘制与网格对齐的实践
《植物大战僵尸》里的植物只能种在草坪格子上,这是游戏交互的核心约束。坐标系的处理方式通常是:GamePanel绘制一张背景图,然后根据背景图左上角坐标和格子尺寸,把鼠标点击位置换算成“第几行第几列”,再根据行列坐标计算出植物应该放置的像素坐标。
格子换算逻辑本身不复杂,但容易踩坑的是图像资源的坐标基准。如果背景图是 900×600,草坪区域不是从 (0,0) 开始的,而是有一圈装饰边框,那么换算公式必须加上偏移量。比如草坪起始点在 (50, 100),每格宽 80、高 100,那么行列换算应该是:
int col = (mouseX - LAWN_OFFSET_X) / CELL_WIDTH; int row = (mouseY - LAWN_OFFSET_Y) / CELL_HEIGHT;这里用LAWN_OFFSET_X和LAWN_OFFSET_Y两个常量记录草坪区域左上角坐标,而不是直接用 0。如果图片资源换了,只需要修改这两个常量并保持和背景图对齐。还有一个细节:计算时如果鼠标点在草坪外面,col或row可能是负数,必须先做范围判断再执行种植逻辑。
3. 植物与僵尸的行为逻辑:从种植到碰撞的完整链路
3.1 种植系统:阳光消耗与冷却时间
种植是玩家操作的核心入口,它通过鼠标事件触发。MouseListener的mousePressed方法里先判断当前选中的植物类型,再判断阳光数量是否足够,接着判断目标格子是否为空,三者都满足才能成功种植。
阳光数值与冷却时间通常是两个独立的状态。阳光作为全局变量存在GamePanel中,每次种植执行一次扣减;冷却时间则记录在卡牌对象中,从种植成功那一刻开始计时。这个计时不能直接用System.currentTimeMillis()做简单比较,因为游戏可能存在暂停状态,更好的做法是用主循环的帧数累积。
public class PlantCard { private int plantType; // 植物类型标识 private int sunCost; // 消耗阳光 private int cooldownTime; // 冷却总时长(毫秒) private long lastPlantedTime; // 上次种下时间 public boolean isReady(long currentTime) { return currentTime - lastPlantedTime >= cooldownTime; } }currentTime由主循环统一产生。这样在处理暂停时,只需要停止更新currentTime,所有卡牌的冷却判断自然冻结,不需要单独处理每张卡牌的状态。如果你在设计自己的项目,可以考虑这个方案,它比每张卡牌各自调用System.currentTimeMillis()更容易控制。
阳光的获取有两个来源:自然掉落和向日葵产出。自然掉落是随机事件:每隔若干秒在草坪随机位置生成一个阳光对象,它有一个下落速度,落到地面后再停留一段时间消失。向日葵则是固定间隔在自己的格子附近产生阳光。两种阳光对象可以被鼠标点击收集,也可以设计成自动飞向阳光计数的动画。出于简化考虑,多数 Swing 实现会选择点击收集,代码上就是在mouseClicked里判断点击坐标是否在阳光对象的矩形范围内。
3.2 子弹发射与碰撞检测的两个方案
子弹逻辑是植物行为中最常见的一类。以豌豆射手为例,它每隔固定间隔检测所在行是否有僵尸,如果有就生成一颗子弹加入子弹列表。子弹每帧沿 X 轴正方向移动,速度是BULLET_SPEED。碰撞检测时遍历子弹和僵尸两个列表,用Rectangle.intersects判断矩形是否相交。
方案一:遍历所有子弹和所有僵尸,做双重循环判断。这种方案简单直观,在对象数量少(植物和僵尸各不超过几十个)时性能没有问题。方案二:按行分组,只检测同一行内的子弹和僵尸。因为植物大战僵尸的豌豆子弹是水平直线飞行的,跨行碰撞本质上不可能发生,按行分组能减少约 80% 的无效判断。我在阅读项目时发现,很多实现没有做这个优化,但它在工程上是很容易加的。
for (Projectile bullet : bulletList) { if (!bullet.isAlive()) continue; Rectangle bulletRect = bullet.getBounds(); for (Zombie zombie : zombieList) { if (!zombie.isAlive()) continue; if (zombie.getRow() != bullet.getRow()) continue; // 只测同行 if (bulletRect.intersects(zombie.getBounds())) { zombie.takeDamage(bullet.getDamage()); bullet.setAlive(false); break; } } }这段代码里,zombie.getRow()是在僵尸生成时就固定好的属性,子弹的getRow()在发射时从射手所在行复制。这样每颗子弹只需要和同行的僵尸做矩形相交判断,逻辑清晰且避免了很多无意义的距离计算。
需要注意:矩形相交是粗略检测,它把物体当作长方形看待。如果你发现游戏里子弹“打中了但没造成伤害”,先检查图片资源是否透明区域太大,导致实际图片比视觉形状小很多。遇到这种情况,可以把碰撞矩形稍微缩小,比如用new Rectangle(x + 5, y + 5, width - 10, height - 10)代替完整矩形。
3.3 僵尸生成与波次控制的关键参数
僵尸生成逻辑直接影响游戏难度曲线。如果只是让僵尸按固定间隔无限生成,玩家很快会腻;如果一次生成太多,玩家前期又挡不住。设计上通常把游戏时间分成若干阶段,每个阶段对应一个“波次序号”,波次越高,僵尸生成间隔越短、单个僵尸血量越高、同时在场数量越多。
一个常见的参数模型是:每隔spawnInterval毫秒随机生成一个僵尸,spawnInterval随游戏时间递减但设置下限;僵尸属性从ZombieConfig中读取,不同波次使用不同的配置组。项目文档里通常会提供一个参数表,像下面这样:
| 波次 | 生成间隔(ms) | 僵尸血量 | 移动速度(px/s) | 攻击力 |
|---|---|---|---|---|
| 1 | 12000 | 100 | 20 | 10 |
| 2 | 10000 | 120 | 22 | 12 |
| 3 | 8000 | 150 | 24 | 15 |
| 4 | 6000 | 180 | 26 | 18 |
调参时优先调生成间隔而不是血量。生成间隔影响玩家面对的压力频率,血量影响单个僵尸的“耐久”,间隔调得合理,即使血量不变,玩家也能感受到明显的难度上升。移动速度对难度影响最大,一般不建议超过 40 px/s,否则会突破玩家种植植物的反应时间。
还有一个容易被忽略的参数:僵尸之间的“重叠容忍度”。如果两个僵尸走到同一个格子里,视觉上会叠在一起,看起来像只有一个。可以在生成新僵尸时检查已有僵尸的位置,如果距离过近就取消本次生成或调整出生点偏移。这个参数不需要做复杂的防止碰撞算法,只要做一个最小间距判断即可。
4. 读懂全注释代码:阅读路径、核心方法定位与文档对照
4.1 从注释结构反推作者设计思路
拿到一份全注释的 Swing 项目,我不建议从头到尾按文件顺序读。文件数量多时,按顺序读容易迷失在细节里。我习惯先从文档入手,看作者写了哪些模块,再看代码里注释密度最高的类,通常那正是核心逻辑所在。
注释密度是一个很好的定位信号。如果一个文件平均 5 行就有一处注释,它大概率是作者用心写的核心类。在这个项目里,GamePanel和Plant基类的注释占比通常最高,因为GamePanel承载了主循环和碰撞检测,Plant基类承载了植物公共行为。阅读时可以先用 IDE 的“大纲视图”展开这两个类的方法列表,先看方法签名,再反推调用关系。
如果注释里写了“为什么”而不是“是什么”,这类注释最值得读。比如某段注释写“这里用迭代器的 remove 方法而不是列表的 remove,是为了避免 ConcurrentModificationException”,说明作者踩过这个坑,这比“从列表中移除对象”这类描述有价值得多。顺着这种“为什么”注释,你能学到很多实际编程习惯。
4.2 找关键常量与参数调整入口
全注释项目的另一个好处是常量的语义很清晰。游戏平衡性相关的常量通常会集中放在某个配置类或文档开头。调整游戏难度时,优先搜索这些关键词:SUN_COST、SPAWN_INTERVAL、MAX_SUN、SUN_NATURAL_INTERVAL、BULLET_SPEED、ZOMBIE_ATTACK_INTERVAL。
public class GameConfig { public static final int SUN_START = 150; // 初始阳光,不要低于 50 public static final int CELL_WIDTH = 80; // 格子宽度,与背景图匹配 public static final int CELL_HEIGHT = 100; // 格子高度 public static final int SUN_FALL_SPEED = 2; // 阳光下落速度(px/帧) public static final int SUN_LIFETIME = 15000; // 阳光存在时间(ms) }调整这些常量时,一次只改一个参数并运行测试,比一次性改多个更容易判断问题来源。尤其是格子尺寸,它必须和背景草坪区域严格对应,改错了会导致鼠标点击种植的位置和画面显示不一致。
常量的线程安全问题值得一提:这些值是static final,在整个游戏运行期间只读不写,所以无论从哪个线程访问都不会出问题。如果你在二次开发中决定把某些常量改成可以动态调整的(比如做游戏内调试面板),需要统一改成从GameConfig实例读取,并避免在不同线程中同时读写。
4.3 文档解释了哪些内容:从类图到时序图
这份项目自带文档一般包含类图、主要流程说明和数据结构解释。读文档时重点看类图里继承关系和你实际代码是否一致;有些文档在写完代码后没有同步更新,类图可能和实际实现有偏差。遇到不一致,以代码为准,同时记录偏差位置,方便后续维护。
时序图部分通常解释一个完整交互周期:鼠标点击 → 生成阳光扣除 → 植物加入列表 → 植物检测到僵尸 → 生成子弹 → 子弹移动 → 碰撞僵尸 → 僵尸掉血 → 僵尸死亡移除。理解这条链路的顺序,对调试有很大帮助。比如植物已经种下但不开火,问题可能出在“检测僵尸”这一步;如果子弹打中僵尸但没有伤害,问题出在“碰撞检测”和“掉血”之间。
4.4 阅读源码时可以动手做的三个验证实验
第一个实验:在paintComponent里加一行输出,打印当前对象数量。这能直接看到对象列表是否泄漏,比如僵尸死了但没被移除,数量会持续上涨。
第二个实验:把Timer的延迟从默认值改成 100,观察游戏变得卡顿的过程。这个实验能帮你理解帧率与游戏速度的关系,也能帮你搞清楚为什么有些电脑上游戏走得太快或太慢。
第三个实验:暂停游戏后调用Thread.sleep(5000),观察所有对象的状态是否保持。如果代码里大量使用了System.currentTimeMillis(),暂停期间时间照样流逝,恢复后冷却时间会直接跳过,这是一个隐蔽的 bug。用主循环统一计时器则可以避免这个问题。
5. 让这版 Swing 游戏更像“作品”:优化与调试的进阶技巧
5.1 绘制性能优化:减少 Graphics 对象的重复创建
Swing 绘制性能瓶颈通常不在绘制本身,而在创建对象和setColor、drawImage这类状态切换上。一个容易被忽视的问题是:在paintComponent里直接 new 一个Font或Color对象,即使每帧只创建一个,也会增加 GC 压力。正确做法是声明成static final字段,在类加载时初始化一次。
另一个优化点是:只在对象状态变化时重绘局部区域。repaint(Rectangle)是 Swing 自带的能力,可以只刷新某个矩形区域,而不是整个画面。子弹每帧移动后只需要重绘它前后两帧覆盖的区域,画面变化较大的场景(比如僵尸被击杀)才需要整帧重绘。用repaint(Rectangle)需要额外的区域计算,在对象数量少时收益不大,但如果你的项目里加了大量动画特效,这个优化能显著降低 CPU 占用。
5.2 存档功能:用 Properties 还是 JSON
这个游戏经常被要求加存档功能。最简单可靠的做法是用java.util.Properties记录当前关卡号、阳光数量、已解锁植物列表。存档频率不要太高,每 10 秒写一次,或者在切换关卡时写一次,避免频繁磁盘 IO 影响游戏体验。
public void saveGame(File file) throws IOException { Properties props = new Properties(); props.setProperty("level", String.valueOf(currentLevel)); props.setProperty("sun", String.valueOf(sunCount)); props.setProperty("unlocked", String.join(",", unlockedPlants)); try (OutputStream out = new FileOutputStream(file)) { props.store(out, "Plant vs Zombie Save"); } }读档时用load(InputStream)读取,再逐项转换类型。Properties的缺点是值只能是字符串,适合简单需求。如果以后要存档地图上所有植物的位置,甚至僵尸的实时状态,就得上 JSON 库或改二进制的Serializable。不要一上来就引入重量级方案,先看需求边界。
5.3 验证游戏平衡性的“挂机测试”法
最后分享一个我验证游戏难度的方法:写一个自动化测试脚本,让游戏“挂机”运行,模拟一个不会操作的玩家,看游戏能坚持多少秒不失败。在这个项目里,可以用代码生成一个AutoPlayer线程,每隔固定时间检查阳光是否足够,够就随机种一棵向日葵,其他什么都不做。如果挂机 5 分钟内游戏不结束,说明僵尸生成强度太弱;如果 30 秒就失败,说明前期压力太大。调整SPAWN_INTERVAL后再跑几轮,就能快速找到一个相对合理的区间,不必手动反复试玩。
这种验证方式比肉眼看界面更客观,而且能被自动化为回归测试,每次修改难度参数后跑一遍,查看数据是否落在期望范围内。对于想把这个课程设计项目变成面试作品的人来说,这个细节会让面试官看到你的工程思维:你不仅写了功能,还建立了可验证的流程。
本文还有配套的精品资源,点击获取