简介:一款基于Java与Swing的植物大战僵尸复刻项目,覆盖冒险、生存、解谜等模式。项目用面向对象抽象植物、僵尸、子弹,结合多线程处理主循环与交互,涉及矩形碰撞检测、Java Sound音效和序列化存档等实现。类设计上高内聚低耦合,事件监听与线程调度配合紧密,适合Java GUI学习者和游戏开发初学者。
资源压缩包共一百四十八个文件,大小约3.77MB。其中十九份java源码是核心学习对象,六十四张png和二十四张jpg作为游戏素材,三十八个class文件可直接运行验证。结构清晰,便于按界面、实体、线程等模块研读。已有两千三百三十八人学习下载,热度较高。借助这份项目,可以直观理解完整小游戏的工程组织方式,也能获得多线程协作、碰撞判定、资源加载等编码经验。
1. Java Swing 的植物大战僵尸项目:复刻一个塔防游戏到底在练什么
Java 项目「植物大战僵尸(Java + Swing)」,说白了就是用一个完全不上游戏引擎、纯 Swing 组件搭出来的塔防复刻。这类项目的价值不在“做出一个能玩的游戏”,而在把面向对象设计、GUI 事件模型、碰撞检测、动画循环这些零散知识点,全部压进一个必须协作运行的系统里:植物要能种、僵尸要会走、豌豆要能击中目标、阳光要能点击收集。它适合刚学完 Java 语法、准备做课程设计或求职项目的人;也适合带学生的导师把它当两周实战作业。真实做下来你会发现,最大的坑不是 Java 语法,而是 Swing 的事件线程模型、坐标换算和资源加载时机。这篇文章就把从棋盘建模到最后一帧动画的落地路径拆开讲,代码可以直接照抄再按需改参数。
2. 先定义棋盘再种植物:Swing 版地图与种植模型怎么搭
很多初学者拿到这个标题就直接开 JFrame、往上拖 JLabel,结果做到一半发现碰撞检测根本没法写。问题出在“地图模型”没有先定义。植物大战僵尸本质上是网格塔防,必须先有一个稳定的棋盘坐标系,之后所有鼠标点击、植物放置、僵尸移动、子弹飞行都基于这个坐标系换算,游戏逻辑才不会散落成一堆 if。
2.1 棋盘常量先行:格子尺寸、行列数如何影响后续所有计算
我一般先把棋盘几何参数全部集中到一个配置类里,绝不散落在各文件里写魔法数字。这里采用 5 行 9 列,每个格子宽 80、高 100,顶部留 60 像素放工具栏和阳光计数。棋盘左侧贴窗口边缘,所以左边距为 0;如果以后想加侧边栏,只需要改这一个常量。
public class GameConfig { public static final int ROWS = 5; public static final int COLS = 9; public static final int CELL_WIDTH = 80; public static final int CELL_HEIGHT = 100; public static final int TOP_BAR_HEIGHT = 60; public static final int LEFT_MARGIN = 0; public static final int BOARD_WIDTH = COLS * CELL_WIDTH; public static final int BOARD_HEIGHT = ROWS * CELL_HEIGHT; public static final int WINDOW_WIDTH = BOARD_WIDTH + LEFT_MARGIN; public static final int WINDOW_HEIGHT = TOP_BAR_HEIGHT + BOARD_HEIGHT; }逻辑说明:ROWS 和 COLS 决定了地图容量,也直接决定植物数组和僵尸列表的规模;CELL_WIDTH 与 CELL_HEIGHT 不只是绘图尺寸,碰撞盒、种植判定、移动速度全都以它们为基准。比如僵尸从右侧进入,它的初始 x 坐标就应该是 BOARD_WIDTH,否则会出现在窗口外。
参数说明:格子尺寸不能随便拍脑袋。我建议先准备素材图,按素材实际像素定格子。比如植物图片宽 70 高 80,格子就定 80×100,留出呼吸空间。行高取 100 是为了给僵尸的“啃食动画”留出上下浮动余量。窗口尺寸由格子数推出来,而不是反过来硬定窗口再切格子,这样以后改行列数时只动常量。
2.2 自绘方案 vs 控件网格:为什么最终选 paintComponent
这个项目常见做法是直接在 JPanel 上重写 paintComponent 自绘,而不是用 GridLayout 摆一堆 JLabel。原因有二:第一,JLabel 网格的每个格子是独立 Swing 组件,绘制层级和碰撞判定都被 Swing 的布局系统绑住,想做动画必须到处 repaint,性能很差;第二,自绘时所有实体对象都存在于同一个 List 里,你可以用统一的坐标系去绘制、检测、排序,心智负担小很多。
| 对比项 | JLabel 网格方案 | 自绘 paintComponent 方案 |
|---|---|---|
| 动画刷新 | 需要逐个 label 更新图标 | 一次 repaint 重绘整个画布 |
| 碰撞检测 | 依赖组件边界,坐标换算啰嗦 | 直接用模型坐标计算 |
| 层级控制 | 受布局管理器和组件层级限制 | 按绘制顺序自由控制 |
| 性能 | 对象数量多时卡顿明显 | 可控,几千个绘制对象无压力 |
| 学习价值 | 低,偏向界面拼接 | 高,贴近真实游戏渲染思路 |
我建议的绘制顺序是:草坪背景 → 植物 → 子弹 → 阳光 → 僵尸。这里有一个小注意点:子弹应该画在植物上方、僵尸下方?其实子弹飞在草坪上方但会被僵尸挡住,所以先画植物,再画子弹,再画僵尸,最后画阳光。阳光是最顶端悬浮物。
public class GamePanel extends JPanel { private GameWorld world; @Override protected void paintComponent(Graphics g) { super.paintComponent(g); drawBackground(g); world.drawPlants(g); world.drawBullets(g); world.drawZombies(g); world.drawSun(g); } }逻辑说明:paintComponent 是 Swing 提供的绘制入口,由 repaint() 触发,系统调用,不要手动调用。super.paintComponent(g) 先清空画布防止残影。之后按层级绘制,每个 draw 方法内部遍历模型集合。
参数说明:如果以后想加“铲子”、“割草机”等特效,把它们插到对应层级即可。注意血条、冷却条这种 UI 信息应该最后画,否则会被游戏实体盖住。绘制对象不能直接在 paintComponent 里 new,所有图片资源必须在初始化阶段加载完,这里只做 drawImage。
2.3 种植判定:鼠标点在哪里,格子落到哪里
种植的核心是把鼠标坐标换算成行和列,然后去操作一个二维数组。这个二维数组就是地图模型,我使用 PlantCell[][] cells 保存每个格子当前种的植物;同时维护一个“选中植物类型”变量,由底部的卡片按钮决定当前要种什么。
addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { int x = e.getX() - GameConfig.LEFT_MARGIN; int y = e.getY() - GameConfig.TOP_BAR_HEIGHT; int col = x / GameConfig.CELL_WIDTH; int row = y / GameConfig.CELL_HEIGHT; if (row < 0 || row >= GameConfig.ROWS || col < 0 || col >= GameConfig.COLS) { return; } PlayerAction action = new PlayerAction(row, col, selectedPlant); eventQueue.offer(action); } });逻辑说明:先把鼠标坐标减掉棋盘原点偏移,再整除格子宽高,得到 0~4 的行和 0~8 的列。越界判断必须在换算后立刻做,否则玩家点到工具栏或窗口外会得到负数索引,数组越界崩溃。
参数说明:LEFT_MARGIN 是 0,但代码里保留这个减法是为了以后改布局不被坑。注意这里用了一个 eventQueue,而不是在鼠标回调里直接执行种植逻辑。原因很简单:鼠标事件在 EDT 线程触发,如果种植逻辑里要做阳光扣除、碰撞检测、动画切换等操作,复杂起来容易阻塞界面,也容易和主循环并发修改集合。把所有玩家操作统一放进队列,由主循环按顺序消费,是游戏开发里最常见的事件模式,新手往往跳过这一步直接改数据,后面 debug 会非常痛苦。
3. 让僵尸动起来:动画循环、状态机与碰撞结算的实现
地图能种植物之后,下一步就是让另一边有东西走过来。这个章节是整个项目的骨架:主循环驱动每一帧更新,僵尸在状态机里切换行为,豌豆飞行并检测碰撞。很多人的项目卡在这一步,因为同时要处理的位置、列表、状态太多了。
3.1 主循环选型:为什么用 javax.swing.Timer 而不是 Thread.sleep
Swing 游戏的主循环有两个选项:一个是 while(true) + Thread.sleep 的独立线程,另一个是 javax.swing.Timer。我强烈推荐 Swing Timer,因为它的回调运行在 EDT 线程上,你在回调里修改 List、更新植物状态都是线程安全的,不会出现 ConcurrentModificationException。用独立线程则必须时刻小心跨线程更新组件,还会碰到焦点切换时界面假死的问题。
Timer timer = new Timer(30, e -> { world.update(); repaint(); }); timer.start();逻辑说明:Timer 构造参数 30 代表每 30 毫秒触发一次 ActionEvent,大约每秒 33 帧。每一帧先调用 world.update() 更新所有实体状态,再调用 repaint() 重绘界面。这样动画的推进和渲染是分离的:update 改数据,paint 只负责画。
参数说明:帧率不是越高越好。Swing 的 repaint 本身有合并机制,大量逻辑运算在 EDT 上执行,你把间隔调到 10ms 也不一定能跑到 100fps,反而可能因为频繁刷新导致CPU 占用飙升。常见的合理区间是 25 到 50ms。我选 30 是因为它和很多动画素材的帧节奏匹配。特别注意:不要在 update 里做图片读取、文件 IO、打印日志这些操作,它们会阻塞 EDT,直接表现为画面卡顿。
3.2 僵尸状态机:行走、啃食、死亡与出现的切换条件
如果只用布尔变量管理僵尸行为,很快就会混乱。比如“僵尸在啃食时还能被减速吗”“死亡后还能被子弹命中吗”“啃食时要不要继续移动”。正确的做法是给僵尸一个状态枚举,每种状态决定哪部分逻辑生效。这里定义一个最简状态机:WALKING、EATING、DYING、DEAD。
public class Zombie { public enum State { WALKING, EATING, DYING, DEAD } public int row; public double x; public int hp; public int maxHp = 200; public double speedPerSecond = 20.0; public State state = State.WALKING; public double eatTimer = 0; public int contactDamage = 20; public void update(double deltaSeconds) { switch (state) { case WALKING: x -= speedPerSecond * deltaSeconds; break; case EATING: eatTimer += deltaSeconds; if (eatTimer >= 1.0) { eatTimer = 0; // 这里调用当前格植物的 takeDamage(contactDamage) } break; case DYING: // 播放死亡动画,动画结束后切换 DEAD 并从列表移除 break; default: break; } } }逻辑说明:这里把速度定义成“每秒 20 像素”,而不是每帧 1 像素,这样主循环的间隔调整不影响僵尸实际速度。deltaSeconds 就是 Timer 的实际间隔换算出来的。EATING 状态下僵尸不再移动,只累积攻击计时,每满 1 秒对植物造成一次伤害。
参数说明:speedPerSecond 的调参依据是棋盘宽度:从右到左要穿过 9 个格子共 720 像素,按 20 像素每秒需要 36 秒走完全程。这是原版的节奏,能留给玩家足够时间种植。如果你觉得太简单,可以把速度调到 30,僵尸全程约 24 秒。初始僵尸建议 150 到 200 血量,配合单颗子弹伤害 20,需要 8 到 10 次命中才能打死,玩家有反馈感。
3.3 子弹飞行与碰撞检测:矩形碰撞盒怎么设计才不“魔幻”
豌豆子弹是直线飞行,检测逻辑很简单:每一帧把子弹 x 加上速度,然后判断是否与同行的僵尸碰撞。这里最容易踩坑的是碰撞盒设计。直接用图片左上角坐标做碰撞,会出现子弹明明穿过了僵尸的透明边却判定的问题,或者视觉上已经打到了却不掉血。
public class Pea { public int row; public double x; public double y; public static final int WIDTH = 20; public static final int HEIGHT = 20; public static final int DAMAGE = 20; public static final int SPEED = 300; // 像素每秒 public boolean alive = true; public Rectangle getBounds() { return new Rectangle((int) x, (int) y, WIDTH, HEIGHT); } }碰撞检测在主循环的 update 阶段完成:
for (Pea pea : peas) { if (!pea.alive) continue; for (Zombie zombie : zombies) { if (zombie.state == Zombie.State.DEAD) continue; if (zombie.row == pea.row && pea.getBounds().intersects(zombie.getCollisionBox())) { zombie.hp -= Pea.DAMAGE; pea.alive = false; break; } } }逻辑说明:子弹只检测同行僵尸,因为豌豆不会跨行攻击,这就把 O(N*M) 的复杂度压低到 O(N) 级别。矩形相交用 Rectangle.intersects 完成,这是 JDK 自带的几何运算,不需要手写边界判断。注意检查僵尸是否 DEAD,否则子弹会命中尸体导致浪费。
参数说明:碰撞盒大小直接决定命中手感。我建议僵尸的碰撞盒宽度设为图片宽度的 60%,高度为图片高度的 80%,居中放在脚底位置。这样视觉上“看起来碰上了”比“数学上碰上了”更重要,稍微放大碰撞盒能明显提升打击感。还有一个防穿技巧:子弹每帧移动距离是 SPEED * deltaSeconds,300 像素每秒在 30ms 帧率下每帧移动约 10 像素,远小于僵尸碰撞盒宽度,不会穿透;如果速度提高到 800 以上且帧率掉到 20fps,就可能跳过碰撞盒,这时要么降低速度,要么用上一帧位置和当前位置连成线段做检测。
4. 阳光、图片和关卡波次:让游戏有内容的调度层
种完植物、僵尸能走之后,游戏还缺三块内容支撑:视觉素材、经济系统、关卡节奏。阳光产出直接影响玩家操作频率,图片加载影响性能和表现力,波次系统决定游戏有没有“关卡感”。这三块恰好是 Swing 项目从“demo”走向“完整小游戏”的分水岭。
4.1 图片资源加载:getResourceAsStream 与缩放策略
图片加载是典型的新手翻车点。常见错误是写 new File("images/sunflower.png"),这种写法依赖当前工作目录,在 IDE 里能跑,一旦打成 jar 就全部失效。正确做法是把图片放到 src/main/resources 目录下,用类路径加载。
public class ImageLoader { private static final Map<String, BufferedImage> CACHE = new HashMap<>(); public static BufferedImage load(String path) { if (CACHE.containsKey(path)) { return CACHE.get(path); } try (InputStream in = ImageLoader.class.getResourceAsStream(path)) { if (in == null) { throw new IllegalArgumentException("找不到资源: " + path); } BufferedImage img = ImageIO.read(in); CACHE.put(path, img); return img; } catch (IOException e) { e.printStackTrace(); return null; } } }逻辑说明:getResourceAsStream 从 classpath 根目录查找资源,路径以 / 开头表示类路径根目录。结合缓存 Map,同一个图片只读取一次,后续直接拿引用。这样既解决了打包问题,也解决了动画每帧重复 IO 导致的卡顿。
参数说明:ImageIO.read 读进来的图片要按格子尺寸缩放。90% 的情况下不要用 img.getScaledInstance(w, h, Image.SCALE_SMOOTH) 在绘制时反复缩放,它会产生新对象且慢。正确做法是加载后就缩放一次并缓存:
BufferedImage original = ImageLoader.load("/images/sunflower.png"); BufferedImage scaled = toBufferedImage(original.getScaledInstance(70, 80, Image.SCALE_SMOOTH));注意 SCALE_SMOOTH 适合缩小,放大照片会模糊,但像素风素材反而可以用 SCALE_FAST 保持边缘锐利。我一般准备两套图片:小图用于工具栏卡片,大图用于场上实体,避免每次绘制做缩放计算。
4.2 阳光的产生和收集:定时任务与点击判定怎么写
阳光系统的逻辑其实是一个事件驱动模型:每隔几秒在场上生成一个阳光对象,该对象边下落边闪烁,玩家点击后阳光币数增加。阳光的生成可以用一个独立的 Swing Timer,或者放到主循环里按帧计时。我建议放到主循环里统管理时间,因为关卡暂停时要连阳光生成一起暂停。
public class Sun { public double x; public double y; public double targetY; public int value = 25; public boolean alive = true; public double lifeTimer = 8.0; public void update(double delta) { if (y < targetY) { y += 40 * delta; } lifeTimer -= delta; if (lifeTimer <= 0) { alive = false; } } public boolean containsPoint(int px, int py) { int radius = 24; int dx = (int) x - px; int dy = (int) y - py; return dx * dx + dy * dy <= radius * radius; } }逻辑说明:阳光落到目标行后不再下移,开始 8 秒倒计时,超时自动消失。containsPoint 用圆心距离判断鼠标是否点中,半径 24 略大于图片视觉尺寸,降低玩家点击难度。这个“扩大点击范围”的思路在移动端游戏里也常见,手指精准度低于鼠标。
参数说明:阳光生成间隔默认 7 到 9 秒,太快会让玩家手忙脚乱,太慢则会卡资源。阳光值 25 是一个经典数值:向日葵每次产 25,普通植物消耗 50 或 100,让玩家需要种 2 到 4 棵向日葵才能支撑起防线。你可以调整产生间隔来改变难度,比如每 5 秒生成一束阳光,游戏明显变得更简单。
4.3 波次系统:用配置数据驱动关卡,而不是硬编码逻辑
波次系统如果把僵尸数量、出现间隔写死在代码里,后面调平衡会非常痛苦。常见做法是让“每一波”是一个配置对象,包含开始时间、僵尸类型、数量、间隔,然后用一个独立调度器按关卡时间逐个放僵尸。
public class Wave { public double startTime; public String zombieType; public int count; public double spawnIntervalSeconds; public Wave(double startTime, String zombieType, int count, double spawnIntervalSeconds) { this.startTime = startTime; this.zombieType = zombieType; this.count = count; this.spawnIntervalSeconds = spawnIntervalSeconds; } }关卡开始时加载一组 Wave 配置:
List<Wave> waves = new ArrayList<>(); waves.add(new Wave(5, "normal", 3, 10)); waves.add(new Wave(25, "normal", 5, 8)); waves.add(new Wave(50, "runner", 8, 5));主循环维护一个 elapsedTime 变量,每次 update 累加 deltaSeconds,然后遍历 waves,如果当前时间超过 startTime 且该波还没有生成完,就按 spawnIntervalSeconds 生成僵尸。
| 关卡时间(秒) | 波次行为 | 难度目的 |
|---|---|---|
| 0~5 | 无僵尸,玩家种向日葵 | 经济铺垫 |
| 5~25 | 每 10 秒出 1 只普通僵尸 | 熟悉操作 |
| 25~50 | 每 8 秒出 1 只,密度增加 | 压力过渡 |
| 50~80 | 混合快慢僵尸,5 秒间隔 | 决胜波 |
逻辑说明:把波次数据提出来之后,调难度只需要改配置,不需要动僵尸生成逻辑。这也是小型游戏项目最重要的架构红利:数据驱动让数值和表现分离。注意所有波次配置应该在进入关卡时一次性读取,而不是每帧 new 对象。
5. Swing 游戏避坑:5 个必踩的界面与性能问题
这部分是我自己反复踩过、也看到别人反复踩的五个坑。每条都按“现象 → 原因 → 解决”写,后面再遇到直接用对应方案排查。
5.1 越玩越卡:Timer 回调里做了重活
现象:游戏刚启动流畅,随着植物种类增多开始掉帧,阳光飘落时尤其明显;窗口失去焦点再回来,界面卡成幻灯片。
原因:主循环的 update 里做了图片读取、对象频繁 new、System.out 打印日志等高开销操作,这些全在 EDT 上执行。任何一个耗时超过 30ms,就会导致后续 repaint 排队,动画断裂。
解决:所有图片资源在 GamePanel 构造函数里用 ImageLoader 加载完毕;子弹、阳光等短生命周期对象尽量复用,或者做好资源池;不要在 update 里打印任何日志。如果想确认是不是 EDT 阻塞,在 Timer 回调开头和末尾记 System.nanoTime 差值,超过 30ms 说明该优化了。
5.2 图片白块或变形:加载失败还继续绘制
现象:植物显示为白色方块,或者图片被拉得面目全非,有时候运行几次才出现。
原因:ImageIO.read 读不到资源返回 null,代码没有判空就直接 drawImage;getScaledInstance 的宽高参数写错,把 0 传进去会导致等比例缩放失效。
解决:加载方法里对 null 的处理要显式抛异常,而不是返回后继续用。绘制前强制检查:
BufferedImage img = ImageLoader.load("/images/plant.png"); if (img == null) { // 用一个纯色占位绘制,并打印错误路径,而不是直接画 null g.setColor(Color.GREEN); g.fillRect(x, y, w, h); return; }这样即使素材缺失,游戏也不会闪退,而且你能立刻从控制台看到是哪个路径出了问题。缩放时指定具体宽高,不要用 0。
5.3 碰撞判定和视觉位置差半格:坐标基准不统一
现象:豌豆已经穿过僵尸头顶却没有命中,或者看起来还隔着一段距离僵尸就掉血了。
原因:绘制图片时用左上角定位,但碰撞盒计算时用了中心点,或者把 x 当成了图片的左上角,但图片本身有大量透明留白,导致碰撞盒偏离。
解决:统一以“脚底中心”作为所有实体对象的定位点。僵尸 x 表示脚底水平中心,绘制时 drawImage(img, (int)(x - img.getWidth()/2), y - img.getHeight(), null),碰撞盒则围绕脚底中心构建。每帧把碰撞盒用不同颜色画出来调试:
g.setColor(Color.RED); g.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);这是最容易见效的调试手段,多画一次碰撞盒,问题立刻暴露。
5.4 打包成 jar 后素材全部丢失:路径写错层级
现象:在 IDE 里跑得好好的,导出的 jar 双击运行提示找不到图片,音效也没了。
原因:代码里用了 new File("images/xxx.png"),依赖当前运行目录;而 jar 打包后文件在压缩包内部,File 根本访问不到。
解决:全部改用 getResourceAsStream 读取,并保证构建工具把 resources 目录打进去。用 Maven 时资源默认在 src/main/resources,打包后位于 jar 内的根路径或 BOOT-INF/classes 下。检查方式是在 IDE 里打开 jar 包看里面有没有 images 目录,路径必须和 getResourceAsStream 的入参完全一致,比如 /images/zombie.png。
5.5 多线程更新界面导致随机闪退:动到了 EDT 的地盘
现象:程序偶尔抛 ConcurrentModificationException 或 NullPointerException,多发于僵尸死亡或点击阳光瞬间,重启后可能又正常。
原因:有人为了“让动画更流畅”用 Thread 或 ScheduledExecutorService 去更新僵尸位置、减血量,非 EDT 线程同时修改了正在被 paintComponent 遍历的集合。
解决:整个游戏的逻辑更新全部收敛到 Swing Timer 里执行,禁止额外起线程改游戏数据。如果实在要用线程做异步加载,加载完成后必须通过 SwingUtilities.invokeLater 回到 EDT 再修改游戏状态。集合遍历时尽可能使用快照迭代,比如 for (Zombie z : new ArrayList<>(zombies)),能大幅降低并发修改概率。
6. 手感优化:双缓冲、暂停与快捷键三个细节决定作品感
地图、僵尸、阳光、波次都跑通后,这个项目已经能玩,但离“作品”还差一步。我做过这个项目后最大的体会是:功能完整和手感流畅完全是两回事。这里分享三个最值得投入的细节优化。
第一个是双缓冲。Swing 的 JPanel 默认已经开启了双缓冲,isDoubleBuffered() 返回 true,所以大多数情况下你不需要自己创建 BufferedImage 做离屏绘制。我看到有人强行自己实现双缓冲,结果和 Swing 内部缓冲冲突,画面反而闪烁。唯一例外是当你的绘制对象超过几千个且每一帧有大量重复背景时,可以手动把静态背景先绘制到一张离屏图,每帧只 drawImage 背景图而不是重新画几十个草坪块。这个优化在一些“地图很大、像元很多”的项目里收益明显,但在植物大战僵尸这种固定 5×9 的棋盘里,直接画背景就够。
第二个是暂停功能。按 P 键暂停和继续,大多数人的第一反应是写一个 boolean paused 然后所有 update 都判断。这样容易漏掉 Timer 和动画状态。我更建议直接使用 timer.stop() 和 timer.start(),同时把“暂停”作为一个独立状态存入游戏状态枚举。这样音乐的暂停、动画的暂停、波次计时的暂停天然统一。注意暂停时要把鼠标事件也挡掉,否则玩家可以趁着暂停疯狂点击阳光。我的做法是在 GamePanel.mousePressed 里判断 if (gameState == State.PAUSED) return。
第三个是快捷键和界面反馈。给植物卡片绑定 1 到 4 的数字键,选中后高亮卡片并显示种植冷却条,这个反馈让玩家明显觉得“像个游戏”而不是教学 Demo。实现 KeyListener 时注意焦点问题:JFrame 默认焦点不在内容面板上,要调用 setFocusable(true) 和 requestFocusInWindow()。冷却条我画在卡片下方的进度条,每次种植后从 0 增长到 100%,用当前时间与种下时间计算比例,比单独维护倒计时变量更稳。
做这个项目时我最深刻的教训是:一开始把速度、伤害、冷却全部写死在各个类里,后来调平衡改到想砸键盘。全部收敛到 GameConfig 和关卡配置后,平衡调整变成了改表格和改常量的事。这些参数没有标准答案,但集中管理、允许配置、开着碰撞盒调试,这三点是通用经验。如果你卡在某个细节,不要急着怀疑 Java 不行,先试试把实体的坐标和碰撞盒画出来看一眼,多半问题出在坐标基准和矩形尺寸上。希望帮到你。
本文还有配套的精品资源,点击获取