ARTICLE DETAIL

资讯详情

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

Java Swing 实现超级马里奥小游戏:主循环、碰撞检测与帧率解耦

Java Swing 实现超级马里奥小游戏:主循环、碰撞检测与帧率解耦 简介这是一份基于Java实现的超级马里奥小游戏源码资源面向Java初学者、进阶学习者以及需要完成课程设计、大作业或毕设项目的学生。项目综合运用了Swing组件、JFrame窗体、事件监听器与多线程等核心技术通过WASD键控制角色上下左右移动帮助读者在实践中理解图形界面编程与游戏循环的基本思路。压缩包共76个文件包含52个png图片、8个java源文件、8个class字节码、2个wav音效、2个jar依赖包及jpg、md、license等辅助文件整体约6.93MB源码、素材与说明文档齐备。目前已有109人学习下载。资源的一大亮点是关卡自由度较高下载后可在Zhangai类中自行修改障碍物布局与关卡样貌便于二次开发与个性化调整适合作为Java图形界面与多线程编程的练手项目也能为课程答辩提供可运行的完整案例。1. 从一张 AWT 画布说起Java 超级马里奥小游戏到底在做什么很多人第一次听到「基于 Java 实现的超级马里奥小游戏」脑子里浮现的是红白机那套像素画面觉得这东西得用游戏引擎才能跑起来。实际上这类项目的核心根本不是引擎而是一块画布加一个定时器JFrame提供窗口Canvas或JPanel负责绘制Timer每隔十几毫秒触发一次逻辑更新和重绘。马里奥的跑、跳、踩怪、顶砖全部是坐标加减和矩形碰撞判断的结果。这个方向适合两类人一类是刚学完 Java 基础、想找一个能看见成果的练手项目把面向对象、集合、线程、事件监听串起来另一类是做 Java 开发工程师面试准备的人想用一个完整小项目讲清楚继承、多态、状态机怎么落地。它不需要 OpenGL不需要第三方引擎JDK 自带的 Swing/AWT 就够。下面按「能跑起来 → 能改参数 → 能避坑」的顺序拆开讲。2. 用 Swing 搭出可运行的游戏主循环窗口、画布与定时器2.1 为什么选 Swing 而不是 JavaFX 或游戏引擎先说选型。Java 做 2D 小游戏常见三条路Swing/AWT、JavaFX、LibGDX。Swing 的优势是 JDK 自带、零依赖、资料多缺点是绘图性能一般、API 偏老。JavaFX 画面更现代但 JDK 11 之后需要单独引入模块对新手是额外负担。LibGDX 是专业游戏框架功能强但学习曲线陡一个马里奥小游戏用不上它的物理引擎和资源管线。我一般会建议如果目标是「练 Java 面向对象 快速看到画面」选 Swing如果目标是「做商业级 2D 游戏」直接上 LibGDX。本文锁在 Swing 路线上因为它最贴合「Java 基础练手」这个诉求也最容易在任意一台装了 JDK 的机器上复现。核心结构就三块一个继承JFrame的主窗口类一个继承JPanel的游戏面板类一个javax.swing.Timer驱动的主循环。主循环做两件事——更新所有对象状态、重绘整个画面。这个「更新 重绘」的分离是后面所有逻辑的地基务必先立住。2.2 最小可运行骨架窗口 面板 定时器先给一个能跑起来的最小骨架画面里只有一个方块但主循环、键盘监听、重绘链路全通。后面所有马里奥逻辑都往这个骨架上挂。import javax.swing.*; import java.awt.*; import java.awt.event.*; public class GameMain extends JFrame { public GameMain() { setTitle(Java Mario - Minimal Loop); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); GamePanel panel new GamePanel(); add(panel); pack(); // 按面板首选尺寸调整窗口 setLocationRelativeTo(null); setVisible(true); panel.requestFocusInWindow(); // 让面板能接收键盘事件 panel.startGameLoop(); } public static void main(String[] args) { // Swing 组件必须在事件分发线程创建 SwingUtilities.invokeLater(GameMain::new); } } class GamePanel extends JPanel implements ActionListener, KeyListener { private static final int WIDTH 800; private static final int HEIGHT 480; private static final int DELAY_MS 16; // 约 60 FPS private final Timer timer; private int playerX 100; private int playerY 300; private boolean left, right, up; public GamePanel() { setPreferredSize(new Dimension(WIDTH, HEIGHT)); setBackground(new Color(107, 140, 255)); // 天空蓝 setFocusable(true); addKeyListener(this); timer new Timer(DELAY_MS, this); } public void startGameLoop() { timer.start(); } Override public void actionPerformed(ActionEvent e) { update(); // 先更新状态 repaint(); // 再请求重绘 } private void update() { if (left) playerX - 4; if (right) playerX 4; if (up) playerY - 4; // 简单边界约束防止跑出画面 playerX Math.max(0, Math.min(playerX, WIDTH - 32)); playerY Math.max(0, Math.min(playerY, HEIGHT - 32)); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); g.setColor(Color.RED); g.fillRect(playerX, playerY, 32, 32); // 先用方块代替马里奥 } Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT - left true; case KeyEvent.VK_RIGHT - right true; case KeyEvent.VK_SPACE, KeyEvent.VK_UP - up true; } } Override public void keyReleased(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT - left false; case KeyEvent.VK_RIGHT - right false; case KeyEvent.VK_SPACE, KeyEvent.VK_UP - up false; } } Override public void keyTyped(KeyEvent e) { /* 不用留空 */ } }逻辑说明Timer每 16 毫秒触发一次actionPerformed里面先update()改坐标再repaint()请求重绘。paintComponent是 Swing 的绘制入口所有可见对象都在这里画。键盘用布尔标志记录按键状态而不是在keyPressed里直接改坐标——这是关键否则按键重复触发会让移动速度不可控。参数说明DELAY_MS决定帧率16 毫秒约 60 FPS改成 33 约 30 FPS帧率越低画面越卡但 CPU 占用越小。playerX/playerY是左上角坐标fillRect的宽高 32 是临时方块尺寸换成马里奥贴图时这个值要和精灵图单帧尺寸一致。移动步长 4 是每帧像素数想调手感就改这个值但要注意它和帧率耦合——帧率一变速度就变后面第 5 章会讲怎么解耦。提示SwingUtilities.invokeLater不能省。Swing 不是线程安全的所有组件创建和修改都必须在事件分发线程EDT里做否则会出现画面撕裂或随机崩溃这类问题排查起来非常费劲。2.3 把主循环拆成 update 与 render 两条线上面骨架里update和paintComponent已经分开了但很多人写着写着就把逻辑塞进paintComponent这是典型的翻车点。paintComponent可能被系统频繁调用窗口遮挡、最小化恢复都会触发如果里面改状态游戏逻辑就会莫名其妙加速或错乱。正确做法是update()只改数据不碰GraphicspaintComponent只读数据画图不改任何游戏状态。这样即使重绘被调用一百次游戏逻辑也只按定时器节奏走一次。这个分离在后期加敌人、加道具时尤其重要因为碰撞检测必须发生在状态更新阶段不能发生在绘制阶段。另外repaint()只是「请求」重绘系统会合并多次请求所以不要指望每次repaint都对应一次paintComponent。真正驱动逻辑的是Timer不是重绘。理解这一点后面做帧率控制和卡顿排查会顺很多。3. 马里奥的核心机制瓦片地图、重力跳跃与矩形碰撞3.1 用二维数组描述关卡瓦片地图的数据结构马里奥关卡本质是一张网格地图每个格子放一种地形空地、砖块、问号块、地面、管道。用二维int数组描述最直观数字代表地形类型渲染时按格子坐标画对应贴图。// 0空 1地面 2砖块 3问号块 4管道 public class Level { public static final int TILE_SIZE 32; // 注意数组第一维是行(y)第二维是列(x) public static final int[][] MAP { {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1} }; // 判断某个像素坐标是否落在实心地形上 public static boolean isSolid(int px, int py) { int col px / TILE_SIZE; int row py / TILE_SIZE; if (row 0 || row MAP.length || col 0 || col MAP[0].length) { return false; // 地图外视为空避免越界 } return MAP[row][col] ! 0; } }逻辑说明MAP用行优先存储MAP[row][col]对应屏幕坐标x col * TILE_SIZE、y row * TILE_SIZE。isSolid把像素坐标换算成格子坐标再查表是碰撞检测的基础函数。注意越界判断必须放在查表之前否则数组越界异常会直接把游戏打崩。参数说明TILE_SIZE是单个瓦片边长必须和贴图尺寸一致否则画面会错位。地图行列数决定关卡大小25 列 × 15 行在 800×480 窗口下刚好铺满。想加长关卡就加列想加高就加行但窗口尺寸要同步调整或者引入摄像机滚动。3.2 重力与跳跃速度、加速度和落地判定马里奥的跳跃手感全靠两个变量垂直速度vy和重力加速度gravity。每帧vy gravity然后y vy落地时把vy归零。跳跃就是给vy一个负值向上。public class Player { public int x, y; public int width 28, height 32; public double vy 0; public boolean onGround false; private static final double GRAVITY 0.6; // 每帧重力增量 private static final double JUMP_POWER -11; // 起跳初速度 private static final double MAX_FALL 14; // 下落速度上限 public void update(boolean jumpPressed) { // 起跳只有站在地面才能跳防止空中连跳 if (jumpPressed onGround) { vy JUMP_POWER; onGround false; } vy GRAVITY; if (vy MAX_FALL) vy MAX_FALL; // 限制下落速度避免穿透地形 y (int) vy; // 落地检测脚下那一格是否是实心 onGround false; if (Level.isSolid(x width / 2, y height)) { // 对齐到格子顶部消除累积误差 y (y height) / Level.TILE_SIZE * Level.TILE_SIZE - height; vy 0; onGround true; } } }逻辑说明onGround标志既控制能否起跳也控制动画状态站立/下落。落地时把y对齐到格子顶部这一步很关键否则浮点误差会让角色一点点陷进地面几秒后就穿模掉下去。MAX_FALL限制下落速度是为了防止高速下落时一帧跨过多个格子导致碰撞检测漏判。参数说明GRAVITY越大下落越快、跳跃越「重」0.6 是偏轻快的手感JUMP_POWER决定跳跃高度-11 大约能跳两格半MAX_FALL一般设为TILE_SIZE / 2左右保证一帧不会跨过半个格子以上。这三个值要一起调单独改一个往往手感就崩了。3.3 矩形碰撞AABB 检测与分轴处理马里奥和砖块、敌人的碰撞都用 AABB轴对齐包围盒因为所有对象都是矩形判断两个矩形是否相交只需四次比较。难点不在检测本身而在「碰撞后怎么处理」——必须分轴处理先解决水平再解决垂直否则会出现卡墙或穿墙。public class Collision { // 两个矩形是否相交 public static boolean intersects(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { return ax bx bw ax aw bx ay by bh ay ah by; } // 水平移动后检测撞墙则贴边 public static int resolveX(int x, int y, int w, int h, int dx) { int nx x dx; int step Integer.signum(dx); while (dx ! 0) { if (Level.isSolid(nx (step 0 ? w : 0), y h / 2)) { return step 0 ? nx - w : nx; // 贴住墙 } nx step; dx - step; } return nx; } }逻辑说明intersects是标准 AABB 公式四个条件同时成立才算相交。resolveX逐像素推进一旦前方格子是实心就停下并贴边这样能避免高速移动时穿过薄墙。垂直方向同理只是检测点换成头顶和脚下。参数说明检测点选y h / 2身体中部而不是顶部或底部是为了避免角色站在地面时水平移动被地面误判为墙。这个细节不注意角色会在地面上「走不动」是新手最常见的翻车点之一。注意碰撞检测的坐标必须是整数像素浮点坐标在isSolid里做除法会引入误差。速度可以用 double 累加但参与碰撞判断前要转成 int并且转换方式要统一用取整而不是四舍五入否则边界行为不一致。4. 避坑与排查Java 小游戏最容易翻车的 5 个地方4.1 画面闪烁为什么重绘时整个屏幕在抖现象角色移动时整个画面明显闪烁尤其是背景色区域。原因直接在JFrame上绘制或者没有用双缓冲。Swing 的JPanel默认开启双缓冲但如果你重写了paint而不是paintComponent或者手动调用了getGraphics()双缓冲就失效了。解决始终重写paintComponent并第一行调用super.paintComponent(g)不要用getGraphics()拿画笔不要在JFrame上直接画。如果还闪检查是否在update里调用了repaint()之外的刷新方法。4.2 键盘失灵按键没反应或一直触发现象按方向键角色不动或者按一下角色一直跑。原因面板没有获得焦点键盘事件被窗口其他组件截走或者在keyPressed里直接改坐标系统按键重复导致速度失控。解决创建面板后调用requestFocusInWindow()用布尔标志记录按键状态在update里统一处理移动keyReleased里务必把标志复位否则松键后角色还在跑。4.3 角色穿墙高速移动时穿过砖块现象马里奥跑得快时直接穿过薄墙或掉出地图底部。原因一帧移动距离超过一个瓦片宽度碰撞检测只检查了终点位置中间被跳过了。解决把移动拆成逐像素推进如 3.3 的resolveX每推进一像素检测一次或者限制单帧最大位移不超过TILE_SIZE / 2。下落速度用MAX_FALL限制也是同一个道理。4.4 数组越界地图边缘一碰就崩现象角色走到地图最左或最右时抛ArrayIndexOutOfBoundsException。原因isSolid里先查表后判断边界或者边界判断写错方向。解决在isSolid最前面做行列范围检查越界直接返回false地图外当空气。注意行和列的边界要分别判断MAP.length是行数MAP[0].length是列数写反了在非方阵地图上会出错。4.5 帧率耦合换台电脑速度就变了现象在自己电脑上跑得正常换台机器马里奥快得像开了加速。原因移动步长写死成「每帧 N 像素」而不同机器定时器实际触发频率不同或者显示器刷新率影响重绘节奏。解决引入基于时间的移动用两次更新之间的实际毫秒数乘以速度像素/秒而不是固定每帧像素数。这样无论帧率多少每秒移动距离一致。这是从「能跑」到「手感稳定」的关键一步。5. 让手感稳定的进阶技巧时间步长与状态机5.1 用 deltaTime 解耦帧率与移动速度前面所有移动都是「每帧 N 像素」这在同一台机器上没问题但一旦帧率波动速度就跟着变。正确做法是记录上一帧时间戳算出deltaTime秒速度用「像素/秒」表示每帧移动speed * deltaTime。public class GameLoop { private long lastTime System.nanoTime(); private static final double NS_PER_SEC 1_000_000_000.0; // 在 Timer 的 actionPerformed 里调用 public double tick() { long now System.nanoTime(); double delta (now - lastTime) / NS_PER_SEC; lastTime now; // 防止窗口拖动等导致的超大 delta 造成瞬移 return Math.min(delta, 0.05); } }逻辑说明delta是距上一帧的秒数正常约 0.016。移动时用x speed * deltaspeed 单位是像素/秒比如 200 像素/秒。Math.min(delta, 0.05)是保险丝——窗口被拖动或系统卡顿时delta可能变成好几秒不限制的话角色会瞬移出地图。参数说明速度值从「每帧像素」换算成「像素/秒」时乘以目标帧率即可比如原来每帧 4 像素、目标 60 FPS就是 240 像素/秒。重力同理GRAVITY从每帧增量变成每秒增量数值要乘以 60 再调整。5.2 用状态机管理马里奥的站立、跑动与跳跃马里奥有多个状态站立、跑动、跳跃、下落、死亡。用枚举加状态切换比一堆布尔标志清晰得多也方便接动画。状态触发条件对应动画备注IDLE在地面且无水平输入站立帧默认状态RUN在地面且有水平输入跑动帧循环按方向切换朝向JUMP离地且 vy 0跳跃帧上升阶段FALL离地且 vy 0下落帧下落阶段DEAD碰到敌人或掉出地图死亡帧触发后禁用输入状态切换集中在update开头根据输入和物理量判断动画帧只根据当前状态选图。这样加新状态比如变身、蹲下时只改状态机不用动渲染代码。我一般会把状态切换写成switch每个分支只做「是否满足切换条件」的判断具体动作交给对应方法避免update方法膨胀成几百行。5.3 一个具体技巧用固定时间步长跑物理渲染自由插值如果追求更稳的手感可以把物理更新固定成每秒 60 次渲染按实际帧率走两者之间用插值平滑。做法是维护一个累加器每帧把delta加进去超过固定步长就执行一次物理更新并减去步长可能一帧执行多次物理更新。渲染时用「上一次位置」和「当前位置」按剩余比例插值。这个技巧在 Swing 里不是必须的因为定时器本身比较稳但如果你发现快速移动时画面有轻微抖动或者想上更复杂的物理这套固定步长方案是标准解法。代价是代码复杂度上升建议先把 5.1 的 deltaTime 方案跑顺确认确实需要再上。我自己的习惯是小游戏先用 deltaTime够用就不折腾固定步长只有做需要精确复现的物理比如回放、联机同步时才上固定步长。这个判断标准帮我省了不少过度设计的功夫。希望帮到你。本文还有配套的精品资源点击获取
返回列表