news 2026/10/7 6:37:46

Java植物大战僵尸课程设计:Swing游戏循环与碰撞检测实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java植物大战僵尸课程设计:Swing游戏循环与碰撞检测实战

简介:这是一份基于Java实现的植物大战僵尸游戏项目设计源码,面向具备一定Java基础、希望以完整案例学习游戏开发的学生与开发者。项目围绕经典塔防玩法展开,玩家通过种植各类植物抵御僵尸进攻,涵盖植物、僵尸、子弹、卡片、阳光、铲子等核心模块,适合课程设计、毕业设计参考或Java图形界面与面向对象编程的进阶练习。压缩包共86个文件,约25.99MB,其中Java源码29个承载游戏逻辑,GIF与JPG、PNG图片共42个用于角色动画与界面素材,WAV音频8个提供背景音乐与音效,另有XML配置及项目文件辅助构建。目前已有459人学习下载。代码结构清晰、注释详尽,读者可从中掌握游戏循环、碰撞检测、状态管理与资源加载等实现思路,并借助现成素材快速运行调试,是研究Java游戏开发的实用参考。

1. Java 版植物大战僵尸:从课程设计到可跑源码,这条路值不值得走

植物大战僵尸这个题材,在 Java 课程设计和毕业设计里出现的频率高得离谱。我前后帮人看过不下十份所谓「基于 Java 的植物大战僵尸游戏项目设计源码」,有的能跑,有的打开就报错,有的干脆是拿别人的半成品改了个包名。这个标题背后真正要解决的问题不是「怎么抄一份代码交作业」,而是「怎么用 Java 把一款塔防游戏的核心循环、碰撞检测、资源调度和关卡状态机完整落地」。它适合三类人:正在做 Java 课程设计的学生、想用一个小项目把面向对象编程和 Swing/JavaFX 练熟的新手、以及需要一份可扩展游戏框架做二次开发的开发者。下面我按实际动手顺序,把选型、搭建、核心模块、避坑和进阶验证一次讲透。

2. 技术选型与工程骨架:Swing、JavaFX 还是 LibGDX

2.1 三种 GUI 方案的取舍

做 Java 桌面游戏,绕不开三个选择:Swing、JavaFX、LibGDX。我一般会先问一句「你是交作业还是要长期维护」,答案不同,选型完全不同。

Swing 是最稳的。JDK 自带,不需要额外依赖,javax.swing.Timer就能驱动游戏主循环,Graphics2D画图足够应付植物大战僵尸这种 2D 固定视角。缺点是 API 老、双缓冲要自己处理、动画容易闪。但对课程设计来说,它的最大优势是「老师电脑上一定能跑」——不用配 Maven 仓库,不用下几百兆的引擎。

JavaFX 画面更好,支持 CSS 样式和硬件加速,AnimationTimer的帧回调比 Swing Timer 精确。但 JDK 11 之后 JavaFX 被移出标准库,得单独引入javafx-controls、javafx-media等模块,打包成可执行 jar 时还要处理模块化配置,新手很容易卡在Error: JavaFX runtime components are missing。

LibGDX 是专业游戏框架,跨平台、自带资源管理和场景图,但它本质是「用 Java 写游戏」而不是「Java 课程设计」,学习曲线陡,交作业反而显得杀鸡用牛刀。

我的建议很直接:课程设计选 Swing,想做得好看点选 JavaFX,别碰 LibGDX。下面所有代码以 Swing 为主,因为它复现成本最低。

2.2 工程目录与依赖

一个能跑的项目,目录结构必须先定好,否则后期资源路径全是坑。我常用的结构是这样:

pvz-java/ ├── src/ │ └── com/pvz/ │ ├── core/ # 游戏主循环、状态管理 │ ├── entity/ # 植物、僵尸、子弹、阳光 │ ├── scene/ # 关卡、菜单、战斗场景 │ ├── util/ # 资源加载、碰撞、定时器 │ └── Main.java ├── res/ │ ├── images/ │ ├── sounds/ │ └── levels/ # 关卡配置 └── pom.xml

用 Maven 管理依赖,pom.xml里只需要最基本的配置:

<project> <modelVersion>4.0.0</modelVersion> <groupId>com.pvz</groupId> <artifactId>pvz-java</artifactId> <version>1.0</version> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> </project>

