简介:这是一份基于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,用调试框确认位置对了,再写渲染。这个顺序反过来,先画图再调逻辑,十有八九要在碰撞上翻车。希望帮到你。
本文还有配套的精品资源,点击获取