news 2026/9/16 3:12:05

Java SE游戏开发:从Swing主循环到AABB碰撞的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java SE游戏开发:从Swing主循环到AABB碰撞的完整实践

简介:这是一份基于Java实现的经典超级马里奥风格小游戏源码,面向计算机、数学、电子信息等专业的本科生,适用于课程设计、期末大作业及毕业设计参考,帮助学习者通过完整可运行项目掌握Swing图形界面开发、游戏主循环、碰撞检测、音效播放与资源管理等核心实践技能。压缩包共75个文件,含8个Java源文件(构成游戏逻辑主体)、8个编译后class文件、52张PNG格式游戏素材图(角色、场景、UI元素)、2个WAV音效文件及2个JAR可执行包,辅以README.md说明文档,整体体积6.89MB,结构清晰,模块划分明确。已有265人下载学习,资源开箱即用,包含完整工程目录(src、bin、lib、Images、Music等),提供从代码组织到资源加载的典型Java桌面游戏开发范式,是理解轻量级2D游戏架构与自主拓展功能的优质学习样本。

1. 这不是怀旧彩蛋,而是一套可拆解、可调试、可面试复现的 Java 游戏架构实践

当你在简历里写“熟悉面向对象设计”,面试官可能只当是套话;但如果你能当场用javac编译、java启动一个带角色跳跃、砖块碰撞、金币收集、关卡切换的超级马里奥风格小游戏,并指着Player.java里的状态机说“这里用枚举控制跳跃中/下落中/受击僵直三种状态”,那“熟悉”二字就落到了实处。这个.zip包里没有黑盒引擎、不依赖 Unity 或 LibGDX,它用纯 Java SE(JDK 8+)+ Swing/AWT 实现——意味着你能看清每一帧如何刷新、每个按键如何被拦截、每块砖如何参与碰撞检测。它适合三类人:刚学完多线程想练手的 Java 新手、准备技术面需要展示工程能力的应届生、以及想逆向理解经典 2D 游戏循环(Game Loop)与状态管理的老手。它不追求像素级还原,但所有核心机制——时间步长控制、坐标系映射、精灵帧动画、边界检测逻辑——都暴露在源码里,且命名清晰、注释到位。你下载解压后看到的不是一堆.class文件,而是可读、可改、可断点调试的.java源文件。

2. 从 Swing 窗口到游戏主循环:Java 小游戏的最小可行骨架

2.1 为什么选 Swing 而非 JavaFX 或 LWJGL?

在 JDK 8–17 的主流开发环境中,Swing 仍是零依赖、跨平台、调试友好的首选。它不需额外 JAR 包,javax.swing.*java.awt.*已内置于 JRE;其BufferStrategy可实现双缓冲绘图,避免闪烁;KeyListener对键盘事件的捕获足够稳定,满足方向键+空格跳跃的基本输入需求。JavaFX 虽更现代,但需模块化配置,且Canvas的渲染控制粒度对初学者略重;LWJGL 则直接跳入 OpenGL 底层,偏离“理解游戏逻辑”的初衷。本项目采用JFrame+Canvas+BufferStrategy组合,是 Java 2D 游戏最轻量、最易追溯的实现路径。

2.2 游戏主循环的 Java 实现:固定时间步长 vs 垂直同步

游戏流畅性的关键不在帧率高低,而在逻辑更新与渲染的节奏一致性。本源码采用固定时间步长(Fixed Timestep),而非简单while(true) { render(); update(); }。核心代码位于GameLoop.java或主类的run()方法中:

