
简介面向Java初学者和游戏开发入门者的经典复刻项目以Swing构建完整图形界面实现植物大战僵尸的核心玩法并贯穿GUI设计、多线程、碰撞检测、音效播放与存档持久化等知识点。压缩包共148个文件包含19个java源码、38个class编译文件及64个png图片素材另有24张jpg图片与工程配置文件整体仅3.77MB目录结构清晰资源按源码、素材与编译输出归类便于快速研读。项目以面向对象方式组织植物、僵尸、子弹等实体并通过僵尸移动、子弹飞行、金钱检测、小车触发等多个并发线程处理实时逻辑界面布局、游戏状态流转、胜负判定等模块也可在源码中对应查阅。已有2337人学习下载适合希望通过完整小游戏项目提升Java编程和桌面应用开发能力的读者同时可从中积累实际的调试与排错经验。1. 植物大战僵尸 Java Swing 复刻为什么值得认真做完的小项目把植物大战僵尸用 Java 重写一遍听起来像是大学编程课的怀旧作业但真把它做到能玩、不闪退、能打包你对 java 基础、swing 事件模型和面向对象的理解会比刷一百道 java 八股文更扎实。这个项目最大的价值不在画面而在于它逼你把碰撞检测、状态机、对象生命周期这些平时只出现在面试题里的概念亲手实现一遍。适合刚学完 java 基础、想要第一个完整项目的人。下文按我自己通常落地的顺序展开从搭画布到踩坑最后给你一份验收清单。2. 搭 Swing 游戏骨架先让 1000x600 的画布跑起来2.1 项目结构拆分GameFrame、GamePanel、GameState 各管什么在 java 学习路线里很多人第一个 GUI 程序是计算器第二个就是这种小游戏。游戏比 CRUD 程序更容易折磨人但也更容易让人理解“高内聚、低耦合”这八个字到底是什么意思。我一般会先建这样的包结构src/main/java/com/pvz/ GameFrame.java JFrame只负责窗口生命周期 GamePanel.java JPanel绘图 主循环 tick GameState.java 游戏逻辑数据纯 java 对象 config/GameConfig.java 好改的常量 plant/ zombie/ bullet/ resource/一开始很容易把所有东西塞进 GamePanel因为写起来最快。等这个类超过 500 行连改一个阳光数量都要翻半天。我现在的习惯是让 GameState 只存数据GamePanel 只读数据并绘制这样改玩法逻辑时不用碰绘图代码。这个拆分方式本身就是面向对象编程 java 里的单一职责思想比背概念更能留下印象。三类对象都继承同一个“可绘制游戏实体”抽象类抽象出 update 和 draw 两个方法主循环才能做到“遍历对象、逐个更新、统一重绘”。窗口大小我一般固定为 1000x600setResizable(false)因为植物大战僵尸是固定棋盘玩法不需要响应式固定尺寸能少掉一堆窗口缩放后坐标全乱的边界问题。2.2 用 Swing Timer 做主循环为什么不用 while(true) Thread.sleep游戏要动起来需要一个周期性调用的主循环。Swing 下最常见的做法是用 javax.swing.Timer把每帧要做的事放在回调里public class GamePanel extends JPanel { public static final int WIDTH 1000; public static final int HEIGHT 600; private final GameState state; private final Timer timer; public GamePanel(GameState state) { this.state state; setPreferredSize(new Dimension(WIDTH, HEIGHT)); setBackground(new Color(87, 158, 37)); // 16 毫秒一帧约 60 FPS新手可以先用 25ms 降低 CPU 开销 timer new Timer(16, e - { state.update(); repaint(); }); timer.start(); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); state.draw(g); } }代码里的 16 是间隔毫秒数16 毫秒约等于 60 FPS。Swing Timer 的回调跑在 EDT事件分发线程上它和鼠标点击、键盘事件天然是同一线程所以你在 update 里改状态、随后 repaint不会出现典型的并发异常。有人喜欢用 while(true) Thread.sleep(16) 单独开线程这在 Swing 里属于自己挖坑一旦子线程里直接碰组件状态就会偶现闪退而且很难复现。三种常见做法的取舍方式回调线程适合场景坑点javax.swing.TimerEDT小游戏、UI 动画不能保证精确帧率Thread sleep子线程纯逻辑循环碰 UI 会并发崩溃java.util.Timer子线程定时任务回调不能碰 UI如果 update 里有耗时操作比如临时加载图片Timer 的帧率就会掉。经验是把间隔从 16 改成 25肉眼几乎无差别CPU 占用明显下降。2.3 双缓冲与绘制顺序先草地再植物最后子弹才会像样paintComponent 里的绘制顺序就是 Z 轴顺序。我一般按这个顺序画背景草坪地砖用 fillRect 画格子或铺一张小图网格线淡色辅助线正式版可去掉植物向日葵、射手、坚果这类静态对象僵尸排在植物之后遮挡关系才正确子弹在最上层HUD右上角阳光数、卡片栏把子弹画在僵尸上面玩家看到的是“子弹飞向僵尸”而不是子弹被僵尸挡住。当多个僵尸叠在一起时绘制顺序还得按 y 坐标排序这时候就会遇到 sort 函数在 java 里的高频用法// 按 y 坐标从小到大绘制保证远处对象被近处对象遮挡 zombies.sort(Comparator.comparingInt(z - z.getY())); for (Zombie z : zombies) { z.draw(g); }ArrayList 的插入顺序是生成时刻决定的后生成的僵尸正好在画面下方时不排序会画得像穿模。排序之后层次立刻正常。再说双缓冲。Swing 的 JComponent 默认开启双缓冲不需要自己手动建后备缓冲。如果你发现画面闪烁先检查是不是有人偷懒用了 getGraphics() 去画而不是在 paintComponent 里画。getGraphics() 拿到的缓冲区不受重绘管理画上去的内容会被下一次重绘抹掉。所有绘制都放进 paintComponent并且只在 EDT 上触发 repaint()这是最稳的组合。3. 面向对象的玩法落地向日葵、豌豆射手与僵尸的协作逻辑3.1 草坪上的 5x9 网格鼠标点击与坐标换算怎么做植物大战僵尸的棋盘是 5 行 9 列。我的做法是把棋盘参数集中放到 GameConfigpublic static final int ROWS 5; public static final int COLS 9; public static final int CELL_WIDTH 100; public static final int CELL_HEIGHT 100; // 左上角留出偏移顶部放阳光数和卡片栏 public static final int OFFSET_X 40; public static final int OFFSET_Y 110;鼠标点击时只需要一次除法就能把像素坐标换算成棋盘行列public void mousePressed(MouseEvent e) { int col (e.getX() - OFFSET_X) / CELL_WIDTH; int row (e.getY() - OFFSET_Y) / CELL_HEIGHT; if (row 0 || row ROWS || col 0 || col COLS) { return; // 点到了草坪范围外 } panel.getGameState().tryPlant(row, col); }OFFSET_Y 很关键顶部要留一块区域放阳光数和卡片栏很多新手把整个 JPanel 都当成草坪一换算就出界。tryPlant 里要做四件事判断阳光够不够、判断这个格子有没有植物、扣阳光、把植物对象 add 进列表。顺序很重要先扣阳光再判断会导致阳光负值。阳光数建议用 int 而不是浮点这是 java 数据类型里一个容易忽视的习惯。浮点表示金额或资源会带来精度问题阳光虽然都是整数但写成 int 会让后续逻辑更干净。同一个格子不能种两棵植物我用一个二维数组 plantGrid[][] 来管理“是否被占用”而不是遍历整个 List 去判断坐标。遍历判断在大场景下更慢也容易漏判。3.2 向日葵产阳光与豌豆射手攻击冷却时间用帧计数而不是时间戳玩法循环很朴素阳光是资源种植花资源向日葵周期性产资源射手自动攻击僵尸消耗玩家的植物防线。实现周期行为时我不用 System.currentTimeMillis() 去比对当前时间因为暂停和计时抖动都不好处理。游戏循环里更常见的是帧计数public class Sunflower extends Plant { private int sunCooldown; // 600 帧 10 秒按 60fps 算 private static final int SUN_INTERVAL 600; public Sunflower(int row, int col) { super(row, col, 100, 50); this.sunCooldown SUN_INTERVAL; } public void update(GameState state) { sunCooldown--; if (sunCooldown 0) { state.addSun(new Sun(getX(), getY())); sunCooldown SUN_INTERVAL; } } }帧计数和毫秒计时的差别在于Timer 是 16ms600 帧和 10 秒之间会有约 0.2 秒的累计误差对这个游戏无所谓。好处是逻辑里没有时间戳调试时把 Timer 间隔调大就能看到向日葵“慢动作”产阳光这种可视化调试找 bug 很爽。豌豆射手的攻击冷却也类似只多一个判断僵尸必须和植物在同一行且在射程内。行号是植物和僵尸共有的核心属性判断条件我习惯写成boolean zombieInSameRow state.getZombies().stream() .anyMatch(z - z.getRow() getRow() z.getX() getX() RANGE);如果你还不熟悉 lambda用普通 for 循环也一样。重点是条件同一行 僵尸在射程内。把行号作为核心字段后面碰撞检测会简单很多。3.3 僵尸的生成节奏与控制参数化的难度曲线僵尸如果开局就疯狂生成游戏毫无体验。我惯用的设计是波次生成把一关拆成若干波每波数量递增波与波之间留 3 到 5 秒喘息时间。实现时可以只用一个状态计数器public class ZombieSpawner { private int wave 1; private int countDown 300; // 第一波从 5 秒后开始 private int spawnedThisWave 0; public void update(GameState state) { if (--countDown 0) return; // 每隔几帧出一只僵尸形成均匀节奏 if (spawnedThisWave % (wave 2) 0) { int row (int)(Math.random() * GameConfig.ROWS); state.addZombie(new NormalZombie(row)); } spawnedThisWave; if (spawnedThisWave wave 3) { wave; spawnedThisWave 0; countDown 180; // 波次间隔 3 秒 } } }生成坐标要注意僵尸总是从最右侧进入行号随机。同一行生成两个重叠僵尸是正常的因为移动速度可能不同最终会拉开距离反而是把生成位置写死在同一像素点会让画面看起来像卡住。难度曲线参数建议单独放配置类。比如普通僵尸 200 HP、每帧移动 0.25px60fps 下每秒约 15px穿过 1000px 画布大约要 66 秒。照着这个数据设计关卡不会出现“开局两分钟僵尸就冲到家门口”的失衡。调平衡这件事没有玄学就是不断改这几个常量然后亲自玩几局。3.4 子弹碰撞检测矩形相交、速度补偿与删除时机豌豆子弹无脑向右飞碰撞检测用最简单的 AABB 矩形相交。直接对每个子弹、每个僵尸做双循环在 9 行场景里每帧几千次相交判断毫无压力所以这个项目里不需要过度设计空间分区private boolean hit(Bullet bullet, Zombie zombie) { Rectangle a bullet.getBounds(); Rectangle b zombie.getBounds(); return a.intersects(b); }这个方案有一个经典问题子弹偶尔“穿过”僵尸。原因是子弹每帧向右移动 8px如果这一帧还在僵尸左边下一帧就到了右边两次检测中间正好跳过。常见解决办法有三种把子弹移动拆成 2 个 4px 子步每步检测一次扩大子弹碰撞矩形的宽度覆盖冲过去的距离检测时用上一帧 x 到当前帧 x 之间的扫掠矩形我一般用第二种或第三种。第二种最简单代价是手感略微宽松。命中后豌豆子弹不穿透僵尸所以要同时移除子弹并扣僵尸血。移除时如果用 for-each 遍历再 remove会踩 ConcurrentModificationException正确姿势是迭代器IteratorZombie it zombies.iterator(); while (it.hasNext()) { Zombie z it.next(); if (z.isDead()) { it.remove(); } }如果想让子弹优先打中本行最前面的僵尸把行内僵尸按 x 排序后取第一个即可排序次数不多性能不用担心。取完别忘了判空这是项目里最常见的空指针来源之一。4. Swing 项目避坑清单最容易翻车的 5 个位置4.1 画面一卡一卡图片同步加载与资源预加载的差别现象游戏刚打开没问题等第一个向日葵长大、出现新图片时画面明显卡顿一下僵尸走路跳帧。原因把 ImageIcon 加载写到了 update 或 paintComponent 里。Swing 的图片加载默认是同步读磁盘卡的就是磁盘 IO 那一瞬间操作系统缓存没生效时每次用到都要重新读。解决程序启动时把所有图片加载到一个 HashMapString, Image后续 drawImage 只从 map 里取。这个技巧就是资源预加载。GameState 初始化时调用一次 ResourceLoader加载失败立刻打日志比“用到才崩”更容易定位问题。4.2 ConcurrentModificationException遍历中删除僵尸的下场现象玩着玩着控制台抛 java.util.ConcurrentModificationException有时是 ArrayList$Itr.checkForComodification程序不一定退出但游戏逻辑卡住。原因update 里用 for (Zombie z : zombies) 遍历同时在循环里调用 removeZombiemodCount 变化后迭代器检测到异常。这不是多线程问题是集合使用问题。解决需要删除的遍历一律用迭代器或者先收集到临时列表遍历结束后统一 removeAll。养成“删除用 iterator.remove()”的条件反射面试里这也算高频 java 八股文考点但这里主要是为了让游戏不翻车。4.3 打包后可执行 jar 双击闪退先看 Main-Class 再看资源目录现象在 IDEA 里运行完全正常打出 jar 后双击没反应或在命令行 java -jar 执行时报“找不到主类”或 NoClassDefFoundError。原因打包配置没把资源目录一起打进去或 MANIFEST.MF 里没有 Main-Class 行。新手只把编译后的 class 打进去了图片和配置文件还在 src 目录运行时 getResource 找不到就闪退。这属于 java 启动失败怎么解决的典型场景之一。解决在 IDEA 的 Project Structure - Artifacts - JAR - From modules with dependencies 里选中主类并确保 resources 目录被加入 jar 根路径。然后用命令行 java -jar 执行看完整报错而不是双击。报错是唯一的后悔药看到 NoClassDefFoundError 先查 Main-Class看到找不到资源就解压 jar 看里面有没有对应目录。4.4 图片与音频路径失效new File 不可移植getResource 才行现象代码里写 new ImageIcon(src/main/resources/plant.png)在 IDEA 里能显示把项目挪到另一台电脑或打包成 jar 后图片全没了或者报空指针。原因new File 是相对当前工作目录的路径。在 IDEA 里运行工作目录是项目根目录所以能对上换一台电脑、用不同方式启动工作目录一变就挂。资源加载的正解是 classpathgetClass().getResource(/plant.png)它的规则稳定跟工作目录无关。解决把资源统一放到 resources 根目录代码里只用 getResource。这个坑和 java 环境配置无关但它跟“换个环境就启动失败”经常一起出现。我当年第一次写这个项目时所有图片都用 new File结果换电脑演示时整个画面黑屏被同事调侃了一下午。后来我给资源加载加了启动自检初始化时遍历资源清单缺一个就直接给出明确报错。4.5 绘制顺序和坐标基准不一致网格与鼠标点对不上的排查现象鼠标点在草坪中间植物种到了隔壁网格线画出来了但植物画得和网格不在同一套坐标系看起来是飘的。原因鼠标换算用的 OFFSET、绘制背景用的 OFFSET、绘制植物用的 OFFSET三处各写一遍改一处忘另一处。解决把 OFFSET_X、OFFSET_Y、CELL_WIDTH、CELL_HEIGHT 全部集中在 GameConfig绘制和事件换算都引用同一组常量。调试时在画布上临时画出 9x5 淡色网格再把鼠标换算结果打印一行肉眼验证每个格子的中心点确认没问题再删调试代码。这类坐标问题没有技术难度纯粹是防呆设计不到位。5. 做完核心玩法之后的验收与一个进阶存档怎么接5.1 一张验收清单把“能跑”变成“做完了”核心玩法写完后我会按下面这张清单完整跑三遍而不是直接去加新功能验收项操作方式预期结果资源加载正常启动游戏无黑屏、无空指针、无延迟卡顿网格种植依次点击 5x9 每个格子植物落在正确格子不能重复种阳光循环等待向日葵产出阳光数增加能购买新植物攻击判定单行放一个射手一只僵尸子弹命中扣血僵尸死亡后清除胜负条件让僵尸走到房子列弹出失败界面游戏状态重置打包运行打出 jar 后命令行执行无报错资源完整可正常游玩前三遍我会分别盯着资源加载、坐标换算和胜负条件三块因为它们正是最容易“换台机器就罢工”的环节。5.2 把植物属性抽成枚举配置给存档系统留好扩展口很多人的植物属性直接写在类里比如豌豆射手的血量写死在构造方法。更好的做法是抽成枚举配置改数值不用翻代码public enum PlantConfig { SUNFLOWER(向日葵, 50, 100, 10_000), PEASHOOTER(豌豆射手, 100, 80, 1_400), WALL_NUT(坚果墙, 50, 3_000, 0); public final String name; public final int sunCost; public final int hp; public final int attackIntervalMs; PlantConfig(String name, int sunCost, int hp, int attackIntervalMs) { this.name name; this.sunCost sunCost; this.hp hp; this.attackIntervalMs attackIntervalMs; } }把攻击力、射程也放进来之后新增植物只需加一行配置和一张图片种植逻辑完全不用动。存档系统同理把当前阳光数、波次、棋盘上的植物位置写成文本或 JSON读取后重建对象就能实现“继续游戏”。这个项目从“一周写完核心”到“周末还能扩展一个新玩法”的差距往往就靠一开始没把数值写死在各个类里。我自己的习惯是完成后强制按清单玩三遍完整对局第一遍盯资源第二遍故意在窗口不同位置启动第三遍开着调试输出盯坐标。这三遍能筛掉大部分“换台机器就罢工”的问题省下的时间正好拿去做存档和新植物希望这篇能帮到你。本文还有配套的精品资源点击获取