简介:这份Java小游戏项目以森林冰火人单人版为主题,面向Java初学者、课程设计学生及游戏开发入门者,帮助读者通过一个可运行的案例理解图形界面、事件处理与基础游戏逻辑。压缩包共78个文件,约2.98MB,包含6个java源码、7个class编译文件、48张jpg与14个gif图像素材,以及Eclipse工程配置,兼顾代码阅读、素材替换和项目导入。已有2664人学习,说明其作为入门练手案例具有一定参考价值。读者可从中梳理角色行走跳跃、重力与碰撞检测、水晶收集计分、定时器倒计时结束游戏、关卡难度设置等实现思路,并借助源码与图片资源进行调试、复现和二次开发,适合作为Java小游戏课程设计或Swing/AWT实践参考。
1. 一个人怎么同时玩两个角色:先看清单人版改了什么
第一次打开这个 java 小游戏,我盯着屏幕愣了十几秒:森林冰火人原本是两个人挤在一个键盘上配合的游戏,单人版把「两个人」变成了「一个人轮流操作两个角色」。火人碰水会死、水人碰火会死、两个角色都怕绿色毒液,这套规则没变,变的是操作节奏:你得先让火人踩住按钮把闸门打开,再切到水人跑过去,切慢一点机关就会复位,下面等着你的就是重新开始。这份源码适合刚学完 Java 语法、想找一个能完整跑起来又不至于被框架劝退的练手项目的人,也适合想拿一份可改可扩的 2D 横版小游戏骨架做二次开发的人。它的价值不在画面,而在于把游戏循环、瓦片地图、AABB 碰撞、角色状态切换这四件事用最朴素的 Swing 写了一遍,逻辑全在你能读懂的几百行里。
2. 项目骨架与技术选型:Swing + AWT 为什么够撑起一个 2D 小游戏
很多人一看到 Java 写游戏就先想到引擎,其实这类关卡制的小体量项目根本不需要。这个项目走的是纯 JDK 路线:JFrame 开窗、JPanel 当画布、Graphics2D 画图、javax.swing.Timer 驱动帧,图片资源用 ImageIO 读,音效可选 javax.sound.sampled。整套下来零第三方依赖,javac能编、java能跑,打包就是一个 jar。对二次开发的人来说这是好事:没有构建插件、没有原生库、没有版本地狱,你改一行代码重编一次就生效,出问题也只会出在你自己的代码里。
2.1 目录结构:渲染、实体、关卡数据三层分开
这类工程如果全塞进一个 JPanel,写到第三个机关就会失控。比较常见的拆法是按职责分包,src放代码、res放图片、levels放关卡文本:
ForestIceFire/ ├── src/ │ └── com/game/ │ ├── main/ # GameMain 入口,只负责装配窗口 │ ├── core/ # GamePanel(画布)、GameWorld(世界)、Input(按键状态) │ ├── entity/ # Player、Gem、Door、Lever、MovingPlatform │ ├── level/ # TileMap(网格)、TileType(枚举)、LevelLoader(读文件) │ └── util/ # ResourceLoader(图片缓存)、MathUtil(clamp 之类) ├── res/ │ ├── images/ # fireboy.png / watergirl.png / tile.png │ └── sound/ ├── levels/ │ ├── level1.txt │ └── level2.txt └── out/ # javac 的输出目录,不进版本库main只做装配、core管流程、entity管对象、level管数据,这条线划清楚以后,加一种机关基本只动entity和level两个包。我见过不少同学把Player的绘制直接写进GamePanel.paintComponent,一开始省事,等到要给角色加个受击闪白效果,就得在全屏绘制方法里翻半天。绘制永远跟着实体走,paintComponent只做调度,这个习惯从第一个项目就该养成。
2.2 游戏主循环:为什么用 javax.swing.Timer 而不是 Thread
Swing 是单线程模型,所有界面操作必须落在事件分发线程(EDT)上。你要是起一个new Thread在后台循环里repaint(),短时间看不出问题,跑久了就会遇到画面撕裂、偶尔卡住一两帧这种「玄学」现象,查起来相当难受。javax.swing.Timer的回调本身就排在 EDT 上,天然避开这个坑:
public class GamePanel extends JPanel implements ActionListener, KeyListener { private static final int FRAME_DELAY = 16; // 约 60 帧/秒 private final Timer timer; private final GameWorld world; private final Input input = new Input(); public GamePanel() { setPreferredSize(new Dimension(960, 640)); // 30x20 个 32px 瓦片 setDoubleBuffered(true); // 开双缓冲,减少闪烁 setFocusable(true); // 不设焦点收不到键盘事件 addKeyListener(this); this.world = new GameWorld(input); this.timer = new Timer(FRAME_DELAY, this); this.timer.start(); } @Override public void actionPerformed(ActionEvent e) { world.update(); // 只算逻辑:移动、碰撞、判定,不碰任何绘制 repaint(); // 重绘交给 Swing 调度,别自己调 paint } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 必须调,否则残影 Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); world.render(g2); // 地图 -> 机关 -> 角色 -> HUD } }几个参数值得说明。FRAME_DELAY = 16对应大约 60 帧,改成 8 会更顺滑但 CPU 占用翻倍,改成 33 就掉到 30 帧,跳跃手感立刻发飘——这个值我一般从 16 开始调,卡就往上加,飘就往下减。setDoubleBuffered(true)是 Swing 自带的缓冲,一般默认开启,显式写一遍是为了后面换 Canvas 时不踩坑。world.update()和world.render()严格分离,这样你以后想加暂停、加回放,只要在 update 外面套个开关,绘制那半边一行都不用动。
注意:
Timer是固定间隔,不是固定步长。机器一卡,两帧之间的真实时间就变长,角色像是被瞬移了一段。要更稳的做法是在actionPerformed里记录System.nanoTime()做累加,一帧最多补跑 3 次update,避免卡顿后角色直接穿过墙。
2.3 窗口装配:JFrame 那几行别省
入口类通常就二十来行,但每一行都在解决一个具体问题:
public class GameMain { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("森林冰火人 · 单人版"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); GamePanel panel = new GamePanel(); frame.setContentPane(panel); frame.pack(); // 按 preferredSize 定窗口,别硬写 setSize frame.setResizable(false); // 固定尺寸,省掉一整套缩放适配 frame.setLocationRelativeTo(null); // 居中,不然窗口常开在左上角 frame.setVisible(true); panel.requestFocusInWindow(); // 焦点必须落到画布上 }); } }invokeLater保证整个装配过程在 EDT 上,pack()依据GamePanel的preferredSize反推窗口大小,比手写setSize(960, 640)稳——后者在标题栏、边框占位不同的系统上会让画布被裁掉几个像素,最右边的瓦片就露不全。最后那句requestFocusInWindow()是血泪经验:焦点在 JFrame 上时,KeyListener挂在 JPanel 上根本收不到事件,表现就是「窗口出来了,按什么都没反应」,新手很容易在这里怀疑人生。
3. 核心模块拆解:角色、瓦片地图与 AABB 碰撞的三条主线
读这类项目最怕从头到尾顺着读,几百行读完了还是不知道改哪。我的顺序是先找三条主线:角色怎么动、地图怎么存、两者怎么判断碰到一起。这三件事理清楚,剩下的机关、宝石、过关判定都是在其上叠的薄薄一层。
3.1 Player:一个类装下火人和水人两个角色
火人和水人的差别只有三处:贴图、怕哪种元素、能开哪扇门。所以没必要写两个类,一个Player加个Element枚举就够了,公共的物理逻辑只写一遍:
public class Player { public enum Element { FIRE, WATER } private static final double GRAVITY = 0.55; // 每帧下落加速度 private static final double ACCEL = 0.90; // 水平加速度 private static final double FRICTION = 0.78; // 松手后的阻尼 private static final double MAX_SPEED = 4.80; // 必须小于瓦片边长,否则穿墙 private static final double JUMP_V = -11.5; // 起跳初速度,负值向上 private double x, y; // 用 double 存坐标,整数会有肉眼可见的抖动 private double vx, vy; private final int w = 28, h = 40; private boolean onGround, alive = true; private final Element element; private final TileMap map; // 构造、getter 省略 public void update(boolean left, boolean right, boolean jumpPressed) { if (!alive) return; // 1) 水平:加速度 + 阻尼,松手会滑一小段,比直接赋值柔和 int dir = (right ? 1 : 0) - (left ? 1 : 0); vx += dir * ACCEL; if (dir == 0) vx *= FRICTION; vx = Math.max(-MAX_SPEED, Math.min(MAX_SPEED, vx)); // 2) 垂直:重力恒定累加,起跳只在落地时生效 vy += GRAVITY; if (jumpPressed && onGround) { vy = JUMP_V; onGround = false; } // 3) 分轴推进并解算碰撞,顺序不能反 moveX(vx); moveY(vy); } private void moveX(double dx) { x += dx; if (collide()) { x -= dx; vx = 0; } // 撞墙原路退回,速度清零 } private void moveY(double dy) { y += dy; if (collide()) { y -= dy; if (dy > 0) onGround = true; // 下落被挡住 = 踩到地面 vy = 0; } } }这组常量我一般从上面这些值开始试:GRAVITY调大角色变沉、跳跃变短,调小就飘;JUMP_V决定跳跃高度,和重力一起改才有效果,只改一个容易出「跳起来像在月球」的违和感;MAX_SPEED是最容易出事的一个,它乘上帧间隔必须小于瓦片边长,否则一帧位移跨过整块墙,碰撞检测直接失效,也就是俗称的穿墙。
角色的切换逻辑通常放在GameWorld里,用一个int activeIndex记当前控制谁:
public void update() { // Tab 是切换键,用边沿触发,否则按住会来回闪 if (input.consumeSwitch()) { activeIndex = 1 - activeIndex; } Player active = players[activeIndex]; active.update(input.left, input.right, input.consumeJump()); // 未激活的角色不更新物理,但保留它踩住的机关状态 }注意最后那句注释:单人版能不能过关,全看这个设计。未激活的角色如果跟着更新物理,它会因为失去输入而停在半空或掉下去;如果不更新物理又把它踩着的按钮状态一并清掉,那么你一切换,刚打开的闸门就关了。常见做法是未激活角色的物理冻结,但它占据的触发区域保持激活,这相当于让它「站在原地当重物」。
3.2 瓦片地图:用字符画关卡,改图不用开编辑器
关卡数据用的是最土也最有效的方案:一个纯文本文件,一个字符代表一种瓦片。
################## #................# #..F..........W..# #....######......# #~~~~.......##### #................ ##################F/W是火人和水人的出生点,#是墙,~是火池,x是毒液,.是空气。整套映射关系塞进一个枚举,解析代码不到二十行:
public enum TileType { EMPTY('.', false), WALL('#', true), LAVA('~', false), WATER('v', false), POISON('x', false), DOOR_FIRE('F', false), DOOR_WATER('W', false), GEM('*', false); public final char code; public final boolean solid; // 是否阻挡移动 TileType(char code, boolean solid) { this.code = code; this.solid = solid; } public static TileType of(char c) { for (TileType t : values()) if (t.code == c) return t; return EMPTY; // 未知字符当空气,别抛异常把整关卡死 } }public final class TileMap { public static final int TILE = 32; private final TileType[][] grid; private final int rows, cols; public TileMap(List<String> lines) { this.rows = lines.size(); this.cols = lines.get(0).length(); this.grid = new TileType[rows][cols]; for (int r = 0; r < rows; r++) { String line = lines.get(r); for (int c = 0; c < cols; c++) { grid[r][c] = TileType.of(line.charAt(c)); } } } public TileType at(int col, int row) { if (col < 0 || row < 0 || col >= cols || row >= rows) return TileType.WALL; return grid[row][col]; // 越界一律当墙,防止角色跑出地图 } public boolean isSolid(int col, int row) { return at(col, row).solid; } }为什么不用图片编辑器或者 JSON?因为改关卡的成本差太多了。文本方案下,你想在第三行中间多放一块墙,敲两个#存盘重跑就完事;换成可视化编辑器,你得先做工具、再做格式、再写解析。越界当墙这条也很关键,很多穿墙 bug 的根因就是角色跑出数组边界,at()返回 null 后抛异常,或者更糟——返回了个默认值让角色一路飞出去。
3.3 碰撞检测:分轴移动 + AABB,别一次算两轴
2D 平台游戏的碰撞,九成问题都出在「同时推进 X 和 Y 再一起判断」。斜着撞墙的时候,你分不清到底是撞了侧面还是撞了顶面,退回去的方向就会错,表现为角色卡在墙角抖个不停。正确做法是拆成两次:先动 X 判 X,再动 Y 判 Y。
private boolean collide() { int c0 = (int) Math.floor(x / TileMap.TILE); int c1 = (int) Math.floor((x + w - 1) / TileMap.TILE); // -1 避免压线多算一列 int r0 = (int) Math.floor(y / TileMap.TILE); int r1 = (int) Math.floor((y + h - 1) / TileMap.TILE); for (int r = r0; r <= r1; r++) { for (int c = c0; c <= c1; c++) { if (map.isSolid(c, r)) return true; } } return false; }-1这个细节值得单独说:角色右边缘正好落在瓦片边界上时,(x + w - 1) / TILE会把它算进右侧那一列,于是角色明明没碰到墙却被判定撞墙,贴着墙走的时候会一顿一顿。加上-1之后判定范围收缩到像素内部,手感立刻正常。这个坑我在三个项目里踩过三次,每次都是「明明没碰到为什么卡住」,查半小时。
还有一点:这里的collide()是全量检查角色覆盖的所有瓦片,角色 28×40、瓦片 32,最多铺 2×2=4 格,性能完全不用担心。等角色变大或者瓦片变密再考虑四叉树之类的优化,这个体量的游戏上空间划分纯属自找麻烦。
3.4 元素与机关:判定顺序决定你会不会「冤死」
踩到火池、水池、毒液之后的效果,是这个游戏唯一的核心规则。判定写在moveY之后、绘制之前,取角色身体中心点所在瓦片:
private void checkTileEffect() { int col = (int) Math.floor((x + w / 2.0) / TileMap.TILE); int row = (int) Math.floor((y + h / 2.0) / TileMap.TILE); TileType t = map.at(col, row); switch (t) { case LAVA: if (element == Element.WATER) kill(); // 水人碰火 = 死 break; case WATER: if (element == Element.FIRE) kill(); // 火人碰水 = 死 break; case POISON: kill(); // 毒液谁碰谁死 break; default: break; // 自己的元素可以安全通过 } }用中心点而不是包围盒相交,是手感上的取舍:包围盒判定下,角色只要脚尖沾到一点火池就算死,玩家会觉得「我明明没踩进去」。改成中心点之后,半个身子悬在池子上都安全,落地判定变得更宽容。如果你想更严格一点,可以采样脚底左右两个点,只要有一个在池子里就死,代价是边缘手感变硬。
门和宝石的判定同理,但要注意顺序:先判死,再判过关。如果一个格子既是毒液又是门,先判过关就会让玩家白嫖一关。
4. 从源码到可运行:javac 编译、资源路径与第一关跑通
拆完结构,接下来就是把它跑起来。这类纯 Swing 工程一般不带构建脚本,压缩包解开就是src+res,跟十年前的 Java 课设一个形态。好处是零依赖,坏处是新手容易在「资源找不到」和「编码不对」上卡住。
4.1 环境与命令行编译
先确认 JDK 版本,Swing 从 8 到 21 都能跑,但javac和java的版本必须一致,不一致会报UnsupportedClassVersionError:
# 1) 确认环境,两条命令输出的主版本号要一致 java -version javac -version # 2) 编译到 out 目录,源码统一按 UTF-8 读,否则中文注释会报错 javac -encoding UTF-8 -d out $(find src -name "*.java") # 3) 启动,注意 classpath 要同时包含 out 和 res java -cp "out:res" com.game.main.GameMain三行命令三个要点。-d out让.class按包名生成子目录,com.game.main.GameMain才能被正确找到;不加的话类文件散落在源码旁边,很快就乱成一锅粥。-encoding UTF-8是必须的,JDK 18 之后默认 UTF-8 还好,老版本默认跟系统区域设置走,中文注释里一个「的」字就能编译失败,报「编码 GBK 的不可映射字符」。-cp "out:res"里的冒号在 Linux/macOS 上是对的,Windows 要换成分号;把res也加进去,是因为getResourceAsStream是按 classpath 找资源的,不加进去就只能读到 null。
4.2 IDE 导入:三个设置项决定能不能跑
命令行跑通以后,大部分人会转到 IDE 里改代码。导入之后如果直接点运行就报「找不到符号」或者「图片为 null」,基本都是这三项没设:
| 设置项 | 位置 | 不设的后果 |
|---|---|---|
| src 标记为 Sources Root | 右键 src 目录 | 包名解析不到,满屏红线 |
| res 标记为 Resources Root | 右键 res 目录 | 编译不报错,运行时图片全 null |
| 运行配置的 Working directory | Run Configuration | 用new File("res/...")时路径错位 |
第三项最隐蔽。因为很多人的资源加载写的是new File("res/images/fireboy.png"),这个路径是相对进程工作目录的,在 IDE 里工作目录默认是项目根,直接java -cp启动时则是当前终端所在目录,两边表现完全不一样——于是出现「IDE 里好好的,打成 jar 就崩」。
4.3 资源加载:用 getResourceAsStream,别用 File
正确的写法是从 classpath 读流,这样无论源码目录、IDE 还是打成 jar,行为都一致:
public final class ResourceLoader { private static final Map<String, BufferedImage> CACHE = new HashMap<>(); /** path 形如 "/images/fireboy.png",开头的 / 表示从 classpath 根开始 */ public static BufferedImage image(String path) { return CACHE.computeIfAbsent(path, p -> { try (InputStream in = ResourceLoader.class.getResourceAsStream(p)) { if (in == null) { throw new IllegalStateException("资源没找到: " + p); } return ImageIO.read(in); } catch (IOException e) { throw new UncheckedIOException("读取失败: " + p, e); } }); } }两个设计点。CACHE是静态 map,同一张图只从磁盘读一次——paintComponent每秒调 60 次,如果在里面ImageIO.read,GC 会被图片对象刷爆,表现为游戏跑两分钟越来越卡。getResourceAsStream返回 null 时主动抛异常并带上路径,比在后面g.drawImage(null, ...)静默失败强太多:前者一秒定位问题,后者只给你一个啥也没有的黑窗口。
提示:打成可执行 jar 时,manifest 里要写
Main-Class,并且res目录必须一起塞进 jar。命令是jar cfe game.jar com.game.main.GameMain -C out . -C . res,注意-C的顺序,少一个res就是满屏「资源没找到」。
5. 排查与避坑:黑屏、按键失灵、穿墙的 5 个真实原因
跑得起来之后,真正耗时间的是「看着不对但不知道错在哪」。下面这五条是这类项目里出现频率最高的,每条我都按现象、原因、解决写,对照着查基本能定位。
5.1 窗口出来了,画面一片灰
现象:JFrame 正常显示,标题栏也有,但画布区域是纯灰或者纯黑,一个像素都没画。
原因:paintComponent里没调super.paintComponent(g),Swing 不会清屏,首帧就是未初始化的缓冲;或者world在构造时因资源为 null 抛了异常,被invokeLater里的 lambda 吞掉,窗口照开,绘制却没跑完。
解决:先补super.paintComponent(g);再在paintComponent最外层包一层 try-catch 把异常打到控制台,别让它静默消失。这类问题十有八九是资源路径错了,别在绘制逻辑里翻。
5.2 键盘按下去毫无反应
现象:角色站着不动,但鼠标拖动窗口、点关闭按钮都正常,说明主线程没死。
原因:三种可能,按概率排序——JPanel 没setFocusable(true);焦点被 JFrame 或某个 JButton 抢走;KeyListener加在了 frame 上而不是 panel 上。
解决:setFocusable(true)+addKeyListener(this)都放在 panel 上,setVisible(true)之后再requestFocusInWindow()。如果界面里有按钮,记得点完按钮把焦点还给画布,否则切一次就失灵。
5.3 角色偶尔穿墙,或者贴着墙走一抖一抖
现象:撞墙时角色偶尔穿过去,或者沿着墙走的时候位置跳一下。
原因:MAX_SPEED乘上帧间隔超过了瓦片边长,一帧位移直接跨过整块墙,采样点检测不到;另一个原因是用了int存坐标,x += 3.7被截断,累积误差让角色和瓦片边界反复摩擦。
解决:坐标用double,MAX_SPEED压到瓦片边长的 1/5 以下;collide()里算列号时用(x + w - 1) / TILE避免压线多算一格。真要高速移动(比如冲刺),就得拆子步:一帧位移 12 像素就分三次走 4 像素,每次走完都判一次碰撞。
5.4 切到另一个角色后,前一个角色自己掉下去了
现象:按切换键后,被切换下去的角色不受控制地往下掉,或者它踩着的闸门自动关上了。
原因:update循环对两个角色都调了物理更新,未激活的那个没有输入、没有起跳,重力照样往下拽;或者切换时把「谁踩在按钮上」的状态一起重置了。
解决:只更新当前激活角色的物理,未激活角色冻结位置;按钮的按下状态由「是否有任意角色与触发器重叠」来判定,而不是绑定在某个角色对象上。这是单人版设计的核心矛盾,捋不清就画个状态图。
5.5 游戏跑得越久越卡,风扇声音越来越大
现象:刚启动很流畅,玩两三分钟开始掉帧,关掉重开又好了。
原因:paintComponent里每次重绘都ImageIO.read或者new BufferedImage,一秒钟制造 60 个图片对象,GC 压力巨大;也可能是每帧都新建ArrayList存实体,没有复用。
解决:图片全部走ResourceLoader的静态缓存;实体列表在构造时建一次,之后clear()复用;Graphics2D上设的RenderingHints每帧都设一遍也没关系,但别每帧 new。
6. 二次开发:加一关、加一种机关,再把单人版改成双人
想拿这个骨架练手,我建议按「加一关 → 加一种机关 → 改双人」的顺序来,难度递增,每一层都能跑出可见的效果,不至于改到一半失去动力。
先说加关卡。在levels/下复制一份level1.txt改成level2.txt,用字符改布局,存盘重跑就行——因为LevelLoader是按行读文本,你甚至可以做个「过一关换一个文件」的简单文件名递增。真正要动手的只有一处:LevelLoader里如果写死了level1.txt,把它换成按关卡索引拼路径:
public static Level load(int index) { String path = "/levels/level" + index + ".txt"; try (InputStream in = LevelLoader.class.getResourceAsStream(path)) { if (in == null) throw new IllegalArgumentException("关卡不存在: " + path); List<String> lines = new BufferedReader( new InputStreamReader(in, StandardCharsets.UTF_8)) .lines().toList(); return new Level(new TileMap(lines)); } catch (IOException e) { throw new UncheckedIOException(e); } }加机关稍微复杂一点,因为它要动三个地方。以「弹簧板」为例:先在TileType里加一项SPRING('^', false),注意solid设成false,否则角色踩上去等于踩在墙上;再在Player.moveY落地判定之后补一段检测,取脚底下一格瓦片,命中就给一个更大的负速度:
// moveY 里落地之后追加 if (onGround) { int footCol = (int) Math.floor((x + w / 2.0) / TileMap.TILE); int footRow = (int) Math.floor((y + h + 1) / TileMap.TILE); if (map.at(footCol, footRow) == TileType.SPRING) { vy = -18.0; // 比普通跳跃的 -11.5 高一大截 onGround = false; // 手动清掉,否则下一帧还会被判成落地 } }最后在渲染里给它画个不同颜色的方块就算完工。这个改动链条很典型:枚举 → 逻辑 → 绘制,三个位置缺一个就会出现「看起来是弹簧但踩上去没反应」或者「能弹起来但画不出来」。我习惯先在纸上列这三处,改完一次性验证,比改一处跑一次省时间。
至于双人模式,改动其实比想象中小:把现在那个全局Input拆成两个实例,一个绑 WASD,一个绑方向键,然后GameWorld.update里对两个 Player 分别调用update(inputA...)和update(inputB...),把切换逻辑删掉即可。真正麻烦的是碰撞——两个角色会互相挡路,简单的做法是先把角色之间的碰撞忽略掉(允许重叠),跑通再考虑互相推挤。如果一上来就做角色间推挤,光是解算两个 AABB 的重叠深度就能耗掉你一晚上。
| 改造点 | 主要文件 | 大致工作量 |
|---|---|---|
| 新增一关 | levels/*.txt、LevelLoader | 半小时 |
| 新增一种机关 | TileType、Player、渲染方法 | 一到两小时 |
| 双人键位 | Input、GameWorld | 两小时左右 |
| 角色互相碰撞 | Player、GameWorld | 半天以上,容易翻车 |
最后说个我自己的教训。早些年我改这种小游戏,图省事直接在paintComponent里加交互逻辑,结果一次改动引发了三个连锁 bug,查了两天才发现根因藏在绘制代码里。从那以后我每次改瓦片类型或者碰撞规则,都会先把isSolid、collide、update这三个方法从头读一遍再动手,哪怕只加一个字符——省下的这五分钟,抵得上后面两小时的排查。源码包就摆在那儿,解压、javac、跑起来,先按第 4 章的命令行走一遍,再回来改第 6 章这几行,你会比看十篇解析都清楚。希望帮到你。
本文还有配套的精品资源,点击获取