Java Swing实战:从零写出植物大战僵尸,掌握游戏循环与面向对象核心
简介一个基于Java与Swing实现的经典塔防游戏复刻项目适合正在学习Java GUI编程、多线程与游戏开发的初学者或课程设计参考。资源完整包含源码工程与可运行类文件共148个文件其中19个java源文件对应植物、僵尸、子弹等核心类38个class为编译产物另有64个png素材与24个jpg图片用于界面和动效压缩包仅3.77MB便于下载后直接导入IDE查看。项目覆盖多线程主循环、矩形碰撞检测、音效播放及游戏进度序列化存档等关键知识点可作为理解面向对象设计与游戏状态管理的极佳范例。目前已吸引2336人学习下载尤其适合需要完成Java课程设计或希望深入Swing实操的读者。1. 把植物大战僵尸跑在 Java Swing 里值得你动手一次「植物大战僵尸 Java Swing」这个组合看起来像个课程设计作业但真正动手把它做出来你会发现它把 Java 面向对象、GUI 事件派发、碰撞检测、帧循环这些核心机制全串起来了。不是刷几道面试题能替代的实战路径。这个项目适合两类人一是刚学完 Java 基础、想找一个能写进简历的中型练手项目的人二是已经在做 Java 开发、但对 Swing 这套老派 GUI 框架始终停留在听说过状态的从业者。它不复杂但坑不少把一个能玩的版本跑通你对 Java 的理解会上一个台阶。2. 用 Swing 做游戏底子框架选型与游戏循环设计2.1 为什么选 Swing 而不是 JavaFX生态与学习成本的平衡很多人在动手前会犹豫JavaFX 不是更现代吗为什么还要用 Swing我的答案很简单课程设计和面试场景里Swing 的认知度仍然极高而且它的渲染模型足够简单适合拆开看原理。Swing 是轻量级组件框架所有绘制都发生在 JVM 进程内不依赖原生窗口控件。这就意味着你可以完全控制每个像素的绘制逻辑这对游戏开发来说反而是好事。JavaFX 虽然提供了更丰富的动画支持和 CSS 样式但它的学习曲线更陡而且很多高校和企业内部系统仍然跑在 Swing 上你写一个 Swing 游戏项目反而比其他框架更容易被读懂。另一个实际考虑是Swing 的绘制刷新机制是暴露在外面的。paintComponent方法由谁调用、在什么时机调用、怎么手动触发重绘这些在 JavaFX 里被封装掉了你很难感知到背后的调度逻辑。而对游戏开发来说理解「什么时候画、画在哪里、什么时候该擦掉重画」恰恰是最核心的能力。2.2 用 Timer 搭游戏主循环delay 参数、repaint 时机与帧率控制Swing 游戏的主循环不推荐用while(true)加Thread.sleep()那是 AW T 时代的写法在 Swing 里会阻塞事件派发线程EDT直接导致界面假死。常见做法是使用javax.swing.Timer它在每次触发时回调actionPerformed并且天然运行在 EDT 上不会和界面绘制抢线程。public class GamePanel extends JPanel implements ActionListener { private Timer timer; private int frameInterval 16; // 单位毫秒约 60 FPS public GamePanel() { // 初始化和布局代码省略 timer new Timer(frameInterval, this); timer.start(); } Override public void actionPerformed(ActionEvent e) { updateGameState(); // 更新所有植物、僵尸、子弹的位置和状态 repaint(); // 触发 paintComponent 重绘 } Override protected void paintComponent(Graphics g) { super.paintComponent(g); drawBackground(g); drawPlants(g); drawZombies(g); drawBullets(g); drawSunCount(g); } }这段代码的逻辑是Timer每 16 毫秒触发一次actionPerformed先更新游戏状态移动僵尸、飞行子弹、检查碰撞然后调用repaint()请求重绘。Swing 的重绘机制会把所有repaint()请求合并到一次绘制周期里执行所以你不必担心中间状态的绘制错乱。frameInterval这个参数要根据实际场景调整如果只有 30 FPS 的动画要求把 delay 设成 33 毫秒即可能显著降低 CPU 占用。但要注意Timer的 delay 并不保证精确帧率它只保证两次触发之间至少间隔这么久。如果updateGameState里有耗时操作实际帧率会低于预期这时要优先优化逻辑代码而不是把 delay 调小。Swing 绘制的另一个关键点是双缓冲。JPanel默认开启了双缓冲也就是说paintComponent里的绘制内容先写入内存中的后备缓冲区再一次性刷到屏幕上。这能有效避免闪烁。如果你自己继承JComponent或者用Canvas就得手动处理双缓冲问题后面的避坑章节我会展开讲。3. 给植物和僵尸建模面向对象的继承与多态落地3.1 基类设计Plant、Zombie、Bullet 的继承树与抽象方法植物大战僵尸这个项目最出彩的地方在于它的对象体系天然适合继承和多态。植物有向日葵、豌豆射手、坚果墙僵尸有普通僵尸、路障僵尸、铁桶僵尸行为各不相同但又有共性。用抽象基类把这些共性收拢用子类覆盖差异这是纯教科书级别的练习。public abstract class Plant { protected int x, y; // 所在格子的像素坐标 protected int hp; // 生命值 protected int cost; // 阳光消耗 protected int attackInterval; // 攻击间隔单位毫秒 protected long lastAttackTime; // 上次攻击时间戳 protected int gridRow, gridCol; // 所在网格行列 public Plant(int row, int col, int hp, int cost, int attackInterval) { this.gridRow row; this.gridCol col; this.x col * GRID_WIDTH OFFSET_X; this.y row * GRID_HEIGHT OFFSET_Y; this.hp hp; this.cost cost; this.attackInterval attackInterval; this.lastAttackTime 0; } // 子类必须实现攻击行为向日葵不攻击豌豆射手发射子弹 public abstract void attack(ListBullet bullets, long currentTime); // 通用受伤逻辑子类可覆盖来改变受伤表现 public void takeDamage(int damage) { this.hp - damage; if (this.hp 0) { // 标记死亡由外部逻辑从集合中移除 } } }这里有几个设计细节需要注意。lastAttackTime用long类型存时间戳比较时用System.currentTimeMillis()比用int计帧数更精确因为帧率不稳定时按帧计数会导致攻击频率失真。attack方法设计成抽象方法把「怎么攻击」留给子类决定——豌豆射手发射子弹向日葵直接空实现坚果墙也可以空实现这样调用方不需要知道具体植物类型统一调用attack即可。3.2 子类实现豌豆射手、向日葵、坚果墙怎么覆盖行为public class Peashooter extends Plant { public Peashooter(int row, int col) { super(row, col, 300, 100, 1400); // 300血100阳光1.4秒攻击间隔 } Override public void attack(ListBullet bullets, long currentTime) { if (currentTime - lastAttackTime attackInterval) { bullets.add(new PeaBullet(x 40, y, 8, 10)); lastAttackTime currentTime; } } } public class Sunflower extends Plant { public Sunflower(int row, int col) { super(row, col, 300, 50, 0); } Override public void attack(ListBullet bullets, long currentTime) { // 向日葵不攻击阳光生成逻辑交给单独的定时任务处理 } } public class WallNut extends Plant { public WallNut(int row, int col) { super(row, col, 4000, 50, 0); // 4000血纯防御 } Override public void attack(ListBullet bullets, long currentTime) { // 无攻击行为覆盖为空 } }参数设置上攻击间隔 1400 毫秒是参考原版手感调的实际跑起来你可以改成 1000-2000 之间的值来改变难度。Peashooter发射子弹时子弹初始位置x 40是为了让子弹从植物前方的出膛口生成这个偏移量要和素材的尺寸匹配否则子弹会从植物中心位置冒出来看起来非常违和。3.3 用 ArrayList 管理场上所有对象遍历、移除与并发修改所有植物、僵尸、子弹都存进 Panel 层的集合里。这里最常见的问题是在遍历集合时删除元素导致ConcurrentModificationException。不要用for-each循环里直接remove()应该用迭代器的iterator.remove()或者先标记后移除。public class GamePanel extends JPanel { private ListPlant plants new ArrayList(); private ListZombie zombies new ArrayList(); private ListBullet bullets new ArrayList(); private void removeDeadObjects() { // 使用迭代器遍历安全移除 IteratorPlant plantIt plants.iterator(); while (plantIt.hasNext()) { Plant p plantIt.next(); if (p.hp 0) { plantIt.remove(); } } IteratorZombie zombieIt zombies.iterator(); while (zombieIt.hasNext()) { Zombie z zombieIt.next(); if (z.hp 0 || z.x 0) { zombieIt.remove(); } } IteratorBullet bulletIt bullets.iterator(); while (bulletIt.hasNext()) { Bullet b bulletIt.next(); if (b.isOutOfBounds()) { bulletIt.remove(); } } } }注意removeDeadObjects的执行时机放在updateGameState()的最后也就是所有对象状态更新完毕、准备重绘之前。如果放在绘制过程中执行这段代码轻则性能下降重则导致正在绘制的对象被移除从而抛出异常。判断僵尸死亡的时机也很关键x 0表示僵尸走到了最左侧攻击到房子这时要移除并通过游戏结束不能让它继续留在集合里空转。4. 把核心玩法跑通种植、射击、碰撞与刷新机制4.1 格子种植与阳光扣费鼠标点击坐标与网格映射种植功能是这个项目的交互核心。玩家点击草地上的某个格子系统判断是否满足种植条件阳光够不够、该格是否为空、是否在可种植区域。坐标映射是第一步——把鼠标点击的像素坐标换算成网格的行列号。public class GamePanel extends JPanel implements MouseListener { private static final int GRID_COLS 9; private static final int GRID_ROWS 5; private static final int GRID_WIDTH 80; private static final int GRID_HEIGHT 100; private static final int GRID_OFFSET_X 40; private static final int GRID_OFFSET_Y 100; Override public void mouseClicked(MouseEvent e) { int clickX e.getX(); int clickY e.getY(); // 先判断是否点击在种植区域内 if (clickX GRID_OFFSET_X || clickX GRID_OFFSET_X GRID_COLS * GRID_WIDTH || clickY GRID_OFFSET_Y || clickY GRID_OFFSET_Y GRID_ROWS * GRID_HEIGHT) { return; } int col (clickX - GRID_OFFSET_X) / GRID_WIDTH; int row (clickY - GRID_OFFSET_Y) / GRID_HEIGHT; // 检查该格子是否已经有植物 if (isGridOccupied(row, col)) { return; } // 检查阳光数量是否足够 if (sunCount selectedPlantCost) { sunCount - selectedPlantCost; Plant plant createPlantBySelectedType(row, col); plants.add(plant); repaint(); } else { // 提示阳光不足 } } }坐标计算的公式很简单(点击坐标 - 偏移量) / 格子宽高取整后就是行列号。这里最容易出错的是边界值处理如果点击到格子右边缘刚好等于OFFSET_X GRID_COLS * GRID_WIDTH会被判定为越界这是正确行为但如果偏移量没写对所有格子都会错位。种植前必须检查isGridOccupied否则玩家可以在同一个格子里叠加种植多个植物。这个检查可以用一个二维布尔数组维护也可以用plants列表实时遍历判断。数据量小时最多 45 个格子实时遍历也很快但二维布尔数组的代码更直观而且方便后期加入更多地面状态逻辑。4.2 子弹与僵尸的碰撞检测矩形相交判定与伤害结算子弹飞行到僵尸身上要造成伤害这个检测不需要精确到像素级别的碰撞。用矩形相交判定就够了把子弹和僵尸都抽象成矩形调用Rectangle.intersects()方法判断是否重叠。原版游戏里子弹是飞行道具僵尸是一格宽、两格高的单位矩形模型完全够用。public class PeaBullet { private int x, y; private int speed 8; // 每帧移动像素数 private int damage 20; private int width 20; private int height 20; public void update() { x speed; } public Rectangle getBounds() { return new Rectangle(x, y, width, height); } } public class Zombie { private int x, y; private int hp 200; private int width 60; private int height 100; public Rectangle getBounds() { return new Rectangle(x, y, width, height); } }碰撞检测的逻辑放在updateGameState里遍历所有子弹再遍历所有僵尸用bullet.getBounds().intersects(zombie.getBounds())判断。一旦命中僵尸扣血、子弹移除。private void checkBulletCollisions() { IteratorBullet bulletIt bullets.iterator(); while (bulletIt.hasNext()) { Bullet bullet bulletIt.next(); for (Zombie zombie : zombies) { if (bullet.getBounds().intersects(zombie.getBounds())) { zombie.takeDamage(bullet.getDamage()); bulletIt.remove(); break; // 一颗子弹只命中一个僵尸 } } } }这个实现有一个隐藏问题当多个僵尸排成一列时子弹只会命中第一个碰到的僵尸因为break跳出了僵尸循环。这是符合直觉的——子弹不能穿过第一个目标去打后面的敌人。但如果你希望子弹有穿透效果比如冰豌豆的减速是单体就另当别论。另外子弹移动速度speed 8配合 60 FPS 大约是每秒 480 像素这个速度保证子弹不会穿过一格的僵尸。如果调大速度到 20 以上就可能出现子弹瞬间越过僵尸而检测不到碰撞的穿模问题这时候需要做上一帧位置的线段检测。4.3 阳光生成与僵尸刷新随机概率、定时器与难度曲线阳光和僵尸是游戏里两个自动产出的资源它们的节奏决定了游戏难度。阳光用固定间隔加随机位置生成僵尸用定时分批刷新前期稀疏后期密集。public class SunManager { private Timer sunTimer; private int sunInterval 6000; // 基础掉落间隔 6 秒 private Random random new Random(); public SunManager(GamePanel panel) { sunTimer new Timer(sunInterval, e - spawnSun(panel)); sunTimer.start(); } private void spawnSun(GamePanel panel) { // 每 6 秒自然掉落一个阳光位置在格子区域内随机 int x GRID_OFFSET_X random.nextInt(GRID_COLS * GRID_WIDTH); int y GRID_OFFSET_Y random.nextInt(GRID_ROWS * GRID_HEIGHT); panel.addSun(new Sun(x, y, 50)); // 50 阳光值 } } public class WaveManager { private int wave 0; private long lastWaveTime 0; private int zombieInterval 8000; // 僵尸生成间隔随波次缩短 public void update(GamePanel panel, long currentTime) { if (currentTime - lastWaveTime zombieInterval) { spawnZombie(panel); lastWaveTime currentTime; // 每波结束后下一波间隔缩短 500ms下限 1000ms zombieInterval Math.max(1000, zombieInterval - 500); } } }这里的难度曲线用最简单的方式实现每生成一个僵尸间隔时间减 500 毫秒直到最短 1 秒。原版游戏的曲线是分波次的每大波之间有喘息时间。如果你想更接近原版手感可以把spawnZombie改成按批次刷新每 10 秒一波、一波出 3-5 个波与波之间有 15 秒的空档。阳光掉落用固定 6 秒间隔但向日葵产生的阳光是另一套逻辑。向日葵的阳光生成应该绑定到植物自身用lastAttackTime判断间隔而不是全局定时器——否则所有向日葵会在同一秒集体产阳光场面非常奇怪。这也是用抽象方法attack的好处之一向日葵的「攻击」就是产阳光只不过要把生成的阳光加入游戏场景而不是子弹列表。5. 避坑报告这些 Bug 让我在深夜反复返工5.1 现象窗口一启动就卡死鼠标点击没反应原因初始化代码里写了while (true)死循环或者Thread.sleep()调用在main线程里阻塞了事件派发线程EDT。Swing 的所有界面响应都在 EDT 上执行这个线程一旦被阻塞整个窗口就变成黑匣子点击、重绘全部失效。解决所有耗时逻辑游戏循环、碰撞检测、对象更新放进javax.swing.Timer的回调里由 Timer 在 EDT 上调度。不要手动创建线程去更新 UI那是 AWT 时代遗留的馊主意。如果你的逻辑确实需要单独线程比如加载音频、读取资源用SwingUtilities.invokeLater把 UI 更新部分切回 EDT。5.2 现象子弹穿过了僵尸但没造成任何伤害原因子弹速度过快相邻两帧之间子弹完全越过了僵尸所在的矩形区域。例如速度 30 像素/帧、僵尸矩形宽 60 像素只要子弹在两帧内的位移超过僵尸宽度就可能跳过命中判定。这种情况在子弹速度为个位数时不会出现一调大就翻车。解决不要单纯调大速度来改善手感。要么限制速度上限要么改用上一帧位置做线段相交检测。通用做法是保留子弹上一帧的 x 坐标判断(prevX, y)到(currentX, y)这条线段是否与僵尸矩形相交用java.awt.geom.Line2D的intersects(Rectangle2D)方法即可。5.3 现象游戏运行一分钟之后越来越卡内存占用持续上涨原因子弹和僵尸死亡后没有从集合中移除仍然在遍历中参与碰撞检测。更隐蔽的是paintComponent里可能每次绘制都创建大量临时对象比如字符串拼接的Font、不必要的Rectangle实例这些对象频繁创建导致 GC 压力飙升。解决严格在每帧更新逻辑的最后清理死亡对象用迭代器安全移除。另外把游戏中固定不变的Font、Image、Color等对象定义为静态常量避免每次重绘时重复创建。阳光对象掉落完毕被点击后要记得从ListSun中移除这个对象很容易被漏掉。5.4 现象游戏窗口缩放或拖动时画面闪烁严重原因如果你继承了JComponent或者自定义了顶层容器双缓冲可能没生效。默认JPanel是开了双缓冲的但有些人为了省事直接继承JComponent画图结果丢失了双缓冲重绘时先擦白再画闪得眼睛疼。解决检查类声明是否是extends JPanel如果你的类确实继承自JComponent在构造函数里手动开启setDoubleBuffered(true)。更稳妥的做法是在paintComponent里直接使用传进来的Graphics g不要自己创建新的Graphics或调用getGraphics()后者的绘制结果很可能不会正确叠加到缓冲中。5.5 现象点击种植植物总是种到旁边一格原因鼠标坐标系和棋盘绘制坐标系没对齐。常见情况是JPanel外层的容器有边框或者内部存在Insets导致e.getX()相对于 Panel 原点但你绘制棋盘时用了全局坐标偏移两者没有换算。解决绘图时统一以面板左上角为原点先确认地图偏移量GRID_OFFSET_X/Y是相对于面板的坐标。在paintComponent里临时打印鼠标坐标和计算出的行列号对比一下三分钟就能查出来。不要用e.getXOnScreen()这类屏幕绝对坐标多显示器环境下会出莫名其妙的问题。6. 让项目真正拿得出手五个进阶升级点游戏跑通只是第一步如果你的目标是课程设计拿高分、面试能讲出亮点下面这几个升级方向值得做。第一个是给游戏加状态机。MAIN_MENU、PLAYING、PAUSED、GAME_OVER四个状态用一个Enum管理paintComponent和actionPerformed里按状态分支处理。这是面试官最想看到的代码结构比在一个类里堆十几个布尔标志位优雅得多。第二个是资源加载优化。不要用new ImageIcon(path)每次都从磁盘读图片启动时统一加载到静态 Map 里用字符串 key 复用。图片全部放resources/images/目录打包时注意路径不要带绝对路径。第三个是音频接入。Swing 里做背景音乐最省事的方案是使用javax.sound.sampled.Clip预加载后再循环播放。要注意音频格式和采样率问题WAV 格式兼容性最好MP3 需要额外解码库我一般会选 WAV 规避这个坑。第四个是鼠标悬停预览。在mouseMoved事件里记录悬停格子绘制时给该格子画一个半透明覆盖层提示玩家将要种在哪里。这个视觉效果非常加分实现成本也低只需要在paintComponent里多加一个矩形绘制。第五个是存档与读档。把场上植物的行列、植物类型、阳光数量、当前波次序列化成 JSON 或 Properties存档时写文件读档时还原。这个功能能体现出你对数据持久化的理解而且实现起来不复杂。最后一个习惯分享给你每改完一个功能跑一次完整对局再提交代码。植物大战僵尸这种两方对抗的游戏很多 Bug 要玩三分钟之后才暴露比如向日葵产阳光的累积效应、后期僵尸密度导致的内存压力。我自己的教训是——写完碰撞检测当天没测第二天加完僵尸刷新后一跑满屏子弹打空气排查了半天才发现是上一帧位置存储的容器没初始化。代码是写出来的也是熬夜排错排出来的值得的地方在于这些坑在未来任何一个游戏项目里都会用得上。希望帮到你。本文还有配套的精品资源点击获取