public void run() { final double NS_PER_SECOND = 1_000_000_000.0; final double TARGET_FPS = 60.0; final double UPDATE_INTERVAL_NS = NS_PER_SECOND / TARGET_FPS; // ≈16.67ms double lastUpdateTime = System.nanoTime(); double accumulator = 0.0; while (running) { double currentTime = System.nanoTime(); double deltaTime = currentTime - lastUpdateTime; lastUpdateTime = currentTime; accumulator += deltaTime; // 固定逻辑更新:每 16.67ms 执行一次 while (accumulator >= UPDATE_INTERVAL_NS) { update(); // 更新玩家位置、碰撞检测、金币状态等 accumulator -= UPDATE_INTERVAL_NS; } render(); // 渲染当前帧(可能插值平滑) sleep(1); // 防止 CPU 占满 } }

提示:update()中的逻辑必须与时间解耦——例如玩家水平速度vx = 3.0表示每秒移动 3 像素,而非每帧移动 3 像素。这样即使渲染帧率波动(如因 GC 暂停),角色移动距离仍保持物理一致性。

2.3 窗口初始化与双缓冲设置:避免闪烁的关键三步

Swing 默认渲染存在撕裂风险,必须手动启用双缓冲。以下是GameWindow.java中的标准初始化流程:

public class GameWindow extends JFrame { private Canvas canvas; private BufferStrategy bufferStrategy; public GameWindow() { setTitle("Super Mario Java"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); canvas = new Canvas(); canvas.setPreferredSize(new Dimension(800, 600)); canvas.setFocusable(true); // 关键:使 Canvas 可获取键盘焦点 add(canvas); pack(); setLocationRelativeTo(null); // 居中显示 setVisible(true); // 1. 创建缓冲策略(必须在 setVisible(true) 之后) canvas.createBufferStrategy(2); bufferStrategy = canvas.getBufferStrategy(); // 2. 请求键盘焦点(否则 KeyListener 不生效) canvas.requestFocusInWindow(); // 3. 添加键盘监听器(注意:监听的是 Canvas,不是 JFrame) canvas.addKeyListener(new KeyHandler()); } }

注意:canvas.createBufferStrategy(2)创建双缓冲区;canvas.requestFocusInWindow()是新手最常遗漏的步骤——若无此句,按方向键将毫无反应;KeyListener必须绑定到Canvas实例,而非JFrame,因为 Swing 的事件分发机制要求组件具有焦点且为事件源。

3. 核心实体建模:Player、Block、Coin 的面向对象设计与碰撞逻辑

3.1 Player 类的状态机设计:用枚举封装行为模式

Player.java不是一个简单的坐标容器,而是包含完整行为契约的对象。其核心是State枚举,定义了角色生命周期中的关键状态:

public enum State { IDLE, // 站立不动 RUNNING, // 左右移动中 JUMPING, // 向上跃起(按键触发) FALLING, // 重力作用下坠落 HURT, // 被敌人击中后的短暂无敌帧 DEAD // 生命归零 } private State currentState = State.IDLE; private double vy = 0.0; // 垂直速度(用于模拟重力) private boolean onGround = false; public void update() { switch (currentState) { case IDLE: case RUNNING: if (!onGround) { currentState = State.FALLING; } break; case JUMPING: if (vy <= 0) { // 速度转负,开始下落 currentState = State.FALLING; } break; case FALLING: if (onGround) { currentState = State.IDLE; vy = 0; } break; case HURT: hurtTimer--; if (hurtTimer <= 0) { currentState = State.IDLE; } break; } }

提示:onGround标志位由碰撞检测模块在update()前置计算得出;vy的累加与重力系数(如GRAVITY = 0.5)共同决定下落加速度;HURT状态配合闪烁动画与无敌帧,是处理“被踩敌人反伤”逻辑的基础。

3.2 Block 碰撞检测:AABB 矩形包围盒的精确判定

所有静态障碍物(砖块、管道、地面)均继承自Block.java,其碰撞逻辑基于 Axis-Aligned Bounding Box(AABB)。Player在每次update()前,遍历所有Block实例,执行四边检测:

public boolean checkCollision(Player player) { Rectangle playerRect = player.getBounds(); Rectangle blockRect = getBounds(); // 返回自身坐标+宽高构成的 Rectangle // 检测是否发生重叠 if (playerRect.intersects(blockRect)) { // 计算穿透深度(penetration depth),用于精准反弹 double px = Math.min(playerRect.x + playerRect.width - blockRect.x, blockRect.x + blockRect.width - playerRect.x); double py = Math.min(playerRect.y + playerRect.height - blockRect.y, blockRect.y + blockRect.height - playerRect.y); // 根据最小穿透方向修正位置(防止卡进砖块) if (px < py) { if (player.getVx() > 0) player.setX(blockRect.x - playerRect.width); else player.setX(blockRect.x + blockRect.width); } else { if (player.getVy() > 0) { // 下落时碰撞 player.setY(blockRect.y - playerRect.height); player.setVy(0); player.setOnGround(true); } else { // 上跳时碰撞顶部 player.setY(blockRect.y + blockRect.height); player.setVy(0); } } return true; } return false; }

注意:intersects()仅判断是否相交,真正游戏体验取决于后续的位置修正。此处通过比较 X/Y 方向穿透量pxpy,选择最小值对应的方向进行位置回弹——这是避免角色“穿墙”或“坠入地面”的关键。player.setOnGround(true)直接影响Player的状态机流转。

3.3 Coin 收集与状态同步:观察者模式的轻量应用

金币(Coin.java)本身是不可交互的静态对象,但其“被收集”事件需通知多个模块:增加分数、播放音效、触发粒子动画。源码采用简易观察者模式,避免硬编码耦合:

// Coin.java public class Coin { private boolean collected = false; private List<CoinObserver> observers = new ArrayList<>(); public void collect() { if (!collected) { collected = true; notifyCollected(); // 通知所有监听者 } } public void addObserver(CoinObserver observer) { observers.add(observer); } private void notifyCollected() { for (CoinObserver obs : observers) { obs.onCoinCollected(this); } } } // ScoreManager.java 实现 CoinObserver public class ScoreManager implements CoinObserver { private int score = 0; @Override public void onCoinCollected(Coin coin) { score += 100; // 触发 UI 更新(如 JLabel.setText("Score: " + score)) } }

提示:Coin类不持有ScoreManager引用,仅维护观察者列表;ScoreManager在初始化时调用coin.addObserver(this)完成注册。这种解耦使后续扩展(如添加音效管理器、成就系统)无需修改Coin代码。

4. 输入响应与动画控制:KeyHandler 与 SpriteSheet 的协同工作

4.1 KeyHandler:去抖动、状态缓存与组合键支持

键盘输入存在重复触发和按键粘连问题。KeyHandler.java采用“按下/释放”双状态缓存,而非直接监听keyPressed/keyReleased

public class KeyHandler implements KeyListener { private final boolean[] keys = new boolean[256]; // ASCII 码范围 private final boolean[] prevKeys = new boolean[256]; @Override public void keyPressed(KeyEvent e) { keys[e.getKeyCode()] = true; } @Override public void keyReleased(KeyEvent e) { keys[e.getKeyCode()] = false; } // 提供“本次帧内首次按下”的判定(防连发) public boolean isPressedOnce(int keyCode) { boolean current = keys[keyCode]; boolean previous = prevKeys[keyCode]; prevKeys[keyCode] = current; return current && !previous; } // 提供“持续按下”的判定(用于奔跑) public boolean isHeld(int keyCode) { return keys[keyCode]; } }

Player.update()中调用:

if (keyHandler.isPressedOnce(KeyEvent.VK_SPACE) && player.isOnGround()) { player.jump(); // 仅在地面时允许起跳 } if (keyHandler.isHeld(KeyEvent.VK_RIGHT)) { player.setVx(2.5); // 持续向右加速 } else if (keyHandler.isHeld(KeyEvent.VK_LEFT)) { player.setVx(-2.5); } else { player.setVx(0); // 松开方向键即停止 }

注意:isPressedOnce()通过对比当前帧与上一帧的按键状态,确保空格跳跃只触发一次;isHeld()则支持连续移动。VK_RIGHT/VK_LEFT等常量来自KeyEvent,比硬编码数字更可读。

4.2 SpriteSheet 动画:按帧率切图与状态驱动播放

角色动画不使用 GIF,而是将多帧图像打包为单张SpriteSheet.png(如 128×128 像素,含 4×4 共 16 帧)。SpriteSheet.java负责裁剪子图:

public class SpriteSheet { private BufferedImage sheet; private int spriteWidth, spriteHeight; public SpriteSheet(String path, int width, int height) { try { sheet = ImageIO.read(getClass().getResourceAsStream(path)); this.spriteWidth = width; this.spriteHeight = height; } catch (IOException e) { e.printStackTrace(); } } public BufferedImage getSprite(int xIndex, int yIndex) { return sheet.getSubimage( xIndex * spriteWidth, yIndex * spriteHeight, spriteWidth, spriteHeight ); } }

Player.render()根据当前状态与计时器选择帧:

private int currentFrame = 0; private long lastFrameTime = 0; private final long FRAME_DURATION_MS = 150; // 每帧显示 150ms public void render(Graphics2D g) { long now = System.currentTimeMillis(); if (now - lastFrameTime > FRAME_DURATION_MS) { currentFrame = (currentFrame + 1) % totalFrames; lastFrameTime = now; } BufferedImage sprite = spriteSheet.getSprite( currentFrame % 4, // X 坐标索引(假设每行 4 帧) getStateRow() // Y 坐标索引:IDLE=0, RUNNING=1, JUMPING=2... ); g.drawImage(sprite, (int)x, (int)y, null); }

提示:getStateRow()返回当前状态对应的行号,使不同动作使用独立动画序列;currentFrame % 4确保循环播放;FRAME_DURATION_MS可按需调整,奔跑动画通常比站立动画更快。

5. 调试技巧与性能验证:如何确认你的 Java 小游戏运行在正确轨道上

5.1 实时帧率监控:用 System.nanoTime() 验证主循环稳定性

render()方法末尾插入帧率统计,输出到控制台或 UI:

private long lastFpsTime = 0; private int frameCount = 0; private int fps = 0; public void render() { // ... 实际渲染代码 ... frameCount++; long now = System.nanoTime(); if (now - lastFpsTime >= 1_000_000_000L) { // 1 秒 fps = frameCount; frameCount = 0; lastFpsTime = now; System.out.println("FPS: " + fps); // 或更新 JLabel } }

提示:真实 FPS 应稳定在 58–62 之间(受系统调度影响)。若持续低于 40,检查update()中是否有耗时操作(如未优化的碰撞检测);若剧烈波动(如 20→60→30),确认未在render()中执行 IO 或复杂计算。

5.2 碰撞调试可视化:临时绘制包围盒轮廓

render()中添加调试开关,用红色矩形描出所有实体的getBounds()

if (DEBUG_MODE) { Graphics2D g2d = (Graphics2D) g; g2d.setColor(Color.RED); g2d.draw(player.getBounds()); // 绘制玩家包围盒 for (Block block : blocks) { g2d.draw(block.getBounds()); // 绘制所有砖块包围盒 } }

启动时传入-Ddebug=true,并在代码中读取:

public static final boolean DEBUG_MODE = Boolean.getBoolean("debug");

注意:包围盒偏移或尺寸错误会直接导致“明明没碰到却触发碰撞”或“穿过砖块”。此法能快速定位getBounds()返回值是否与实际图像区域一致。

5.3 内存与线程健康检查:jconsole 连接验证无泄漏

小游戏长期运行需防范内存泄漏(如未释放BufferedImage)或线程失控。启动时添加 JVM 参数:

java -Dcom.sun.management.jmxremote -jar SuperMario.jar

然后用 JDK 自带的jconsole连接本地进程,重点关注:

  • Memory标签页:观察Heap Memory Usage是否周期性回落(GC 正常);
  • Threads标签页:确认仅存在mainAWT-EventQueue-0TimerQueue等必要线程,无意外创建的Thread-XX
  • VM Summary:检查Loaded Class Count是否随时间增长(类加载器泄漏迹象)。

若发现Heap持续上涨且不回收,检查SpriteSheet是否重复加载同一资源,或Player是否持有已销毁对象的引用。

5.4 关键参数速查表:修改何处可快速调整游戏手感

参数位置作用推荐调整范围效果说明
Player.GRAVITY = 0.5下落加速度0.3–0.8值越小,跳跃越高、滞空越久;过大则显得沉重
Player.JUMP_FORCE = -12.0起跳初速度-8.0 至 -15.0负值越大(绝对值),起跳越猛;需与 GRAVITY 平衡
GameLoop.UPDATE_INTERVAL_NS逻辑更新频率16.67ms(60Hz)降低此值可提升响应精度,但增加 CPU 负担
SpriteSheet.FRAME_DURATION_MS动画帧间隔100–200ms值越小,动画越快;奔跑帧建议 120ms,站立帧 200ms
KeyHandler.FRAME_DURATION_MS键盘去抖时间无(由 isPressedOnce 保证)无需修改,但理解其原理可避免误判

修改后务必重新编译javac *.java并测试——这些数值共同定义了“马里奥式”的操作反馈,是 Java 小游戏区别于其他语言实现的核心手感来源。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 3:12:00

技术人如何通过写博客从极客成为行业意见领袖:路径、方法与避坑

先把话说在前面&#xff1a;这篇文章不是什么成功学&#xff0c;也不是那种“三个月从零做到十万粉”的速成教程。我见过太多技术人&#xff0c;代码写得漂亮、方案讲得清楚&#xff0c;但一提到写博客、做分享&#xff0c;就觉得那是另一个世界的事。老蒋博客从最开始一个没人…

作者头像 李华
网站建设 2026/9/16 3:11:53

ROS多无人机仿真:命名空间、TF前缀与气流耦合的系统级重构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:08:21

Kubernetes离线部署全流程:从镜像搬运到集群稳定运行

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:08:02

Go语言前缀和优化:区间非零数字拼接与求积问题

最近刷题的时候碰到一个挺有意思的字符串处理问题&#xff0c;题面很简短&#xff0c;翻译成人话大概是这样的&#xff1a;给你一个只包含数字的字符串&#xff0c;然后来一堆区间查询&#xff0c;每次给你一个区间 [l, r]&#xff0c;你要在这个子串里把所有“非零”的字符数字…

作者头像 李华
网站建设 2026/9/16 3:08:01

破解版资源传播二十年:从下载站到安全威胁模型分析

3DM与游民星空&#xff0c;这两个名字摆在一起&#xff0c;老玩家的DNA多少会动一下。十多年前的网吧、宿舍、串盘时代&#xff0c;谁没从这两个站里扒过资源&#xff1f;但今天我不想聊“哪个站资源全”“哪个组汉化快”&#xff0c;那些话题早被聊烂了。我想从网络安全的角度…

作者头像 李华
网站建设 2026/9/16 3:04:04

AI原生SD-WAN:从阈值触发到预测切换的网络重构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华