首页 / 资讯中心 / 文章详情

Java Swing 手搓森林冰火人:游戏循环与 AABB 碰撞实战

Java Swing 手搓森林冰火人:游戏循环与 AABB 碰撞实战 ★ FEATURED ARTICLE
简介面向Java初学者、课程设计实践者与游戏开发入门读者的森林冰火人单人版工程围绕角色行走跳跃、水晶收集、积分累计和倒计时失败等玩法涉及Swing图形界面、键盘事件监听、游戏循环、重力模拟与碰撞检测等知识点可用于项目练习、改造或答辩演示。压缩包共78个文件约2.98MB以48张jpg和14个gif图像素材为主配有6个java源码、7个class编译文件及Eclipse工程配置便于导入IDE运行并对照阅读实现。目前已有2664人学习下载。读者可据此梳理角色移动与跳跃的物理参数、水晶触碰判定与分数更新逻辑、Timer倒计时结束条件以及关卡与碰撞检测的组织方式并借助现成素材和源码快速搭建可运行版本理解Java小游戏的界面构建、事件处理与状态更新流程对源码结构、素材替换与二次开发亦有参考意义。1. 一个人两只手java 小游戏森林冰火人单人版到底解决了什么玩过原作的人都有个共识冰火人本来是双人游戏一个人根本玩不转。这个 Java 版把两套键位塞进同一个键盘WASD 管火人方向键管冰人左手跑位右手踩机关难度变成了一种单人解谜训练。关卡、火池水池毒池、按钮拉杆升降台、宝石和出口门一套 2D 解谜该有的骨架它都齐全。适合谁Java 语法过了、想做一个能真正跑起来的完整小游戏练手的人以及想看清 Swing 游戏循环、AABB 碰撞、瓦片地图到底怎么写的人。不适合谁想找引擎级方案、想联机对战的可以直接跳过。2. 技术栈与工程骨架Swing 加 Java2D 怎么撑起一个 2D 解谜游戏2.1 技术选型为什么是 Swing Java2D不是 JavaFX 也不是游戏引擎这个项目的技术栈非常朴素JDK 自带的javax.swing负责窗口和面板java.awt.Graphics2D负责所有绘制javax.swing.Timer负责驱动帧循环键盘监听用 Swing 的 Key Bindings没有任何第三方依赖。说白了一个JFrame加一个JPanel把paintComponent重写掉游戏就跑起来了。选这套东西的好处是门槛低、分发简单。编译完打成 jar双击就能玩不用装运行库不用配 OpenGL。对一个以「看懂 2D 游戏骨架」为目的的小项目来说这一点很关键——引擎会把游戏循环、碰撞、资源加载这些真正值得看的东西全部藏起来你用 LibGDX 五分钟能出一个能跑的 Demo但一个月后还是不知道一帧里到底发生了什么。代价也很明确。Java2D 是立即模式绘制没有场景图、没有内置的精灵批处理每一帧都要从头把瓦片和角色重画一遍Swing 的Timer精度只能到毫秒级长时间跑会有漂移物理这块完全要自己写重力、落地吸附、斜向卡角都得手动处理。所以这个项目的定位不是「工程级产品」而是「麻雀虽小五脏俱全的教学样本」——它把 2D 解谜游戏的六个核心问题都摆出来了帧循环、输入、瓦片地图、碰撞、机关状态、关卡数据。我用下来最舒服的一点是它的调试成本极低。画面画错了直接断点打在paintComponent里看坐标碰撞不对把角色的Rectangle画成半透明红色就能定位。引擎里想干同样的事得先翻半天文档。2.2 目录结构源码分五个包资源分三类文件常见的组织方式是按职责分包而不是按「界面/逻辑」分两层。这样做的原因是角色和瓦片都属于实体但生命周期完全不同混在一起改起来会很乱。下面是这类项目最典型的目录形态fireice-solo/ ├── src/ │ ├── Main.java // 入口只负责创建窗口 │ ├── core/ │ │ ├── GamePanel.java // 主面板帧循环 绘制入口 │ │ ├── GameState.java // 运行/暂停/死亡/通关的状态机 │ │ └── InputState.java // 按键状态的集中记录 │ ├── entity/ │ │ ├── Player.java // 角色抽象基类含物理与碰撞 │ │ ├── FireBoy.java // 火人吃火池怕水池 │ │ ├── WaterGirl.java // 冰人吃水池怕火池 │ │ └── Tile.java // 瓦片含 solid / hazard 属性 │ ├── mechanism/ │ │ ├── Mechanism.java // 机关基类定义 update/draw/reset │ │ ├── Button.java // 压力板 │ │ ├── Lever.java // 拉杆 │ │ ├── Platform.java // 升降台 │ │ └── Door.java // 出口门读取宝石计数 │ ├── level/ │ │ ├── Level.java // 关卡运行时瓦片数组 机关列表 │ │ └── LevelLoader.java // 把字符地图解析成 Level │ └── ui/ │ ├── Hud.java // 宝石计数、计时、重开提示 │ └── MenuPanel.java // 开始菜单 ├── res/ │ ├── images/ // 精灵图、瓦片贴图 │ ├── levels/ // level1.map、level2.map ... │ └── sound/ // 拾取、死亡、通关音效 └── README.mdcore包只关心「怎么跑」entity和mechanism只关心「个体怎么动、怎么变」level负责把两者粘起来。这个分层有一个很实际的好处加一种新机关只需要动mechanism包和一个解析分支GamePanel一行都不用改。反过来如果你把机关判定直接写在GamePanel.update()里加到第五种机关的时候那个方法会变成两百行改一次心惊一次。2.3 跑起来JDK 版本、编译命令与资源路径环境要求不高JDK 8 及以上都能编用 JDK 17 也没问题——代码里没有用到模块系统的特性也不依赖 JavaFX。Windows、macOS、Linux 行为基本一致唯一需要注意的是资源路径的斜杠方向和大小写敏感问题。命令行编译步骤# 1. 编译输出到 out 目录-encoding 防止中文注释乱码 javac -encoding UTF-8 -d out $(find src -name *.java) # 2. 把资源拷进 classpath注意保持 res 下的子目录层级 cp -r res/* out/ # 3. 运行主类在默认包下 java -cp out MainWindows 下$(find ...)换成手动列文件或者用 IDE 编译即可影响不大。三个参数的含义-d out决定 class 文件落点-encoding UTF-8决定源码读取编码-cp out决定运行时去哪里找 class 和资源。资源加载是整个启动链路里最容易翻车的一环。正确写法是从 classpath 取而不是从文件系统路径取// 正确路径以 / 开头相对于 classpath 根 InputStream in LevelLoader.class.getResourceAsStream(/levels/level1.map); // 错误工作目录一变就找不到 File f new File(res/levels/level1.map);原因很简单打成 jar 之后res/这个目录在磁盘上根本不存在了File必然失败而getResourceAsStream是从 jar 内部的条目里读永远有效。这个坑我在别的项目上踩过不止一次血泪经验是只要资源最终要打进 jar就从第一天开始用getResourceAsStream别图省事用File。提示如果 IDE 里跑得好好的打包之后报NullPointerException且堆栈指向资源加载那一行八成就是路径少了开头的斜杠或者资源压根没进 jar。3. 主循环与输入分发60 帧下两个角色怎么各走各的3.1 用 javax.swing.Timer 做固定步长update 和 repaint 的分工游戏循环只有两件事推进逻辑、重绘画面。这个项目用javax.swing.Timer把两件事按固定间隔绑在一起间隔 16 毫秒约等于 60 帧。public class GamePanel extends JPanel implements ActionListener { private static final int FPS 60; private static final int DELAY 1000 / FPS; // 16ms private final Timer timer; private final GameState state; public GamePanel(GameState state) { this.state state; setPreferredSize(new Dimension(960, 640)); setBackground(Color.BLACK); setFocusable(true); // 不设这个键盘事件收不到 setDoubleBuffered(true); // 开双缓冲防闪烁 timer new Timer(DELAY, this); } public void start() { timer.start(); } Override public void actionPerformed(ActionEvent e) { state.update(); // 只改数据不碰画笔 repaint(); // 请求重绘由 EDT 调度 paintComponent } }actionPerformed里刻意只做update和repaint两件事所有绘制逻辑都放在paintComponent里。这个分工不是风格问题是正确性问题Swing 的绘制线程EDT和定时器回调是同一个线程如果在update里顺手拿了Graphics去画画面会和你想象的时序对不上调试起来是黑匣子。固定步长的好处是逻辑简单位移直接按「每帧多少像素」算不用乘 deltaTime。坏处是机器卡顿时游戏会整体变慢而不是掉帧。对这类单机解谜游戏来说慢一点比跳帧穿墙更安全所以这个取舍是合理的。如果要做成跑酷类就得改成累加时间、固定步长补帧的写法。setDoubleBuffered(true)这行也值得说一句。Swing 默认对顶层容器开了双缓冲但在JPanel上显式开一次配合super.paintComponent(g)先清背景能避免大量瓦片重绘时的撕裂感。3.2 绘制顺序背景、瓦片、角色、HUD 谁先谁后paintComponent的调用顺序就是图层的叠加顺序后画的盖住先画的。这个顺序不是随便排的排错了会出现「角色被墙面挡住」或者「HUD 被地板盖住」这种一眼假的问题。Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 先清空避免残影 Graphics2D g2 (Graphics2D) g; // 像素风放大时关掉抗锯齿避免精灵边缘发虚 g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); level.drawBackground(g2); // 1. 背景图层 level.drawTiles(g2); // 2. 静态地形 level.drawMechanisms(g2); // 3. 机关升降台要在角色下面 fireBoy.draw(g2); // 4. 角色 waterGirl.draw(g2); level.drawForeground(g2); // 5. 前景装饰水花、雾气 hud.draw(g2); // 6. HUD 永远在最上层 }这里有两个细节容易被忽略。第一升降台必须画在角色之前但它的碰撞盒要在角色移动之后才更新绘制顺序和更新顺序是两个独立的概念别混在一起。第二super.paintComponent(g)不能省省掉之后连续重绘会留下上一帧的残影看起来就像角色拖了一条尾巴。如果关卡瓦片多可以在Level里做一层缓存把静态地形先画到一张BufferedImage上paintComponent里直接drawImage一次贴出来。这个优化在 30×20 的瓦片规模下不明显但关卡放大到 100×60 就能感觉到差别。3.3 单人双角色输入Key Bindings 怎么把两套键位分开单人版的灵魂就在这里两个角色两套键位一个人操作。实现上不能用KeyListener要用 Swing 的 Key Bindings。原因有三个KeyListener要求组件必须有焦点焦点一丢按键全部失效它不处理按键重复它在组合键场景下顺序不可控。public class InputState { private final SetString down new HashSet(); public void press(String action) { down.add(action); } public void release(String action) { down.remove(action); } public boolean isDown(String action) { return down.contains(action); } }// 在 GamePanel 构造时统一绑定 private void bindKeys() { InputMap im getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am getActionMap(); // 火人WASD bind(im, am, A, fireLeft); bind(im, am, D, fireRight); bind(im, am, W, fireJump); // 冰人方向键 bind(im, am, LEFT, waterLeft); bind(im, am, RIGHT, waterRight); bind(im, am, UP, waterJump); } private void bind(InputMap im, ActionMap am, String key, String action) { im.put(KeyStroke.getKeyStroke(key), action); am.put(action, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { input.press(action); // 只记录状态不改坐标 } }); // 松开事件单独绑一个 key名字加后缀区分 im.put(KeyStroke.getKeyStroke(released key), action Released); am.put(action Released, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { input.release(action); // 状态清零 } }); }关键点是按键回调里只写「这个动作被按下了」绝不直接改角色坐标。真正的位移统一在update里根据InputState算。这样做的好处是跳跃这种需要「按住时长决定高度」的操作能自然实现而且角色移动和输入来源彻底解耦——将来想加手柄或者录制回放只换InputState的数据来源就行。WHEN_IN_FOCUSED_WINDOW这个条件也很重要。用WHEN_FOCUSED的话玩家点一下窗口标题栏再回来角色就动不了了。用窗口级绑定只要窗口是激活的就能收到按键。4. 玩法核心拆解瓦片碰撞、机关联动与死亡判定的完整链路4.1 瓦片地图与 AABB碰撞检测的最小实现整个关卡是一张二维瓦片数组每格 40×40 像素。角色的碰撞盒是一个矩形宽度略小于格子26 像素高度等于格子36 像素——故意留窄一点是为了让玩家从一格宽的缝隙里穿过去时不至于卡住。public class Level { public static final int TILE 40; private Tile[][] tiles; private int cols, rows; public Tile tileAt(int col, int row) { // 越界当成实心墙防止角色跑出地图 if (col 0 || col cols || row 0 || row rows) return Tile.WALL; return tiles[row][col]; } // 矩形与所有实心瓦片做粗筛命中即返回 public boolean collides(Rectangle box) { int c0 box.x / TILE; int c1 (box.x box.width - 1) / TILE; int r0 box.y / TILE; int r1 (box.y box.height - 1) / TILE; for (int r r0; r r1; r) for (int c c0; c c1; c) if (tileAt(c, r).isSolid()) return true; return false; } }collides的写法是先用矩形范围算出可能重叠的瓦片行列区间再逐个判断而不是遍历整张地图。这一步从 O(关卡瓦片数) 降到 O(4)是最简单也最有效的优化。角色的移动必须分轴处理先水平后垂直protected void moveAndCollide(int dx, int dy, Level level) { // 水平轴 if (dx ! 0) { Rectangle next new Rectangle(x dx, y, w, h); if (!level.collides(next)) { x dx; // 无阻挡正常移动 } else { // 贴墙把坐标对齐到瓦片边界避免残留缝隙 x dx 0 ? (x w) / Level.TILE * Level.TILE - w : (x / Level.TILE 1) * Level.TILE; vx 0; } } // 垂直轴 if (dy ! 0) { Rectangle next new Rectangle(x, y dy, w, h); if (!level.collides(next)) { y dy; onGround false; } else { if (dy 0) { // 落地 y (y h) / Level.TILE * Level.TILE - h; onGround true; } vy 0; // 撞头或落地都清零竖直速度 } } }如果两个轴一起判断会出现「斜着走的时候明明旁边是空的却被挡住」的卡角现象。分轴之后水平被挡不影响垂直下落手感立刻正常。这是 2D 平台游戏里最经典的一课自己写一遍比看十篇教程管用。4.2 机关联动按钮、拉杆、升降台怎么和角色解耦机关系统的设计目标是「加一种新机关不用改主循环」。做法是定义一个基类把所有机关都当成需要每帧update的对象由Level统一调度。public abstract class Mechanism { protected final Rectangle hitBox; protected Mechanism(int x, int y, int w, int h) { this.hitBox new Rectangle(x, y, w, h); } /** 每帧调用参数是当前两个角色的状态 */ public abstract void update(Player fireBoy, Player waterGirl, Level level); public abstract void draw(Graphics2D g2); /** 重开关卡时恢复到初始状态 */ public abstract void reset(); }压力板的实现最短也最典型public class Button extends Mechanism { private boolean pressed; private final int targetId; // 联动哪一组平台 Override public void update(Player fireBoy, Player waterGirl, Level level) { // 只要有一个角色站在上面就算触发 pressed fireBoy.getBounds().intersects(hitBox) || waterGirl.getBounds().intersects(hitBox); level.setPlatformState(targetId, pressed); } Override public void reset() { pressed false; } }拉杆的逻辑不同它是「接触即切换」而不是「站上去才生效」所以要加一个冷却否则角色站在拉杆上会每帧翻转一次public class Lever extends Mechanism { private boolean on; private int cooldown; Override public void update(Player fireBoy, Player waterGirl, Level level) { if (cooldown 0) { cooldown--; return; } boolean touching fireBoy.getBounds().intersects(hitBox) || waterGirl.getBounds().intersects(hitBox); if (touching) { on !on; level.setPlatformState(targetId, on); cooldown 15; // 约 0.25 秒防止连击 } } }这个冷却机制是我建议一定要加的。没有它的时候玩家碰到拉杆会看到平台疯狂上下跳一度以为是渲染 bug实际上是每帧翻转一次。targetId把机关和平台做了间接关联好处是同一组平台可以被多个按钮控制比如两个按钮必须同时踩住这在双人合作解谜里是标配玩法。4.3 元素池判定火人碰水、冰人碰火的死亡链路冰火人的核心规则就三条火人碰水死、冰人碰火死、任何人碰毒池死。实现上不要把判定写进Player而是让Level提供查询GamePanel做结算。public enum Hazard { NONE, FIRE, WATER, POISON } public Hazard hazardAt(Rectangle box) { int c0 box.x / TILE, c1 (box.x box.width - 1) / TILE; int r0 box.y / TILE, r1 (box.y box.height - 1) / TILE; Hazard result Hazard.NONE; for (int r r0; r r1; r) for (int c c0; c c1; c) { Hazard h tileAt(c, r).getHazard(); if (h ! Hazard.NONE) result h; } return result; }private void checkDeaths() { Hazard fh level.hazardAt(fireBoy.getBounds()); if (fh Hazard.WATER || fh Hazard.POISON) { fireBoy.die(); // 切换到死亡动画 状态机 state.set(GameState.DEAD); return; } Hazard wh level.hazardAt(waterGirl.getBounds()); if (wh Hazard.FIRE || wh Hazard.POISON) { waterGirl.die(); state.set(GameState.DEAD); return; } }这里有一个手感细节判定用的是角色完整的碰撞盒但实际操作中玩家会觉得「我明明还没碰到水就死了」。原因是碰撞盒的脚部贴地时正好压在水池格子上。比较稳妥的做法是把判定盒向内缩 4 像素或者只取碰撞盒下半部分做池子判定。这种一像素级别的调整说出来没什么技术含量但玩家体验的差别就是「这游戏能玩」和「这游戏有毒」。死亡之后不要立刻重开播一个 0.5 秒的动画再重置让玩家看清楚自己死在哪。这个间隔是后悔药的窗口期也是防止玩家连续暴毙时产生挫败感。4.4 关卡数据一张字符地图怎么变成可玩的场景关卡用纯文本存一个字符代表一格改关卡就是改文本不用碰代码也不用装地图编辑器。文件放在res/levels/下加载时从 classpath 读。######################## #F....D......W........G# #......................# #....BBB.....~~~.......# #............~~~.......# #.....L................# ###########.....######## #^^^^^^^^^#.....#......# ########################字符映射表字符含义属性#墙solid.空地可通过F火人出生点唯一W冰人出生点唯一~水池hazardWATER^火池hazardFIREX毒池hazardPOISONB压力板mechanismL拉杆mechanismG宝石collectibleD出口门goal加载器把字符逐个映射成瓦片同时把出生点和机关位置抽出来机关不进瓦片数组而是进一个独立的列表public static Level load(String resource) throws IOException { ListString lines; try (InputStream in LevelLoader.class.getResourceAsStream(resource)) { if (in null) throw new IOException(关卡不存在: resource); lines new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)) .lines().collect(Collectors.toList()); } int rows lines.size(); int cols lines.get(0).length(); Level level new Level(cols, rows); for (int r 0; r rows; r) { for (int c 0; c cols; c) { char ch lines.get(r).charAt(c); int px c * Level.TILE, py r * Level.TILE; switch (ch) { case F: level.fireSpawn new Point(px, py); level.setTile(c, r, Tile.EMPTY); break; case W: level.waterSpawn new Point(px, py); level.setTile(c, r, Tile.EMPTY); break; case B: level.addMechanism(new Button(px, py, Level.TILE, Level.TILE / 2)); break; case G: level.addGem(px, py); level.setTile(c, r, Tile.EMPTY); break; default: level.setTile(c, r, Tile.fromChar(ch)); break; } } } return level; }fromChar里对未知字符应该抛异常或者打日志而不是默默当成空地。我之前做过一个版本是默默忽略结果地图里有个字符拼错了那一格凭空消失排查了半天才反应过来是数据问题不是碰撞问题。5. 避坑与排查跑不起来、按键失灵、角色穿墙的五类现场5.1 打包后双击没反应或者报资源为 null现象IDE 里跑得好好的打成 jar 之后一启动就白屏控制台抛NullPointerException堆栈指向getResourceAsStream那一行返回值为 null。原因资源加载用了new File(res/...)或者getResourceAsStream(levels/level1.map)这种相对路径写法。前者在 jar 里根本没有对应的磁盘文件后者是相对当前类的包路径去找而资源实际在 classpath 根下。解决统一改成以/开头的绝对 classpath 路径并在打包时确认资源确实进了 jar。用jar tf fireice.jar | grep levels看一眼就知道了。如果打不出来说明构建脚本只拷了 class 没拷资源补上cp -r res/* out/这一步。注意getResourceAsStream返回 null 的时候不要直接往InputStreamReader里传会抛一个完全不相关的异常把真正的原因盖掉。先判空再抛带路径信息的异常能省一半排查时间。5.2 键盘按下去没反应点一下窗口才行现象游戏启动了画面在动但角色不动。用鼠标点一下游戏区域突然就能操作了。原因用了KeyListener绑定在JPanel上而JPanel没有拿到键盘焦点。Swing 里焦点在菜单栏、标题栏或者别的组件上时keyPressed根本不会派发过来。解决两条路。一是改用 Key Bindings 加WHEN_IN_FOCUSED_WINDOW这是最稳的做法二是保留KeyListener但要setFocusable(true)并在addNotify之后调requestFocusInWindow()。第二种在窗口失去焦点再回来后还是会失效所以推荐第一种。顺带一提requestFocusInWindow()在构造函数里调是无效的那时候组件还没显示出来必须在addNotify()或invokeLater里调。这个细节坑过不少人。5.3 角色高速移动时穿过薄墙现象低速走的时候墙体正常阻挡一旦角色速度提上来比如加了冲刺或者帧间隔变长偶尔会直接穿到墙的另一边。原因碰撞检测只在移动后的位置上做一次判断。如果这一帧的位移大于瓦片尺寸角色可能从墙的左侧直接跳到右侧中间那一格从来没被检测到过。解决控制单帧位移不超过瓦片尺寸的一半超出的部分拆成多次小步移动。改法是在moveAndCollide外面套一层循环protected void moveWithSubStep(int dx, int dy, Level level) { int steps Math.max(Math.abs(dx), Math.abs(dy)) / (Level.TILE / 2) 1; int stepX dx / steps; int stepY dy / steps; for (int i 0; i steps; i) { moveAndCollide(stepX, stepY, level); // 每步都做一次完整判定 } }同时给速度加一个上限正常情况下MAX_SPEED控制在 6 像素/帧以内配合 40 像素的瓦片基本不会出问题。5.4 重开关卡之后机关还保持着上一局的状态现象死亡后按 R 重开角色回到出生点但之前踩下去的按钮还是按下状态升起的平台还悬在半空。原因重开时只重新创建了角色对象Level实例被复用了而机关的状态存在机关对象自己身上没有被清掉。解决给Mechanism定义reset()在Level.restart()里遍历所有机关调一遍同时清空宝石计数和角色位置。更彻底的做法是直接重新加载一次关卡文件代价是多一次 IO但状态绝对干净。我一般选后者因为省心而且关卡文件很小几百字节的读取可以忽略。5.5 画面闪烁或者 CPU 占用一路飙高现象角色移动时画面有撕裂感或者风扇突然狂转。原因闪烁通常是没调super.paintComponent(g)上一帧的像素没被清掉也可能是绘制代码直接在外层getGraphics()上画绕过了 Swing 的双缓冲。CPU 飙高一般是paintComponent里做 IO——每次重绘都ImageIO.read一次图片资源60 帧就是每秒读 60 次磁盘。解决图片在类加载或初始化阶段一次性读进BufferedImage缓存起来paintComponent里只做drawImage。绘制入口只保留repaint()绝不在update里拿Graphics。这两点做好一个 2D 小游戏的 CPU 占用通常只有个位数百分比。6. 二次开发加一关、加一种机关、调一次手感的实操路线加关卡是最容易上手的改造也是最能建立信心的。步骤就三步在res/levels/下新建一个.map文件按字符表把地形拼出来确认火人和冰人的出生点各只有一个在关卡列表里注册这个文件名。有一个硬约束必须遵守——宝石数量必须大于等于出口门要求的数量否则门永远打不开玩家会在关卡里转半天。我建议在LevelLoader里加一行校验加载时发现宝石不够就直接抛异常比让玩家帮你在游戏里 debug 强。加一种机关比如传送带流程是固定的继承Mechanism实现update、draw、reset三个方法然后在LevelLoader里加一个字符映射在Level.updateMechanisms()里确认新机关已经被加入遍历列表。传送带的update里只需要修改站在上面的角色的x坐标注意别直接改vx否则角色离开传送带之后还会滑一段。调手感是投入产出比最高的部分。所有魔法数字都应该集中到Player的静态常量里常量常见取值作用GRAVITY0.6 ~ 0.9每帧增加的下落速度MAX_FALL10 ~ 14终端速度防止穿地MAX_SPEED4 ~ 6水平最大速度ACCEL1 ~ 2地面加速度JUMP_POWER-12 ~ -15跳跃初速度负值向上改完之后不要靠肉眼判断做一个可复现的验证脚本更靠谱用 headless 模式跑几十帧模拟输入断言角色的最终坐标和预期一致。Test public void jumpShouldNotPassThroughCeiling() { Level level LevelLoader.load(/levels/test_ceiling.map); Player p new FireBoy(level.fireSpawn); p.setVy(-20); // 故意给一个超大的向上速度 for (int i 0; i 30; i) p.update(level, InputState.empty()); assertTrue(p.getY() 0, 角色不应该穿出地图顶部); }这种做法不能覆盖手感问题但能挡住「改了一个常量导致穿墙」这类回归。最后说一个习惯。从那以后我每次改完碰撞相关的代码都会把角色的碰撞盒用半透明红色画出来跑一遍从出生点走到关卡尽头专门往墙角、升降台边缘、水池边沿这些地方蹭。这个动作花不了两分钟但能挡住绝大多数「跑起来才发现」的问题。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站