这里 JDK 版本选 17 是因为它是当前 LTS,语法上能用record、sealed这些特性简化实体类定义,同时 Swing 在 17 上完全稳定。如果你学校机房还是 JDK 8,把 source/target 改成 1.8 即可,代码里避开新语法就行。

提示:资源目录res不要放在src里面,否则 Maven 打包时默认不会把它复制到 classpath,运行时getResource会返回 null。正确做法是把res放在项目根目录,然后在 pom 里配置<resources>把它纳入构建。

2.3 游戏主循环的最小实现

游戏的心脏是主循环。Swing 里最稳的写法是用javax.swing.Timer,每 16 毫秒触发一次(约 60 FPS),在回调里更新逻辑再重绘。

public class GamePanel extends JPanel implements ActionListener { private final Timer timer; private final GameWorld world; public GamePanel(GameWorld world) { this.world = world; this.timer = new Timer(16, this); // 16ms ≈ 60FPS this.setPreferredSize(new Dimension(1440, 810)); this.setDoubleBuffered(true); // 开启双缓冲,消除闪烁 } public void start() { timer.start(); } @Override public void actionPerformed(ActionEvent e) { world.update(16); // 传入 deltaTime,单位毫秒 repaint(); // 触发 paintComponent } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d = (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); world.render(g2d); } }

逻辑说明:Timer的actionPerformed在 EDT(事件调度线程)上执行,所以update和render天然串行,不用加锁。setDoubleBuffered(true)让 Swing 在内存里先画好整帧再刷到屏幕,这是消除画面撕裂的关键。world.update(16)传入固定步长而不是真实耗时,好处是逻辑稳定、可复现,坏处是机器卡顿时游戏会变慢——对课程设计来说这个取舍完全可接受。

参数说明:Timer的间隔不要设成 0 或 1,那会让 EDT 被占满导致界面无响应。16ms 是 60FPS 的标准值,如果画面元素少想省 CPU,改成 33ms(30FPS)也行,但僵尸移动会明显变顿。

3. 核心实体与碰撞:植物、僵尸、子弹怎么组织

3.1 用面向对象拆实体

植物大战僵尸的实体看着多,其实抽象出来就三层:GameObject(有坐标、有更新、有渲染)→LivingEntity(有血量、有攻击)→ 具体的Plant、Zombie。子弹和阳光属于GameObject但不属于LivingEntity。

public abstract class GameObject { protected double x, y; protected int width, height; public abstract void update(int deltaMs); public abstract void render(Graphics2D g); public Rectangle getBounds() { return new Rectangle((int) x, (int) y, width, height); } } public abstract class LivingEntity extends GameObject { protected int hp; protected int maxHp; public void takeDamage(int dmg) { hp -= dmg; if (hp <= 0) onDeath(); } protected abstract void onDeath(); }

这样拆的好处是碰撞检测可以统一用getBounds()返回的Rectangle,不用给每种实体写一套。植物和僵尸的差异通过子类覆写update实现:植物是静止的,僵尸是沿 x 轴向左移动。

3.2 碰撞检测的两种做法

最直接的是 AABB(轴对齐包围盒)矩形相交:

public static boolean intersects(GameObject a, GameObject b) { return a.getBounds().intersects(b.getBounds()); }

对植物大战僵尸来说,AABB 完全够用,因为所有实体都是矩形贴图,没有旋转。但有个细节要注意:僵尸的判定框应该比贴图窄一点,否则僵尸还没碰到植物就触发了啃咬。我一般把僵尸的碰撞框宽度设成贴图的 70%,居中放置。

另一种是网格判定。因为游戏是 5 行 9 列的格子布局,可以直接用「僵尸所在列 == 植物所在列 && 僵尸所在行 == 植物所在行」来判断,省去遍历所有植物。这种做法性能更好,但要求所有实体严格对齐网格。实际项目里我通常两者结合:先用网格快速筛选,再用 AABB 精确判定。

// 网格坐标转换 public static int toCol(double x) { return (int) ((x - GRID_LEFT) / CELL_WIDTH); } public static int toRow(double y) { return (int) ((y - GRID_TOP) / CELL_HEIGHT); }

参数说明:GRID_LEFT、GRID_TOP是草坪左上角在面板里的像素坐标,CELL_WIDTH、CELL_HEIGHT是单格尺寸。以 1440×810 的面板为例,草坪通常占中间区域,格子大约 120×130 像素。这些值一旦定了就别乱改,否则所有实体的初始坐标都要跟着调。

3.3 子弹与攻击的时序问题

豌豆射手发射子弹,子弹飞行、命中僵尸、扣血——这条链路最容易出 bug 的地方是「子弹还没到,僵尸已经死了」或者「同一帧子弹命中两次」。

我的做法是给子弹加一个hasHit标记,命中后立即置位并从场景移除,同时用CopyOnWriteArrayList存子弹列表,避免遍历时删除导致ConcurrentModificationException。

public class Bullet extends GameObject { private int damage; private boolean hasHit = false; @Override public void update(int deltaMs) { x += speed * deltaMs / 16.0; // 按帧率归一化速度 if (x > 1440) hasHit = true; // 飞出屏幕视为失效 } public boolean isActive() { return !hasHit; } }

逻辑说明:speed * deltaMs / 16.0这行是让子弹速度不随帧率变化。如果直接写x += speed,在 30FPS 的机器上子弹会比 60FPS 慢一半。除以 16 是把速度定义成「每 16ms 移动多少像素」,这样无论实际帧率多少,视觉速度一致。

参数说明:豌豆子弹速度一般设 8~12 像素/帧,太快看不清弹道,太慢僵尸都走到跟前了子弹还在飞。伤害值普通豌豆 20,寒冰豌豆 20 外加减速效果,这些数值直接决定游戏平衡,建议放在配置文件里而不是硬编码。

4. 关卡状态机与资源加载:让游戏能从头玩到尾

4.1 用状态机管理游戏流程

一个完整的植物大战僵尸至少有这几个状态:主菜单、选卡、战斗中、暂停、胜利、失败。如果不用状态机,代码里会到处是if (gameState == 1)这种魔法数字,改起来想死。

public enum GameState { MENU, CARD_SELECT, PLAYING, PAUSED, WIN, LOSE } public class GameWorld { private GameState state = GameState.MENU; private final List<GameObject> entities = new ArrayList<>(); private int sunCount = 50; private int waveIndex = 0; public void update(int deltaMs) { switch (state) { case PLAYING -> updatePlaying(deltaMs); case PAUSED -> { /* 不更新逻辑 */ } default -> { /* 菜单类状态只更新 UI */ } } } private void updatePlaying(int deltaMs) { entities.forEach(e -> e.update(deltaMs)); entities.removeIf(e -> e instanceof Bullet b && !b.isActive()); checkWaveProgress(); checkWinLose(); } }

逻辑说明:switch用箭头语法(JDK 14+)避免 fall-through。removeIf在遍历后统一清理失效实体,比在forEach里删安全。checkWaveProgress负责按波次生成僵尸,checkWinLose判断僵尸是否走到最左侧或全部消灭。

参数说明:初始阳光 50 是经典设定,第一波僵尸一般在开局后 15~20 秒出现,每波间隔逐渐缩短。这些数值建议抽到levels/level1.json里,用 Jackson 或 Gson 读取,改关卡不用重新编译。

4.2 资源加载与缓存

图片和音效如果每次用都重新ImageIO.read,游戏会卡成幻灯片。正确做法是启动时一次性加载到Map里缓存。

public class ResourceManager { private static final Map<String, BufferedImage> IMAGES = new HashMap<>(); private static final Map<String, Clip> SOUNDS = new HashMap<>(); public static BufferedImage getImage(String name) { return IMAGES.computeIfAbsent(name, k -> { try { var url = ResourceManager.class.getResource("/images/" + k + ".png"); return ImageIO.read(Objects.requireNonNull(url)); } catch (IOException e) { throw new RuntimeException("图片加载失败: " + k, e); } }); } public static void preload() { String[] names = {"peashooter", "sunflower", "wallnut", "zombie_normal", "bullet"}; for (String n : names) getImage(n); } }

逻辑说明:computeIfAbsent保证同一张图只加载一次,后续直接命中缓存。getResource从 classpath 读取,所以res/images必须在构建时被复制到 classpath 根目录下的images文件夹。

参数说明:图片格式优先用 PNG,支持透明通道,僵尸和植物的镂空边缘不会出现黑边。JPG 不支持透明,用上去会很难看。音效方面,短促音效用Clip,背景音乐用SourceDataLine流式播放,否则一首 BGM 加载进内存就是几十兆。

注意:Clip对象数量有限,JVM 同时打开的音频线路一般不超过 32 条。如果音效很多,用完要调clip.close()释放,否则玩到后面会静音。

4.3 阳光经济与冷却系统

阳光是游戏的经济核心,植物卡片有冷却时间,这两套系统必须和主循环同步。

public class PlantCard { private final String plantType; private final int cost; private final int cooldownMs; private long lastUsedTime; public boolean canUse(int currentSun) { return currentSun >= cost && System.currentTimeMillis() - lastUsedTime >= cooldownMs; } public void markUsed() { lastUsedTime = System.currentTimeMillis(); } }

逻辑说明:用System.currentTimeMillis()而不是帧计数来算冷却,好处是暂停时冷却也会暂停(因为暂停时不调用canUse),恢复后不会出现「暂停十分钟回来卡片全好了」的漏洞。

参数说明:向日葵产阳光间隔一般 8~10 秒,每次 25 点。豌豆射手成本 100,冷却 7.5 秒;向日葵成本 50,冷却 7.5 秒;坚果墙成本 50,冷却 30 秒。这些数值直接照搬原版就行,自己调很容易把经济搞崩。

5. 避坑与排查:那些让我重写三遍的坑

5.1 坑一:图片路径在 IDE 能跑,打包就报 NullPointerException

现象:在 IntelliJ 里运行一切正常,mvn package之后双击 jar 启动,直接抛NullPointerException,定位到ImageIO.read那一行。

原因:IDE 运行时工作目录是项目根目录,new File("res/images/x.png")能找到;打包后工作目录变成 jar 所在目录,而且res根本没进 jar。用getResource才是正解,但前提是资源被复制到了 classpath。

解决:把资源目录配置进 pom 的<resources>,并且代码里统一用getResource("/images/x.png"),路径以 classpath 根为基准,不要带res前缀。

5.2 坑二:僵尸多了之后画面卡顿,帧率从 60 掉到 15

现象:前几波流畅,僵尸超过 20 个后明显卡顿,repaint耗时飙升。

原因:每次paintComponent都对所有实体重新ImageIO.read,或者对每张图做了缩放getScaledInstance。getScaledInstance是出了名的慢,而且每次调用都生成新对象。

解决:图片加载时一次性缩放到目标尺寸并缓存,渲染时直接drawImage原图。另外把不在屏幕内的实体跳过渲染,虽然植物大战僵尸场景不大,但子弹多了也有收益。

5.3 坑三:碰撞检测漏判,僵尸从植物身上穿过去

现象:僵尸走到植物位置但没有触发啃咬,直接穿过去继续走。

原因:僵尸移动速度较快时,一帧移动距离超过植物宽度,Rectangle.intersects在两帧之间都没检测到重叠,这叫「隧穿」。

解决:要么限制单帧最大移动距离不超过最窄实体宽度的一半,要么用扫掠检测(记录上一帧位置,检测移动路径与植物的相交)。实际项目里我用前者,把僵尸速度上限卡在 4 像素/帧,简单可靠。

5.4 坑四:EDT 上做耗时操作导致界面假死

现象:点击「开始游戏」后界面卡住两三秒才响应,期间点什么都没反应。

原因:在按钮的actionPerformed里直接加载所有图片和音效,这些 IO 操作阻塞了 EDT。

解决:把资源加载放到SwingWorker的doInBackground里,完成后在done里切换状态。或者更简单——启动时先显示一个加载画面,在后台线程加载完再进主菜单。

5.5 坑五:多线程修改实体列表导致 ConcurrentModificationException

现象:偶尔崩溃,堆栈指向ArrayList$Itr.checkForComodification。

原因:在forEach遍历实体的同时,另一个线程(比如音效回调或定时器)往列表里加了新实体。

解决:所有实体增删都收敛到主循环的update里做,其他线程只标记「待添加」「待删除」,主循环统一处理。或者用CopyOnWriteArrayList,但那个写性能差,实体多的时候不合适。

6. 进阶验证:怎么确认你的实现真的对

6.1 用固定步长做可复现测试

游戏逻辑最难测的就是「随机性」。僵尸生成有随机、阳光掉落有随机,导致同一个 bug 很难复现。我的做法是把随机数种子固定下来,写一个GameWorld的无头测试。

@Test public void testZombieReachesHouse() { GameWorld world = new GameWorld(12345L); // 固定种子 world.startLevel(1); for (int i = 0; i < 60 * 60; i++) { // 模拟 60 秒 world.update(16); } assertTrue(world.isGameOver(), "不种植物时僵尸应在 60 秒内进屋"); }

逻辑说明:固定种子后,僵尸生成序列完全可复现,测试结果稳定。60 * 60次更新对应 60 秒游戏时间,如果僵尸没走到房子,说明移动逻辑或生成逻辑有问题。

参数说明:种子用long,随便给个常数就行,关键是每次测试都用同一个。断言条件要选「必然发生」的事件,比如「不种植物必输」,这种测试才有意义。

6.2 帧率无关性的验证方法

前面提到速度要按deltaMs归一化,怎么验证真的做到了?跑两次,一次 60FPS 一次 30FPS,看僵尸到达同一位置用的游戏时间是否一致。

long timeAt60 = simulateUntilZombieAt(800, 16); long timeAt30 = simulateUntilZombieAt(800, 33); assertEquals(timeAt60, timeAt30, 100); // 允许 100ms 误差

如果两次时间差很多,说明某处速度没做归一化,通常是直接写了x += speed而不是x += speed * deltaMs / 16.0。

6.3 一个我常用的调试技巧

开发阶段我会在paintComponent最后加一段调试渲染,把所有实体的碰撞框画出来:

if (DEBUG) { g2d.setColor(Color.RED); for (GameObject e : entities) { g2d.draw(e.getBounds()); } }

这个习惯帮我省了无数时间。碰撞问题肉眼看不出来,但框一画,是判定框偏了还是根本没重叠,一眼就清楚。上线前把DEBUG置 false 即可,不影响性能。

说到底,基于 Java 的植物大战僵尸项目,难点从来不是「写不出来」,而是「写出来能跑、跑起来不卡、卡了能查」。我自己的习惯是每加一个实体类型,先写它的update和getBounds,用调试框确认位置对了,再写渲染。这个顺序反过来,先画图再调逻辑,十有八九要在碰撞上翻车。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 6:36:50

端侧Agent工程化实战:可靠性、安全与记忆的落地指南

1. 端侧 Agent 工程化到底在解决什么问题如果你只跑过几个 Demo&#xff0c;会觉得端侧 Agent 已经挺能打了&#xff1a;把模型量化后塞进手机&#xff0c;接上两三个工具&#xff0c;它自己就能规划、调用工具、组织回答。但一旦开始做工程化&#xff0c;画风马上就变了——真…

作者头像 李华
网站建设 2026/10/7 6:36:22

Intel RealSense D435深度相机详解:主动立体视觉与VINS-Fusion实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 6:36:17

AI编程智能体实战指南:普通程序员如何用AI智能体提升开发效率

做程序员这么多年&#xff0c;我一直觉得技术圈最不缺的就是“风口”&#xff0c;但大多数风口跟普通程序员没什么关系——要么是大厂之间拼算力拼资金&#xff0c;要么是创业公司烧钱讲故事。可这次不一样&#xff0c;AI 编程智能体从去年开始落地到现在&#xff0c;我身边已经…

作者头像 李华
网站建设 2026/10/7 6:35:48

DDR4信号完整性仿真:Sigrity SystemSI建模与眼图优化实战

1. 为什么DDR4信号质量仿真总在“差一点”时崩盘&#xff1f;——从Sigrity 2022 SystemSI的底层逻辑说起你是不是也经历过&#xff1a;原理图刚画完&#xff0c;一跑Sigrity SystemSI就报错“Net not found in layout database”&#xff1b;好不容易导入了PCB&#xff0c;仿真…

作者头像 李华
网站建设 2026/10/7 6:35:45

Canvas实现微信找茬小游戏:从开发到流量主变现

简介&#xff1a;这是一份可直接部署学习的微信益智小游戏源码包&#xff0c;涵盖“大家来找茬”“找不同”两类玩法&#xff0c;并已接入流量主功能&#xff0c;适合微信小程序开发者、独立游戏爱好者用于研究游戏逻辑、界面交互与广告变现方案。压缩包内共2005个文件&#xf…

作者头像 李华