简介基于Java Swing实现的《植物大战僵尸》完整项目面向正在学习Java SE与GUI编程的开发者可帮助理解如何用Swing组件搭建一个交互式桌面小游戏。压缩包共155个文件大小19.15MB包含20个Java源码、39个class编译产物以及png/gif/jpg图片素材、wav音效和一份docx说明文档素材与代码分离、目录结构清晰。源码配有全量注释docs文档对游戏逻辑、设计决策和核心组件做了进一步解释涉及JFrame主窗口、JPanel绘图区域、事件监听器、Graphics2D绘图、多线程动画、MVC设计模式与布局管理器等关键知识点。从植物种植、僵尸移动到子弹碰撞与能量判定读者可以顺着注释和文档读懂完整的游戏循环。已有438人学习下载适合想通过实战项目巩固Java基础、入门Swing游戏开发的学习者也可作为课程设计或自学练手的参考。1. 用 java Swing 做植物大战僵尸一个把基础语法全串起来的实战项目新手学 Java 最纠结的事就是面向对象、集合、事件、线程这些知识点都学完了但做题只会写算法一到做项目就不知道怎么组织代码。用 java Swing 做的植物大战僵尸恰好是能把 java 基础、面向对象编程 java 思想、集合框架和事件处理全部串起来的课程设计经典选题。别误会这项目不是让你拿 Swing 拖几个按钮做界面它的核心是用 Swing 的事件分发机制驱动一整个游戏循环——僵尸从右边走出来、植物在格子里开火、阳光往下掉全都是你手动用代码驱动的。只要是系统学过 java 基础、正在找 java 课程设计案例源码、或者单纯想把语法落地的读者都很适合拿这个方向练手。我接下来按一个可复现的方案拆解怎么组织窗口、怎么写循环、怎么让碰撞和阳光经济系统跑起来以及帮你避开那些让新手反复翻车的坑。2. 游戏主循环与双缓冲绘制Swing 游戏的地基2.1 为什么不能直接用 while(true) 当游戏循环很多新手拿到这个项目第一反应是写一个while(true)循环把僵尸的坐标往前挪然后调用界面刷新。这个思路本身没错但放进 Swing 里就出问题了。Swing 的事件分发线程EDT是所有界面组件的“黑匣子”哪怕只是一个进度条更新也必须交给它处理。你在主线程里写个死循环EDT 根本没机会处理鼠标点击、窗口重绘表现就是窗口假死、点按钮没反应、CPU 飙到 100%。常见做法是用javax.swing.Timer驱动游戏循环。它不是普通的定时器而是把回调事件投递到 EDT 队列里每次触发都天然和界面线程安全协作。核心代码骨架是这样的public class GamePanel extends JPanel { private Timer gameLoop; private ListZombie zombies new ArrayList(); private ListPlant plants new ArrayList(); public void startGame() { // 每 16 毫秒触发一次约等于每秒 60 帧 gameLoop new Timer(16, e - updateAndRender()); gameLoop.start(); } private void updateAndRender() { // 逻辑更新让所有僵尸向前移动、植物开火、子弹位移 updateGameState(); // 触发界面重绘paintComponent 会在下一次绘制时被调用 repaint(); } }注意这里的Timer构造方法里传了两个参数间隔毫秒数和ActionListener监听器。间隔 16 毫秒意味着理论帧率约 60 FPS实际运行时如果逻辑计算量太大帧率会自然掉下来。updateGameState负责改内存里的坐标和血量repaint只负责通知系统“该画了”两者严格分开。这套“逻辑”和“渲染”分离的思想学完再回头看会非常有用。2.2 双缓冲绘制paintComponent 是唯一的画布游戏画面必须在paintComponent方法里完成这是 Swing 留给你的唯一合法画布。很多人一开始直接在监听器里调用getGraphics()画图画完一刷新就没了或者闪烁严重。原因就是绘制没有走组件的正规重绘流程也没有双缓冲保护。双缓冲的意思是系统先在内存里把整张画面画好再一次性地拷贝到屏幕上避免一条线一条线地显示出来造成闪烁。Swing 的JPanel默认已经开启了双缓冲你只要老老实实在paintComponent里画就行。Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 先清掉上次绘制的残留内容 Graphics2D g2 (Graphics2D) g; // 绘制草地网格背景 for (int row 0; row 5; row) { for (int col 0; col 9; col) { g2.drawRect(col * CELL_WIDTH, row * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT); } } // 绘制植物 for (Plant p : plants) { g2.drawImage(p.getImage(), p.getX(), p.getY(), this); } // 绘制僵尸 for (Zombie z : zombies) { g2.drawImage(z.getImage(), z.getX(), z.getY(), this); } }这段代码里super.paintComponent(g)是必须保留的它负责用背景色把整块面板清空。你不调用它上一帧的图形残留会叠在新画面上形成“拖影”。还有一个容易被忽略的点绘制顺序决定了遮挡关系你要先画地图、再画植物、最后画僵尸这样僵尸踩在植物前面的视觉效果才自然。如果你有地面装饰、子弹、阳光特效也按从底层到顶层的顺序画。2.3 初始化和启动的线程规则窗口和游戏面板的创建必须放在 EDT 上这是很多新手第一行代码就踩坑的地方。标准启动代码是public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(() - { JFrame frame new JFrame(植物大战僵尸); GamePanel panel new GamePanel(); frame.setContentPane(panel); frame.setSize(960, 540); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); frame.setVisible(true); panel.startGame(); }); } }SwingUtilities.invokeLater会把创建界面的任务丢给 EDT 异步执行保证窗口的创建、显示和后续交互都在同一条线程里。很多人在main里直接new JFrame也能跑但遇到复杂启动逻辑时不按这个规范写容易遇到随机性的界面卡死。这个习惯最好连 java 面试题里问“Swing 是线程安全的吗”的时候一起记住——不是线程安全的所有组件访问必须在 EDT。3. 植物与僵尸的面向对象设计用多态撑起整个游戏3.1 抽象类 Plant把共性行为锁进基类有了画面循环接下来要思考最核心的问题豌豆射手、向日葵、坚果墙这些植物行为完全不同怎么用一套代码统一管理所有植物有共同属性占哪个格子、当前血量、种植阳光消耗、是否已经死亡。不同点在于“每帧做什么”——向日葵每 9 秒产出阳光豌豆射手每 1.4 秒发射一颗豌豆坚果墙什么也不做就站在那挨打。这种场景就是设计模式的教科书范例把共同部分放进抽象类把差异部分留给子类实现。我通常会这么建模public abstract class Plant { protected int row; protected int col; protected int hp; protected int sunCost; protected boolean isDead; // 构造时就把格子和血量固定下来 public Plant(int row, int col, int hp, int sunCost) { this.row row; this.col col; this.hp hp; this.sunCost sunCost; } // 每帧行为子类各自实现 public abstract void update(); public int getX() { return col * GamePanel.CELL_WIDTH; } public int getY() { return row * GamePanel.CELL_HEIGHT; } }这里的update()是抽象方法强制每个子类回答“你这一帧要干什么”。这么做的好处是游戏循环里只需要一行代码for (Plant p : plants) { if (!p.isDead) p.update(); }不用判断它是什么类型、不用写 if-else 分支。这就是面向对象编程 java 思想里“开闭原则”的直观体现——要加一个新植物不需要改游戏循环只要新建一个子类。3.2 子类的具体行为向日葵和豌豆射手的差异实现向日葵不用攻击只需要按固定节奏产出阳光资源。豌豆射手则需要找到当前行最近的僵尸决定要不要发射豌豆。具体实现public class Sunflower extends Plant { private long lastProduceTime; private static final long PRODUCE_INTERVAL 9000; // 9秒产出一次 public Sunflower(int row, int col) { super(row, col, 300, 50); lastProduceTime System.currentTimeMillis(); } Override public void update() { long now System.currentTimeMillis(); if (now - lastProduceTime PRODUCE_INTERVAL) { // 在植物上方生成一个阳光实体交给游戏管理器 GameManager.getInstance().spawnSun(getX() 20, getY() - 30); lastProduceTime now; } } }豌豆射手的逻辑类似但有两点不同它要记录上次发射时间还要确认这一行有僵尸才开火。这个“行内检测”是整个游戏的攻防核心我会在第 4 章单独展开。每个植物的update()方法内聚了自己全部行为外部只关心调它这就是做这个标题项目里最值得反复体会的设计思路。3.3 僵尸的移动与状态切换僵尸和植物最大的不同是它必须持续移动而且有两种状态走路和啃食。遇到植物挡路就停下来攻击植物倒下了就继续走。用状态字段建模很简单public class Zombie { private int x, y; private int hp 200; private int speed 2; // 每帧移动的像素数 private boolean eating false; public void update(ListPlant plantsInThisRow) { if (plantsInThisRow.isEmpty()) { eating false; x - speed; // 向左移动 } else { eating true; // 啃食逻辑由外部或此方法执行 } } }在游戏循环里僵尸根据当前所在行去查找植物列表。数组列表 ArrayList 存的是同一行的植物挨个检测矩形和碰撞最终决定是移动还是停下攻击。很多新手在这里会陷入一个误区把逻辑写得太散结果僵尸和植物互相调方法代码绕成一团。推荐的边界是僵尸只判断自己该不该停攻击扣血由管理方统筹调用避免对象之间互相改对方的私有字段。4. 碰撞检测、子弹与阳光经济让游戏真正“活”起来4.1 子弹和僵尸的矩形相交判定游戏里最频繁的操作是判断豌豆子弹有没有打中僵尸。每个实体有坐标和宽高天然可以抽象为矩形。Java 里Rectangle类自带相交检测方法这是做这个项目最常见的碰撞方案比起逐像素判断不知道省多少事public class PeaBullet { private int x, y; private final int speed 8; public void update() { x speed; // 豌豆向右飞 } public Rectangle getBounds() { return new Rectangle(x, y, 28, 28); } }在管理子弹的更新逻辑里遍历每一颗子弹再遍历当前行的僵尸用矩形相交判定是否命中for (PeaBullet bullet : bullets) { Rectangle bulletRect bullet.getBounds(); for (Zombie zombie : zombiesInSameRow) { if (bulletRect.intersects(zombie.getBounds())) { zombie.hp - 20; bullet.hit true; break; // 一颗子弹只打中一个僵尸 } } } // 用迭代器或 removeIf 移除命中后子弹和死亡的僵尸 bullets.removeIf(b - b.hit); zombies.removeIf(z - z.hp 0);这套实现的优势是直观、好调试。你可以在子弹和僵尸的绘制代码里临时画出它们的边界矩形肉眼验证碰撞点是否合理。注意这里有个细节碰撞检测放在子弹坐标更新之后同一帧里先让子弹移动再去检测新位置是否压到僵尸。顺序反了子弹会看起来还没碰到僵尸就消失。4.2 网格种植像素坐标和行列坐标的换算植物只能种在固定格子上游戏界面是 5 行 9 列的网格。鼠标点击时拿到的坐标是像素值必须换算成行和列再检查这个格子是否为空、阳光够不够。换算公式不难但舍入方式有讲究public boolean onMouseClicked(int mouseX, int mouseY, int selectedPlantType) { int col mouseX / GamePanel.CELL_WIDTH; int row mouseY / GamePanel.CELL_HEIGHT; // 修正点在最右边一条边缘像素时可能算出 col 9超出地图范围 if (col 9) col 8; if (row 5) row 4; if (grid[row][col] ! null) return false; // 已占用 Plant newPlant createPlant(selectedPlantType, row, col); if (sunCount newPlant.getSunCost()) return false; sunCount - newPlant.getSunCost(); grid[row][col] newPlant; return true; }注意这里直接用了整型除法等价于向下取整。玩过原版就会知道鼠标点在格子左半边和右半边种出来的植物位置略有偏差但玩家不会介意——项目里保持统一的处理规则就行。真正要小心的是数组越界最后一格边缘点击容易算出超范围的下标所以要加边界修正。4.3 阳光的产生、下落与收集体系阳光是这个游戏的“经济系统”原版里的阳光来源两种天空随机掉落、向日葵产出。实现上需要两个生产者、一个收集判定。天空阳光的生成可以做一个独立的Timer或放进主循环里用随机数控制// 在主循环的逻辑更新里调用 private void spawnSkySunIfNeeded(long now) { if (now - lastSkySunTime 7000 random.nextInt(3000)) { int x 100 random.nextInt(760); // 界面宽度内随机 int y 0; sunList.add(new SunEntity(x, y, 0)); // 类型0代表天空阳光 lastSkySunTime now; } }阳光实体自己带一个下落速度在update()里让y坐标缓慢增加。点击收集的判断和种植类似检测鼠标点击点是否落在阳光的矩形区域内。有个常见的小优化阳光快落地时加速下落避免玩家干等着这个细节能明显提升手感。4.4 用 ArrayList 和 Map 管理实体容器整个游戏所有实体都存在集合里。植物适合放在二维数组或二维ArrayList里因为固定格子僵尸和子弹适合用ArrayList动态增删。这里最容易翻车的操作是遍历时删除元素直接写for (Zombie z : zombies) { if (z.hp 0) zombies.remove(z); }会立刻抛ConcurrentModificationException因为迭代器检测到列表被外部修改。解决方案就是第 4.1 节代码里的removeIf或者用显式迭代器调用iterator.remove()。这个坑是 java 容器相关面试题里最常被问到的点做这个项目时踩上一次比背十道题都记得牢。5. Swing 实战避坑记5 个常见问题与排查路径5.1 画面疯狂闪烁或显示残留现象游戏跑起来后植物和僵尸的轮廓一直闪拖动窗口时画面像在“抖”有时候新画面被画上去了旧的轮廓还隐约留着。原因九成是paintComponent方法里没调用super.paintComponent(g)导致背景没有清空新旧画面叠加还有一成是自己在非 EDT 线程里直接调用repaint()破坏了双缓冲的重绘机制。解决检查paintComponent第一行有没有调用父类方法再确认所有修改组件状态的操作都发生在 Timer 回调或事件监听器里。外部线程要刷新界面用SwingUtilities.invokeLater(() - panel.repaint())。5.2 程序启动后窗口白屏无响应现象双击运行后窗口能弹出来但里面的面板一片空白鼠标点哪里都没反应任务管理器里看到 CPU 占用非常高。原因有人为了“性能好”自己写了new Thread(() - { while(true) { update(); panel.repaint(); Thread.sleep(16); } })。这个线程和 EDT 竞争资源而且绝大多数repaint请求被累积最终 EDT 被拖垮。解决删掉手动线程改用javax.swing.Timer。Timer 的回调本身就在 EDT 里执行逻辑更新完成后调repaint会安全地排队。如果确实有耗时计算想放后台用SwingWorker做异步任务不要在回调里做阻塞操作。5.3 动画卡顿掉帧僵尸移动一卡一卡现象僵尸不是平滑移动而是每几百毫秒才跳一下像在看定格动画。原因最常见的不是循环频率问题而是绘制方法里执行了耗时操作——比如每次绘制时重新读取图片文件、缩放图片、或者在循环里打印日志到控制台。I/O 操作在绘制路径里是大忌。解决所有图片资源在项目启动时一次性加载进内存放进一个静态资源容器里绘制时只做引用传递。日志输出降级只在调试开关打开时打印。还卡的话检查是否在update里做了大量字符串拼接或频繁new对象。5.4 点击收割阳光总是误判现象阳光明明在画面里鼠标也点到了图标上但就是收集不到或者点空白处反倒触发了收集。原因阳光实体的坐标记录的是它左上角的位置但点击判定的矩形写成了中心点偏下一大截还有一种情况是阳光下落后坐标还在按“生成点”更新没有同步最新位置。解决在阳光生成位置处日志打印它的getBounds()矩形再在鼠标点击处打印点击坐标把两者画出来对比。这样能直观看到判定区域偏移了多少像素然后修正矩形宽高和点击偏移量。5.5 游戏逻辑越写越乱改一个功能牵一发动全身现象加了新植物后原来的代码要改动好几处或者想在某一帧暂停游戏发现暂停一个状态要改七个地方。原因没有把游戏状态集中管理。经常出现的情况是植物列表、僵尸列表、阳光数量、游戏是否结束散落在各个类里被到处改。解决用一个单例管理类如GameManager统一持有这些数据和状态提供pause()、resume()、addPlant()这样的方法供外部调用把可变状态收口。这也是“文档解释”里最容易写清楚的部分后续展开类的职责时一眼就知道谁负责什么。6. 全注释与文档解释的落地写法让代码从自己能看懂到别人能复现项目跑通、动画流畅之后真正能拉开差距的是标题里后半句——全注释和文档解释。新手写注释常见的毛病是“给代码贴翻译”x speed; // x 加上速度这种注释对读者毫无价值。我写课程设计级别的注释时只解释三件事这段代码在游戏逻辑里承担什么角色、为什么用这个写法、边界条件和异常情况是什么。比如第 4 章里的碰撞代码注释应当写“这里用矩形相交而非逐像素检测性能足够且便于调试一颗子弹只结算一次命中break 防止同一颗子弹穿透多个僵尸”。这种注释才是真正的“解释”而不是复读代码。文档结构我一般按照“需求分析 → 模块划分 → 类设计 → 核心流程 → 测试记录”五段式来组织。需求分析里定义玩家能做什么选择植物、收集阳光、种植到空地、防御僵尸进攻、关卡结束时僵尸不能越过左侧边界。模块划分把界面层、逻辑层、资源层分开描述。类设计用表格列出每个类的职责和关键方法比如下面这样类名类型核心职责关键方法GamePanel界面层绘制画面、驱动主循环paintComponent / startGamePlant抽象基类植物公共属性与行为模板update() 抽象方法Sunflower植物子类定时产出阳光update() 覆盖Peashooter植物子类检测同行僵尸并发射子弹update() 覆盖GameManager管理类持有游戏状态与实体容器spawnSun / addPlant / pause测试记录是很多文档里缺失的部分也是最容易被答辩老师追问的部分。我一般会写三张表功能测试表逐条列出操作步骤和预期结果比如“种植向日葵后等待 9 秒检查界面上方是否出现可收集阳光”边界测试表记录阳光不足时的提示、空格子已占用时的拦截、最后一列点击不越界性能测试表记录持续运行时帧率是否稳定、僵尸数量多时有没有明显掉帧。这最后一张表的价值很大它展示了你作为开发者真的在考虑运行质量而不只是把功能写出来。写这份文档时我才意识到注释的意义不在当下而在一周后自己回头看代码能不能快速回忆起当时的意图以及别人接手时能不能顺着注释独立改功能。这个习惯帮助我后续做所有项目都保持了“写代码即写文档”的节奏希望帮到你。本文还有配套的精品资源点击获取