ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Swing实现超级马里奥:游戏循环与碰撞检测

Java Swing实现超级马里奥:游戏循环与碰撞检测 简介基于Java实现的超级马里奥小游戏资源包面向Java初学者、课程设计或毕设阶段的学生也适合作为课程设计、大作业、工程实训的起步项目。资源覆盖Swing图形界面、JFrame窗口、事件监听与多线程等核心知识点是理解Java桌面游戏开发的完整范例。压缩包共76个文件大小约6.93MB主要包括png图片素材、java源代码、class编译产物、wav音效、jar依赖库及jpg背景图等代码与素材分离便于直接导入IDE运行和二次修改。目前已有109人学习下载适合需要快速上手Java小游戏项目的开发者参考。资源内提供完整可运行的超级马里奥游戏支持WASD控制角色移动玩家可在Zhangai类中自定义关卡样式自由度高同时搭配源码、音乐与图片资源方便对照学习游戏循环、碰撞检测和音效触发等逻辑具备较好的扩展与改造空间。1. 基于Java的超级马里奥小游戏从像素块到完整状态机不少 Java 学习路径里都出现过“用 Java 做一个超级马里奥小游戏”这个题目。它看起来只是把几个彩色矩形画到窗口里实际上要处理的却是游戏开发最核心的几件事游戏循环、渲染线程、输入映射、碰撞检测、地图解析和状态切换。不用 Unity、不用 Godot选择纯 Java Swing/AWT意味着依赖只有 JDK 和文本编辑器代码能编译就能跑非常适合用来复习 OOP 设计、多线程协作以及 JVM 运行时行为。把这个项目拆开做一遍新手能理清“更新-渲染”的关系熟手也能从 GC 停顿和渲染对象分配里重新审视 Java 桌面应用的真实边界。2. 游戏循环与 Swing 窗口先把 60 FPS 主循环跑起来2.1 游戏循环为什么要绕过 Swing 的事件线程Swing 的 UI 操作必须在事件分发线程EDT上执行但游戏逻辑如果也放在 EDT 里帧循环里的耗时操作会直接卡住窗口绘制。常见的做法是用javax.swing.Timer或自定义线程驱动主循环逻辑更新与渲染仍然在 EDT 上调用。自定义线程的好处是能够更精确地控制帧间隔不会被 EDT 上其他事件挤占。这里需要区分两个概念固定时间步与可变时间步。固定时间步让逻辑更新频率稳定渲染频率可以随之变化可变时间步则是根据上一帧实际时间计算这一步的位移量。对于超级马里奥这类平台跳跃游戏我建议用固定时间步加最大帧间隔保护避免窗口拖拽时物理突然“跳楼”。public class GameLoop implements Runnable { private final GamePanel panel; private boolean running true; private static final int TICKS_PER_SECOND 60; private static final double NANOS_PER_TICK 1_000_000_000.0 / TICKS_PER_SECOND; public GameLoop(GamePanel panel) { this.panel panel; } Override public void run() { long last System.nanoTime(); double delta 0; while (running) { long now System.nanoTime(); delta (now - last) / NANOS_PER_TICK; last now; if (delta 4) delta 4; // 防止窗口卡顿后物理走向失控 while (delta 1) { panel.update(); // 逻辑更新位移、碰撞、敌人 AI delta--; } panel.repaint(); // 请求绘制实际渲染仍在 EDT } } public void stop() { running false; } }这段代码把update()和repaint()分开。delta用来累计需要执行的逻辑帧数超过 4 帧直接截断是为了避免 debugger 断点或系统休眠导致跳跃距离异常。逻辑帧率固定在 60渲染则依赖 Swing 合并绘制请求实际刷新率可能高于或低于 60但对一个 2D 小游戏来说足够。2.2 用 JPanel 完成最小渲染窗口主体建议继承JPanel并覆写paintComponent(Graphics g)。因为 JPanel 默认开启双缓冲能减少画面闪烁。注意不要再调用super.paint(g)也不要直接在paint()里画否则双缓冲和裁剪都会被破坏。public class GamePanel extends JPanel { private int playerX 50; private int playerY 200; Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 先用背景色铺底再绘制实体 g2.setColor(new Color(108, 148, 255)); g2.fillRect(0, 0, getWidth(), getHeight()); // 这里的矩形代表玩家角色后续会替换成精灵图 g2.setColor(Color.RED); g2.fillRect(playerX, playerY, 32, 32); } public void update() { playerX 1; // 逻辑更新里移动玩家 } }关键点是Graphics2D的变换和绘制状态。渲染排序要稳定先背景再砖块地图再敌人和玩家最后是 UI 层的生命值或分数。如果物体之间有遮挡关系还可以考虑ArrayListDrawable按 y 坐标排序实现简单的绘画顺序排序。repaint()只是标记区域真正执行绘制的是 EDT所以主循环线程里不能直接操作组件状态以外的 Swing 组件。2.3 输入绑定与按键状态表监听键盘时不要在keyPressed里直接修改玩家位置因为按住按键会触发系统键盘重复事件导致位移速率不稳定。更稳的写法是把按键状态记录到布尔数组或SetInteger每一帧逻辑更新时读取状态统一处理。按键语义状态维度A / ←向左移动水平方向D / →向右移动水平方向Space / W跳跃触发式带边沿检测S / ↓下蹲或快速下落按下时改变碰撞箱边沿检测的意思是记录“上一帧按下了吗”和“这一帧按下了吗”只有从 false 变 true 才触发跳跃。如果直接把keyPressed当作跳跃触发一次按击可能触发多次。用SetInteger pressedKeys时每帧更新结束后清空 “已消费”的触发事件即可。焦点问题也需要处理点击窗口后键盘事件才会生效常见做法是在构造方法里调用setFocusable(true)并请求焦点。3. 地图、实体与碰撞TileMap 里马里奥为什么能站住3.1 用二维数组描述关卡超级马里奥的地图本质是一个二维 Tile 数组。每个格子有固定尺寸比如 32×32 像素。地图文件可以是文本0 表示空白1 表示地面砖块2 表示可破坏砖块3 表示金币。这样关卡编辑只需要改文本文件不需要重新编译 Java 代码。public class TileMap { public static final int TILE_SIZE 32; private int[][] tiles; private int rows; private int cols; public TileMap(String[] lines) { rows lines.length; cols lines[0].length(); tiles new int[rows][cols]; for (int r 0; r rows; r) { for (int c 0; c cols; c) { tiles[r][c] lines[r].charAt(c) - 0; } } } public int getTile(int row, int col) { if (row 0 || row rows || col 0 || col cols) return 0; return tiles[row][col]; } }这里用字符转 int 的方式如果有超过 9 种 tile就需要换成逗号分隔的 CSV 或 JSON。地图初始化时要留意行列方向通常用行表示 y列表示 x遍历时外循环行、内循环列绘制时x col * TILE_SIZEy row * TILE_SIZE。坐标转换是常见错误点碰撞检测时始终要把像素坐标转成格子坐标。3.2 实体速度与 AABB 碰撞箱马里奥实体不需要像素级碰撞用轴对齐包围盒AABB就够了。实体包含位置x, y和速度vx, vy。传统做法是在水平移动和垂直移动分开检测先按 x 方向尝试移动检测碰撞解决后回到合法位置再按 y 方向尝试移动检测碰撞。这样能避免斜向移动时一次检测到两个方向的问题。public boolean collidesWithMap(int newX, int newY, int width, int height, TileMap map) { int left newX / TileMap.TILE_SIZE; int right (newX width - 1) / TileMap.TILE_SIZE; int top newY / TileMap.TILE_SIZE; int bottom (newY height - 1) / TileMap.TILE_SIZE; for (int row top; row bottom; row) { for (int col left; col right; col) { int tile map.getTile(row, col); if (tile ! 0) return true; } } return false; }该方法用四个边界点确定覆盖的格子范围。newX width - 1中的减一是为了避免右边界的整数除法多算一列。真正应用时需要把水平检测和垂直检测拆开否则在墙角位置会直接被判为碰撞玩家无法贴着墙移动。碰撞解决完成后要把速度分量置 0否则下一帧还会继续钻进墙里。3.3 重力、跳跃、惯性的三个关键参数平台跳跃手感大半由参数决定。常见初始值可以这样设置参数建议值作用gravity0.45 px / 帧²下落加速度决定跳跃弧线moveSpeed3.0 px / 帧水平移动速度jumpSpeed9.0 px / 帧起跳瞬间的垂直速度maxFallSpeed10.0 px / 帧防止下落速度无限增大friction0.85地面水平速度衰减系数прыжок in air 需要设置是否在地面。当检测到 y 方向碰撞时如果vy 0说明刚才向下运动实体落到了地面上就应该把onGround设为 true同时vy 0。反之vy 0且碰撞说明撞到头顶也需要把vy 0。水平方向碰撞则把vx 0并回退位置。跳跃手感还需要区分“按下阈值”和“起跳延迟”。很多实现会因为keyPressed和固定时间步之间的相位差偶尔丢失跳跃输入。可以在按下跳跃键时设置jumpBufferTimer 6每帧减 1只有大于 0 才允许起跳。同样coyoteTime 6用来允许玩家离开平台后很短时间还能起跳这个细节能明显减少边缘跳跃的挫败感。3.4 常见误用像素碰撞与浮点比较记得有项目直接用怪物图片的getRGB()遍历做碰撞60 FPS 下每帧扫描几万个像素把 GC 拖垮。另一个误用是if (speedX 0)判断是否停下浮点累加会出现 0.999999 永远不归零。解决方案是设定一个极小的阈值例如Math.abs(speedX) 0.05时直接归零。Tile 碰撞检测还需要处理“实体穿过薄墙”的问题。当帧间隔过大或速度过快时实体可能从一块砖的一侧直接穿到另一侧。固定时间步已经把速度控制在合理范围但如果后续要支持高速冲刺或传送就需要做连续碰撞检测比如把本次位移拆成多个小步逐帧检测或者使用扫描体swept AABB。4. 敌人、玩家状态与关卡加载从单场景到可通关4.1 敌人类的状态机马里奥里的板栗仔Goomba行为非常简单直线移动碰墙回头被踩死。把它抽象成状态机至少有三个状态步行、被踩、死亡消失。用枚举或常量表达状态比一堆布尔值清晰。public class Goomba { enum State { WALKING, STOMPED, DEAD } private State state State.WALKING; private int x, y; private int vx -1; public void update(TileMap map) { if (state State.DEAD) return; if (state State.STOMPED) { state State.DEAD; // 动画播放完后置为 DEAD return; } int nextX x vx; if (collidesWithMap(nextX, y, 32, 32, map)) { vx -vx; // 碰到墙就反向 } else { x nextX; } y 2; // 最简重力用重力常数代替 if (collidesWithMap(x, y, 32, 32, map)) { y - 2; } } public void stomp() { state State.STOMPED; } }敌人体积与玩家相同位置移动同样要先处理水平碰撞再处理垂直碰撞。被踩后的STOMPED状态可以记录一个帧数等到超过某个阈值再变为DEAD这样画面上能看到敌人被压扁的短暂表现。如果敌人始终不销毁渲染时还要继续检查状态性能就会逐渐变差。4.2 马里奥玩家的状态切换玩家状态用枚举或状态模式实现。比较常用的最小集合IDLE,RUNNING,JUMPING,DEAD。四个状态之间不是完全自由切换IDLE - RUNNING需要检测方向键RUNNING - JUMPING需要按下跳跃且在地面JUMPING - IDLE只有在落地后发生。如果后续加入无敌星或变大蘑菇状态的组合会指数增长此时建议引入状态模式把每个状态封装成独立类。状态切换时的数据重置经常被忽略。比如从JUMPING回到IDLE不仅要改状态还要把vy清零如果遗留vy 0下一帧角色会突然继续下落。用枚举状态时可以在changeState方法里统一处理退出动作和进入动作。public void changeState(PlayerState newState) { if (this.state newState) return; this.state.onExit(this); this.state newState; this.state.onEnter(this); }这里onExit可以处理重置特效onEnter可以设置进入无敌帧或移动动画起始帧。这个结构顺便解决了“吃到蘑菇后变大动画穿插”的需求也算面试里常考的模板方法模式。也可以用switch简单处理但当状态数超过 5 个后代码里的switch分支会越来越难维护。4.3 关卡文本格式与重新加载一个关卡文件不需要放在资源包里直接放在src/main/resources/levels/level1.txt用相对路径读取即可。文本文件的每一行对应地图行为了便于人工编辑可以使用1表示地面2表示问号砖3表示敌人出生点。出生点在加载后要清理掉 tile否则敌人会困在砖块里。public Level loadLevel(String path) throws IOException { ListString lines Files.readAllLines(Paths.get(path)); TileMap map new TileMap(lines.toArray(new String[0])); ListEnemy enemies new ArrayList(); ListCollectible coins new ArrayList(); for (int row 0; row lines.size(); row) { String line lines.get(row); for (int col 0; col line.length(); col) { char ch line.charAt(col); if (ch 3) { int x col * TileMap.TILE_SIZE; int y row * TileMap.TILE_SIZE; enemies.add(new Goomba(x, y)); } else if (ch 4) { int x col * TileMap.TILE_SIZE; int y row * TileMap.TILE_SIZE; coins.add(new Coin(x, y)); } } } return new Level(map, enemies, coins, playerStartX, playerStartY); }读取时需要注意Files.readAllLines返回的行可能包含末尾回车不影响字符解析。真正的坑是资源文件的根目录IDE 和命令行参数的工作目录不同直接用相对路径容易找不到文件。常见做法是把关卡文件放到类路径下用getClass().getResourceAsStream(/levels/level1.txt)读取这样不管从哪个目录启动都能找到。死掉后重新加载时要把playerX、playerY、生命值、敌人列表全部重置只重置地图位置不够敌人位置和金币也要重新初始化否则会出现“人活了但敌人已经消失”的怪现象。5. 卷轴摄像机、精灵动画与音效补充游戏质感5.1 摄像机坐标与地图边界钳制横向卷轴是超级马里奥最直观的特征。实现时不需要真的移动地图只需要绘制时统一减去一个摄像机偏移量。摄像机通常跟随玩家核心公式是cameraX playerX - viewportWidth / 2。坐标计算结果会出现负数需要用钳制函数让摄像机不超出地图范围。public int getCameraX(int viewportWidth, int mapWidth) { int cameraX playerX playerWidth / 2 - viewportWidth / 2; cameraX Math.max(0, Math.min(cameraX, mapWidth - viewportWidth)); return cameraX; }绘制时对每个实体调用g2.translate(-cameraX, -cameraY)。注意平移状态是一次性的如果循环里绘制多个实体不要每画一个实体就平移一次。正确姿势是先设置一次平移再绘制地图和实体画 UI 前用g2.translate(cameraX, cameraY)恢复或者保存变换。Graphics2D的变换基于状态栈也可以用AffineTransform old g2.getTransform()保存最后setTransform(old)恢复但频繁创建AffineTransform会增加 GC 压力。摄像机还需要考虑横向是否锁定跳跃高度。马里奥关卡通常只向右延伸垂直方向限制在几个屏幕高度内所以摄像机 y 可以简单取Math.max(0, playerY - viewportHeight * 2 / 3)让玩家站在区域下方时也能够看到更上方。如果做水下关卡玩家下潜时要让屏幕往下跟要改用Math.min(playerY - viewportHeight / 2, mapHeight - viewportHeight)。5.2 从精灵表截取动画帧真实的马里奥角色有大量精灵帧全部用图片绘制并不难。准备一张精灵表每个子图尺寸相同通过BufferedImage.getSubimage(x, y, width, height)截取。注意getSubimage返回的图像与原图共享像素数组如果后续要缩放或旋转最好复制到新BufferedImage否则像素缓冲区共享会引发奇怪的颜色串扰。public BufferedImage getFrame(int col, int row) { int sx col * frameWidth; int sy row * frameHeight; BufferedImage sub spriteSheet.getSubimage(sx, sy, frameWidth, frameHeight); return sub; }动画播放需要一个帧计时器。简单实现是维护frameTimer每帧减 1当减到 0 时切换到下一帧并重置计时器。帧率请按照固定时间步的 tick 数来定比如跑步动画 6 tick 换一帧如果 tick 是 60 FPS每 100 毫秒换一帧。不要用System.currentTimeMillis()驱动动画计时它和逻辑帧不同步在低帧率时会出现动画时快时慢。5.3 用 javax.sound.sampled 播放背景音乐Java 里播放 WAV 最稳妥的是Clip。它把音频一次性加载到内存里循环播放不占额外 CPU。缺点是文件不宜太大背景音乐压缩成 22050Hz、16bit、单声道 WAV 比较合适。下面是一个最小播放入口。public class SoundPlayer { private Clip clip; public void play(String path, boolean loop) { try { AudioInputStream stream AudioSystem.getAudioInputStream(getClass().getResourceAsStream(path)); clip AudioSystem.getClip(); clip.open(stream); if (loop) { clip.loop(Clip.LOOP_CONTINUOUSLY); } else { clip.start(); } } catch (Exception e) { e.printStackTrace(); } } public void stop() { if (clip ! null) clip.stop(); } }getResourceAsStream比File更推荐因为打包成 jar 后文件系统路径会失效。Clip打开后要记得close()否则反复播放死亡音效会句柄泄漏。另一个问题是每次play都创建新Clip实例短音效建议预加载到内存并复用同一个对象切换轨道时先stop()flush()。如果想要音量控制Clip没有直接提供主音量还要用FloatControl.Type.MASTER_GAIN获取控制对象这部分在 Windows 某些声卡驱动上行为不一致部署时要留一个静音开关。6. JVM 优化、内存与面试延伸超级马里奥小游戏的价值边界6.1 渲染路径上的对象池与垃圾回收很多卡顿并不是游戏逻辑复杂而是 GC 在渲染循环里频繁回收对象。每一帧更新都new ArrayList()或用StringBuilder拼坐标压力会很大。常见做法是地图碰撞检测和敌人列表在关卡开始时分配更新循环内部尽量复用临时对象。玩家子弹、粒子这类短暂对象可以用对象池但对象池也有风险池满了怎么办池对象是否被逻辑泄漏残留。对一个小游戏而言先避免每帧无意义分配就够。6.2 常用 JVM 参数与卡顿排查桌面 Java 游戏启动时给一个小堆就够了关键参数是调整 GC。例如用 G1 收集器并限制暂停时间java -Xms256m -Xmx512m -XX:UseG1GC -XX:MaxGCPauseMillis50 -jar super-mario.jarMaxGCPauseMillis是目标值不是保证值不要调到 1 毫秒那会让 GC 频繁执行CPU 反而升高。排查卡顿可以用jvisualvm或 JDK Mission Control 抓取样例观察char[]和byte[]的分配速率。如果发现音效加载每次重新读文件就要把Clip统一放到资源管理器缓存。6.3 面试时把这个项目讲成可迁移经验面试中常遇到的“java 面试题”其实可以借这个项目串起来游戏循环对应多线程协调与可见性碰撞检测对应 AABB 计算与边界条件地图加载对应 IO 与异常处理状态切换对应状态模式。讲解时先说“渲染线程和逻辑线程分离”然后提到“我用固定时间步保证确定性”再补一句“用双重缓冲避免闪烁”比背八股文更有说服力。如果被问到为什么不用现有引擎可以强调“用 Swing 是为了观察线程模型和内存分配”。最后的调试技巧是给关键流程加日志开关。不要用System.out.println打刷屏日志可以用boolean debug控制绘制碰撞箱按 F3 时把玩家和敌人的 AABB 画成半透明矩形。这样在处理“为什么掉进砖缝里”的问题时能一眼看出碰撞箱比视觉贴图大一圈还是小一圈。一个可维护的调试面板往往比反复修改代码重启程序更省时间。本文还有配套的精品资源点击获取
返回列表