资讯动态

Java Swing复刻植物大战僵尸:多线程拆解与碰撞检测实战

发布时间:2026/10/25 12:42:38 来源:尧图企业网站定制
简介这套Java项目以经典塔防玩法为蓝本使用Java与Swing完整实现了一款可运行的《植物大战僵尸》复刻版适合正在学习Java SE、希望了解桌面游戏开发流程的初学者也可作为课程设计的参考案例。资源共148个文件压缩包仅3.77MB。主要文件类型包括64个png图片、38个class编译文件、24个jpg图片以及19个java源码文件其中图片资源用于植物与僵尸的视觉表现class和java文件则分别对应编译产物与可读源码。项目覆盖Swing界面搭建、多线程主循环、碰撞检测、音效播放及存档读写等关键模块借助面向对象思想将植物、僵尸、子弹等实体抽象为独立类能够帮助读者从代码层面理解一款小型游戏从设计到实现的全过程。目前已有2337人学习下载适合用于实战练手、毕业设计参考或课堂教学演示。1. 用 Java 和 Swing 复刻植物大战僵尸为什么这篇值得照着拆很多人一听“Java Swing 植物大战僵尸”就认为是课堂作业但真上手会发现Swing 做 2D 网格游戏反而比 HTML5 或 Unity 更容易盯住逻辑界面组件是现成的坐标靠 JPanel 手动管理调试时能直接看到每个组件的边界。这套工程把游戏循环拆成了 BulletMoveThread、CorpseMoveThread、MoneyEnoughThread、CarThread 四个线程而不是用一个巨型 while 硬撑这个拆法对新手理解多线程撞进 Swing UI 的边界特别有教学价值。本文按启动链路、线程协作、网格碰撞、避坑和验证的顺序拆完适合正在做课程设计、准备 Java 面试聊并发的人。2. 从类名清单反推源码骨架启动链路与窗口切换2.1 先看懂这组类文件的分工拿到这套编译后的工程第一件事不是找 main而是按名字猜职责。Controller.class 不用说是入口和全局状态IndexWin.class 是主窗口Win.class 是结算或胜利弹窗Grid.class 管理草坪网格坐标Plant.class 是植物基类MyLabel.class 大概率是承载图标和命中的自定义组件剩下四个 Thread 类分别是子弹移动、尸体消失、阳光是否足够、小车触发。这个命名习惯相当直白适合用反射或反编译工具还原源码也适合照着拼自己的实现。我一般会把职责映射成一张表写代码前先看这张表有没有循环依赖。如果 IndexWin 同时依赖 Controller 和 GridController 又持有 IndexWin就会形成双向引用虽然能跑但后续改窗口跳转时会很别扭。下面的表是这套 .class 清单对应的最可能职责列出的“替代设计”是我自己项目里的习惯供参考。类名从这个类名能判断的职责我常用的替代设计Controller程序入口、游戏总控、全局状态单例的 GameControllerIndexWin游戏主窗口包含面板和按钮GameFrameWin胜利/结算窗口Dialog 而非 JFrameGrid5x9 草坪网格行列换算二维数组 grid[row][col]Plant植物基类含生命值和攻击参数抽象类 Plant PlantType 枚举MyLabel显示植物/僵尸图标的自定义标签继承 JLabel 并持有 GameEntity 引用CorpseMoveThread僵尸移动和攻击循环放到 GameLoop 中统一调度BulletMoveThread子弹移动和碰撞检测每帧轮询子弹列表MoneyEnoughThread检查阳光数量是否允许种植在种植动作时同步校验CarThread小车触发和移动状态机 定时检查这张表能帮你少走很多弯路不要把线程类做得太细也不要全部塞进一个类。这套工程属于前者拆分清晰但线程数多因此并发修改集合的坑也格外明显后面专门有一章讲。2.2 Controller 入口和 IndexWin 的初始化一套 Swing 游戏main 里通常只有三件事设置系统外观、启动主窗口、把 Controller 绑定到窗口。常见的翻车写法是不加 SwingUtilities.invokeLater 就直接 new IndexWin()在大多数环境里能跑但在多线程工程里会埋下 EDT 混乱的隐患。我一般建议这样写public class Controller { public static void main(String[] args) { // 统一在 EDT 中创建界面避免非 UI 线程操作组件 SwingUtilities.invokeLater(() - { String lookAndFeel UIManager.getSystemLookAndFeelClassName(); try { UIManager.setLookAndFeel(lookAndFeel); } catch (Exception ignored) { // 外观设置失败不阻塞游戏用默认外观继续跑 } IndexWin frame new IndexWin(); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); frame.setVisible(true); }); } }这里用 invokeLater 包了一层是为了保证窗口创建落在事件调度线程上。setResizable(false) 对这类固定尺寸的网格游戏很重要否则用户拉大窗口后Grid 的行列换算和鼠标点击坐标会对不上。EXIT_ON_CLOSE 不是最优解最好是改成在 windowClosing 里停掉所有线程再退出这个细节在第 5 章避坑记录里展开。IndexWin 的构造器也不适合直接开始游戏。我见过有人把 new Grid(...) 和所有线程启动都放进构造器结果窗口还没显示僵尸已经走到第二行。正确的节奏是构造器只搭界面把“开始游戏”拆成独立方法等用户点击按钮或计时器触发后再真正启动逻辑。2.3 窗口切换与线程启动时机主窗口和结算窗口之间最常见的是在 IndexWin 里 new Win() 并 dispose 掉自己。但要注意Win 如果是 JFrame 而不是 Dialog会带来两个独立窗口互相抢夺焦点的问题。更稳的做法是用 Dialog并把 Controller 传进去让结算窗口能拿到当前关卡的阳光数、击杀数。下面这段是典型的开始按钮回调兼顾了界面和线程启动顺序private void startGame() { // 先清空上一次的实体列表再初始化网格最后才启动线程 plantList.clear(); zombieList.clear(); bulletList.clear(); Grid grid new Grid(parkPanel, 5, 9); controller.setGrid(grid); // 四个线程各自负责一块逻辑运行标志统一交给 Controller 管理 new BulletMoveThread(bulletList, zombieList, grid).start(); new CorpseMoveThread(zombieList, plantList, grid).start(); new MoneyEnoughThread(sunCount, controller).start(); new CarThread(carList, zombieList, grid).start(); }这段代码的逻辑顺序是“先清空数据、再重建网格、最后启动线程”顺序反了会出现第一帧访问到空引用。另一个值得注意的点是每个线程的 start 之前一定要把 running 标志置为 true而不是在构造器里默认 true。否则窗口关闭后重建游戏时旧线程尾部的一轮循环可能和新线程互相干扰。到这里启动链路已经清晰Controller 负责入口IndexWin 负责主界面Grid 负责布局四个线程在开始按钮回调里按固定顺序启动。接下来要解决的是这些线程内部的协作方式这是整个项目最值得抄的部分。3. 四个线程怎么撑起一场游戏分工、频率与并发冲突3.1 为什么不用一个 Timer 而是拆分线程Swing 自带 Timer每 10 毫秒触发一次 actionPerformed很多人图省事把子弹、僵尸、阳光生成全部塞进同一个 Timer 的监听器里。这样做有个问题一次回调里要遍历植物、子弹、僵尸三份集合任何一段卡顿都会拖垮整体帧率。更重要的是 Timer 的回调只在一个线程里排队没法体现“僵尸移动是 200ms 一帧、子弹是 30ms 一帧”这样的频率差异。这套工程用了四个独立 Thread各自循环 sleep让不同系统按自己的节奏跑。比如子弹线程每 50ms 飞一格僵尸线程每 300ms 走一格阳光线程每 7 秒生成一次金币。这样逻辑上解耦调参数时不互相影响。但随之而来的就是共享集合的并发问题后面第 5 章会专门讲。3.2 BulletMoveThread子弹遍历与删除的安全姿势子弹线程要做的动作很简单每隔固定帧遍历 bulletList把每颗子弹的 x 坐标往右移动然后检查是否和僵尸相交。这里最经典的做法是使用 CopyOnWriteArrayList 或在遍历中用迭代器避免删除元素时抛 ConcurrentModificationException。public class BulletMoveThread extends Thread { private final ListBullet bulletList; private final ListZombie zombieList; private final Grid grid; private volatile boolean running true; public BulletMoveThread(ListBullet bullets, ListZombie zombies, Grid grid) { this.bulletList bullets; this.zombieList zombies; this.grid grid; } Override public void run() { while (running) { long start System.currentTimeMillis(); // 使用迭代器遍历子弹被移出列表也不会破坏循环结构 IteratorBullet it bulletList.iterator(); while (it.hasNext()) { Bullet b it.next(); b.setX(b.getX() BULLET_STEP); if (hitZombie(b, zombieList)) { Zombie target b.getTarget(); target.setHp(target.getHp() - b.getDamage()); it.remove(); // 命中后移除子弹 } else if (b.getX() grid.getRightEdge()) { it.remove(); // 飞出场外防止列表无限膨胀 } } repaintGamePanel(); sleepFrame(start, 50); } } }这里 BULLET_STEP 是子弹每帧移动的像素数我一般取 850ms 是帧间隔相当于每秒 20 帧已经足够肉眼流畅。sleepFrame 是封装后的方法用 System.currentTimeMillis() 计算实际耗时再补足剩余时间能避免 sleep 精度问题导致子弹忽快忽慢。hitZombie 内部对子弹所属行做快速剪枝先比较 row 是否一致再判断 Rectangle 是否相交这比直接全量遍历快一个数量级。3.3 CorpseMoveThread僵尸移动、攻击与尸体消失僵尸线程不是简单地把僵尸从右往左移动它要处理三个状态移动、啃植物、死亡。当僵尸所在行列处有植物时僵尸必须停下来攻击植物被打掉后再恢复移动。这个逻辑用状态位比每次判断来得干净。public class CorpseMoveThread extends Thread { Override public void run() { while (running) { long start System.currentTimeMillis(); for (Zombie z : zombieList) { Plant p grid.getPlantAt(z.getRow(), z.getCol()); if (z.isDead()) { continue; // 尸体由专门的清理逻辑处理 } if (p ! null z.isInAttackRange(p)) { z.setAttacking(true); p.setHp(p.getHp() - z.getAttackPower()); if (p.getHp() 0) { grid.removePlant(p); z.setAttacking(false); // 植物倒下僵尸恢复移动 } } else { z.setAttacking(false); z.setX(z.getX() - ZOMBIE_STEP); grid.rowColToPixel(z); // 根据 x 更新所在格 } } respawnZombieIfNeeded(); sleepFrame(start, 300); } } }这段逻辑里最容易被忽略的是“恢复移动”那一行。如果不把 z.setAttacking(false) 写出来植物被打掉后僵尸会一直保持攻击动画但不再前进玩家会看到僵尸卡在原地。另一个细节是僵尸死亡后要放到尸体列表由专门的流程处理消失而不是立刻从集合删除否则正在播放的僵尸动画会突然消失视觉上很生硬。这个项目里单独的 CorpseMoveThread 其实还承接了尸体透明度渐变任务你可以把 setIcon 替换成带 alpha 的图标200ms 衰减一次。3.4 MoneyEnoughThread 与 CarThread低频线程的边界阳光线程的职责是每隔几秒检查当前阳光数量决定是否允许玩家种下选中植物。这个线程不需要高频循环7000ms 一次足够。但正因为频率低容易出现“阳光已经不够了界面上的数字却还停在旧值”的错觉。所以每次扣减阳光后要立刻去更新界面标签而不是等下一轮检查。小车的逻辑也一样平时几乎不做事一旦有僵尸到达某行最后第五列就触发移动并清掉该行所有僵尸。一个常见的实现是让 CarThread 循环检查所有行的临界标志而并非每帧都遍历全部僵尸。public class CarThread extends Thread { Override public void run() { while (running) { for (int row 0; row grid.getRows(); row) { if (grid.isZombieReachLine(row)) { Car car carList.get(row); if (!car.isUsed()) { car.moveAndKill(row, zombieList); } } } sleepFrame(start, 100); } } }这里 isZombieReachLine 可以只检查该行最小 x 的僵尸是否越过临界列。车移动后要置为 used避免反复触发。整个项目的线程协作到此闭环子弹线程主动出击僵尸线程受击响应阳光线程管理经济小车线程兜底防线。4. 网格坐标与碰撞检测Grid 和 Plant 的落地实现4.1 把像素坐标转成行列Grid 的核心换算Swing 界面是像素坐标系而植物大战僵尸的玩法是行列坐标系。界面上草坪通常是 5 行 9 列每个格子宽 80、高 100。鼠标点击的像素位置要换算成 row、col用到的公式很简单row y / cellHeightcol x / cellWidth。但这三个参数必须和 JPanel 的实际大小严格对齐否则点击会偏一格。下面是 Grid 的骨架代码public class Grid { private final int rows; private final int cols; private final int cellWidth; private final int cellHeight; private final Plant[][] plants; public Grid(int rows, int cols, int cellWidth, int cellHeight) { this.rows rows; this.cols cols; this.cellWidth cellWidth; this.cellHeight cellHeight; this.plants new Plant[rows][cols]; } public int getRow(int y) { return Math.max(0, Math.min(rows - 1, y / cellHeight)); } public int getCol(int x) { return Math.max(0, Math.min(cols - 1, x / cellWidth)); } public boolean canPlant(int row, int col) { return row 0 row rows col 0 col cols plants[row][col] null; } public void setPlant(int row, int col, Plant p) { plants[row][col] p; p.setRow(row); p.setCol(col); } }注意 getRow 和 getCol 里的 clamp 逻辑如果鼠标点在草坪外也不能返回负数否则数组越界。setPlant 里把 row、col 写回植物对象这一步能避免后续子弹线程每次都被迫去遍历 Grid 才能知道植物在哪行。整个 Grid 类只做数据管理不负责绘制绘制还是交给 JPanel 的 paintComponent。4.2 Plant 基类与多种植物的扩展方法Plant 承担两个职责保存属性生命值、攻击力、攻击间隔、阳光消耗以及定义每次攻击的行为。不同的植物只是参数不同用继承来做会很快把类层级搞得很深。我一般会更推荐用 PlantType 枚举配合参数表但在 Swing 课程设计里继承反而直观。public class Plant { protected int hp; protected int attack; protected int sunCost; protected int row; protected int col; protected int attackInterval; // 攻击间隔单位毫秒 protected long lastAttackTime; public void attack(ListBullet bulletList) { // 子类覆盖此方法例如豌豆射手创建子弹并放入 bulletList } public boolean isReadyToAttack() { return System.currentTimeMillis() - lastAttackTime attackInterval; } }子类比如 Peashooter 要覆盖 attack 方法在方法里 new 一颗子弹设置初始 x 为植物 x 坐标加 20y 为植物所在行的中线然后丢进 bulletList。Sunflower 则覆盖为增加阳光计数。这样就做到了“逻辑在 Plant绘制在 MyLabel”BulletMoveThread 只认 bulletList不关心植物类型。4.3 碰撞检测落地矩形相交和优先剪枝碰撞检测最常见的做法是让每个实体持有一个 Rectangle 区域每帧调用 intersects 判断。虽然像素级检测更准但在这种棋盘式游戏里性能不够也没有必要。Plant 和 Zombie 的碰撞可以简化为矩形相交子弹和僵尸的碰撞先用行号剪枝再取矩形。private boolean hitZombie(Bullet b, ListZombie zombieList) { for (Zombie z : zombieList) { if (z.isDead() || z.getRow() ! b.getRow()) { continue; // 行号不相等直接跳过快速剪枝 } Rectangle bulletRect b.getBounds(); Rectangle zombieRect z.getBounds(); if (bulletRect.intersects(zombieRect)) { b.setTarget(z); return true; } } return false; }这里 getBounds 返回的可以是 JLabel 的实际边界如果图片有透明边角效果会略微放大碰撞范围但绝大多数课程设计都接受这个误差。如果你想让手感更贴近原版可以把矩形四周各收缩 5 像素再参与 intersects。碰撞后子弹要立即移除僵尸血量扣减并且只在血量归零时标记死亡不要在碰撞瞬间移除僵尸否则子弹还在飞但没有目标视觉上会穿模。到这里网格和碰撞的落地方式已经明确。真正写起来出问题的地方往往不在算法而在并发和 UI 刷新。下面这一章是我最想让你保存的避坑记录。5. 避坑记录Swing 游戏里最容易翻车的五个并发细节5.1 正遍历着 List 就删元素ConcurrentModificationException 频繁爆现象游戏运行几秒后控制台抛 java.util.ConcurrentModificationException子弹或僵尸的集合被多个线程同时修改。原因BulletMoveThread 在遍历 bulletList而种植动作或碰撞清理在另一个线程里 remove同一个 ArrayList 在迭代过程中被结构性修改。解决把所有共享集合改成 CopyOnWriteArrayList或统一用 Iterator 删除避免在 for-each 中直接 list.remove。我一般会把实体列表都声明为 CopyOnWriteArrayList虽然写入多时性能略差但胜在稳定。要注意的是 CopyOnWriteArrayList 的 Iterator 是弱一致的它不保证遍历过程中看到最新的添加所以在同一个线程里边加边遍历时需要用别的方式取新增元素比如在下一次循环自然读到。5.2 点击种植没反应鼠标坐标和网格对不上现象鼠标点在第一格植物却种到第二格或者完全没动静点击位置恰好落在格子边界时尤其明显。原因Grid 构造时 cellWidth 和 cellHeight 是写死的但 JPanel 实际尺寸和固定 panel 尺寸有边框偏移换算时没有减掉边框宽度。解决在 Grid 的坐标转换里先减去面板边框例如 (x - PANEL_LEFT) / cellWidth并且把整个网格面板的边框宽度定为常量。还有一个容易忽略的地方鼠标事件拿到的是相对于面板的坐标如果面板嵌在带 title 的窗口里坐标原点会受窗口装饰影响最好统一用 MouseEvent.getPoint() 再配合 SwingUtilities.convertPoint 换算到 Grid 面板坐标系。5.3 窗口关掉了游戏进程还在CPU 占用降不下来现象点窗口关闭按钮界面消失但任务管理器里 java 进程几秒后才能退出甚至一直在占用 CPU。原因JFrame.EXIT_ON_CLOSE 只能结束 EDT四个业务线程还在 while 循环里跑没有收到退出信号。解决在 windowClosing 事件里把 running 置为 false并调用 join() 等所有线程退出后再 dispose。frame.addWindowListener(new WindowAdapter() { Override public void windowClosing(WindowEvent e) { controller.shutdownAllThreads(); // 依次置 running false controller.joinAllThreads(); // 用 1000ms 超时防止个别线程卡住 frame.dispose(); } });这段代码里 joinAllThreads 要设超时不能无限等。如果某个线程在 sleep 中没退出join 会一直阻塞用超时可以保证窗口一定关掉同时让线程自然进入死亡。5.4 画面闪烁、动画拖影像幻灯片一样现象子弹和僵尸移动时画面有残影或者整个面板刷新极不规律连着卡顿几帧又恢复正常。原因在子弹线程或僵尸线程里直接调用 setIcon 和 repaint非 EDT 线程操作组件导致重绘排队错乱。解决把 UI 更新全部通过 SwingUtilities.invokeLater 提交到 EDT或者只用主线程每隔固定时间统一 repaint业务线程只改数据。我自己的习惯是让业务线程只更新 x、y、hp 这些纯数据然后调用一个统一的 refreshPanel()refreshPanel 内部用 invokeLater 触发 repaint。这样即便多个线程同时改数据Swing 也会在最合适的时机合成一帧画面不容易闪。5.5 僵尸杀掉植物后原地罚站不给后续僵尸让路现象僵尸啃完植物后仍保持攻击姿态后面的僵尸堵成一排游戏难度瞬间爆炸。原因植物被移除后僵尸的攻击状态没有复位attacking 仍然为 true导致移动分支被跳过。解决在 removePlant 后强制将周围僵尸的 attacking 置为 false并在每一帧开始时根据当前植物坐标重新评估攻击目标。这个坑在平时写单线程代码时完全不会触发多线程下因为状态分散在不同对象里尤其容易漏。我通常会在 Plant 上维护一个被攻击状态植物被移除时回调一个 onPlantRemoved 方法让相关行所有僵尸立刻重新扫描目标而不是等下一帧才发现空位。6. 从“能跑”到“能开源”验证玩法与扩展技巧你照着拆完这套工程第一步不是加功能而是先验证多线程协作是否真的稳定。我最常用的手段是加一个 Debug 开关用一个 volatile boolean debug在子弹线程里每 50 帧把子弹坐标、僵尸血量、阳光数打一行日志连续跑 10 分钟看坐标是否单调递增、血量是否准时归零。这样能很快发现谁在偷偷改共享数据。想要快速验证游戏手感把参数抽到一个配置类里最划算。BULLET_STEP、ZOMBIE_STEP、ATTACK_INTERVAL、阳光生成间隔全部提成静态常量甚至放到一个 game.properties 里用 Properties 加载。这样调平衡时不用重新编译代码改完配置重启游戏就能试。如果想让项目从课程设计变成能放到简历上的作品我建议优先加三块一是序列化存档用 ObjectOutputStream 把当前阳光数、植物二维数组、僵尸列表写进 save.dat下次启动时反序列化恢复这正好对应 Java 数据持久化考点二是替换掉默认 Swing 图标用真正的僵尸和豌豆图片只要把 MyLabel 的 setIcon 换成从 classpath 读取的 ImageIcon 就行三是给五个类加上日志注解把每个线程的开始和结束时间打印出来面试的时候能顺口讲清楚线程生命周期。从那以后我每次拿到别人的 Swing 游戏工程都会先看三件事窗口关闭时线程有没有 join每条 repaint 是不是都在 EDT集合遍历有没有用迭代器。看完这三处基本就能判断这个项目是能上手改还是只供演示。这套植物大战僵尸的线程拆分比大多数课程设计都干净值得下载下来跑一遍把它改成自己的版本。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