☰
Java手写超级马里奥:Swing游戏主循环与状态机实战
2026/10/9 3:43:39 网站建设 项目流程

简介:这是一款基于Java Swing开发的超级马里奥风格小游戏,面向Java初学者与课程实践者,适合作为Java GUI编程、事件驱动机制及多线程应用的综合实训项目。资源完整覆盖从界面构建(JFrame、Swing组件)、交互控制(WASD键响应、监听器实现)到游戏逻辑(关卡渲染、角色移动)等核心知识点,支持在Zhangai类中自由编辑关卡布局,具备较强的教学延展性与二次开发空间。压缩包共76个文件,含52张PNG游戏素材图、8个Java源码文件(含主入口与逻辑模块)、8个编译后class文件、2个JAR可执行程序、2个WAV音效文件及配套README与LICENSE文档,整体体积6.93MB,结构清晰,便于按模块理解与调试。已有109人学习下载,提供开箱即用的完整工程结构、可视化资源组织方式及可定制化设计入口,是掌握Java桌面应用开发流程的典型入门级实战范例。

1. 为什么用 Java 写超级马里奥不是“复古情怀”,而是练透图形渲染、游戏循环与状态机的硬核入口

你可能在 B 站刷到过「Java 手写马里奥」的 demo 视频:像素小人踩砖块、吃蘑菇变大、顶问号箱掉金币——没有 Unity 的拖拽,没有 LibGDX 的封装,纯java.awt+javax.swing搭建的双缓冲画布,靠Timer驱动每秒 60 帧的逻辑更新。这不是怀旧玩具,而是Java 工程师验证自己是否真正吃透事件驱动、坐标系变换、碰撞判定和资源生命周期管理的试金石。它比「冒泡排序」或「MyBatis-Plus 自动生成建表 SQL」更能暴露你对 JVM 内存模型、AWT 线程安全边界、Swing EDT(Event Dispatch Thread)调度机制的真实理解。新手能靠它把Graphics2D的drawImage()、fillOval()和translate()用熟;老手则会借它重审「游戏主循环如何与 Swing 渲染线程协同」——稍一错位,就出现画面撕裂、按键延迟、角色瞬移等玄学问题。如果你正准备 Java 面试题(尤其面游戏引擎岗、中间件岗或需要高实时性 GUI 的嵌入式 Java 岗),这个项目不是加分项,是入场券。


2. 从零搭起游戏骨架:用 Swing + Timer 构建可稳定跑 60 FPS 的主循环

2.1 为什么不用 JavaFX 或 LibGDX?选 Swing 是为直面底层约束

很多教程一上来就推 LibGDX,但那等于用「汽车」教「造轮子」。而 JavaFX 在 JDK 11+ 被移出标准库,打包部署时需额外引入javafx-controls等模块,对想快速验证核心逻辑的开发者反而增加干扰。Swing 虽老,却是 JDK 自带、跨平台兼容性极佳、且强制你直面 AWT 线程模型——这恰恰是面试官最爱挖的点。比如:

  • repaint()必须在 EDT 中调用,否则 UI 卡死;
  • 游戏逻辑(如角色移动、碰撞检测)若也塞进 EDT,会导致 UI 响应迟滞;
  • 必须手动实现「逻辑帧 vs 渲染帧」分离,否则帧率波动直接暴露设计缺陷。

所以,我们采用Swing EDT 专责渲染 + 独立Timer线程驱动逻辑更新的经典解法。这不是妥协,是刻意训练。

2.2 最小可运行主循环:37 行代码撑起整个世界

// GamePanel.java import javax.swing.*; import java.awt.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.image.BufferedImage; public class GamePanel extends JPanel implements ActionListener { private static final int FPS = 60; private static final long FRAME_TIME_MS = 1000L / FPS; // ≈16.67ms private Timer gameTimer; private BufferedImage offScreenImage; private Graphics2D g2d; public GamePanel() { setPreferredSize(new Dimension(800, 600)); setBackground(Color.BLACK); setDoubleBuffered(false); // 关键!Swing 双缓冲由我们手动控制 gameTimer = new Timer((int) FRAME_TIME_MS, this); gameTimer.start(); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); if (offScreenImage == null) { offScreenImage = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB); g2d = offScreenImage.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); } // 1. 清空离屏缓冲区 g2d.setColor(Color.BLACK); g2d.fillRect(0, 0, getWidth(), getHeight()); // 2. 绘制所有游戏对象(此处暂为空) // 3. 将离屏图像一次性刷到屏幕 g.drawImage(offScreenImage, 0, 0, null); } @Override public void actionPerformed(ActionEvent e) { // 此处放每帧逻辑更新:角色位置、碰撞检测、状态切换... // 注意:这里不在 EDT 中执行!Timer 默认在 EventDispatchThread 运行, // 但我们显式用 SwingUtilities.invokeLater 包裹耗时操作 updateGameLogic(); } private void updateGameLogic() { // 示例:模拟重力下落(后续扩展为 Mario 的跳跃/移动) // 实际项目中,这里会调用 Player.update(), World.checkCollisions() 等 } }

关键参数说明:

  • FRAME_TIME_MS = 1000L / FPS:精确计算每帧允许耗时,避免Timer累积误差导致帧率漂移;
  • setDoubleBuffered(false):禁用 Swing 自带双缓冲,防止与我们手动离屏绘制冲突;
  • g2d.setRenderingHint(...):开启抗锯齿,让像素角色边缘不毛刺(马里奥的帽子轮廓更清晰);
  • actionPerformed中不直接写业务逻辑,而是调用updateGameLogic()—— 为后续接入多线程逻辑(如物理引擎)留扩展口。

2.3 启动器:确保 EDT 正确初始化并接管渲染

// GameLauncher.java import javax.swing.*; import java.awt.*; public class GameLauncher { public static void main(String[] args) { // 强制在 EDT 中创建并显示窗口 SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("Super Mario Java"); GamePanel gamePanel = new GamePanel(); frame.add(gamePanel); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); frame.pack(); // 自动适配 panel 尺寸 frame.setLocationRelativeTo(null); // 居中显示 frame.setVisible(true); // 关键:请求焦点,否则键盘事件无法捕获 gamePanel.requestFocusInWindow(); }); } }

为什么必须SwingUtilities.invokeLater?
若在 main 线程直接 new JFrame,UI 组件可能在非 EDT 线程创建,导致paintComponent调用异常或渲染错乱。这是 Java GUI 开发的铁律,也是高频面试题考点。


3. 让马里奥动起来:基于 Sprite 的角色系统与输入响应闭环

3.1 Sprite 类:不止是图片,更是状态容器与动画控制器

马里奥不是一张静态图,而是包含「站立」「奔跑」「跳跃」「受伤」等多种状态的 Sprite。每个状态对应一组帧序列(Frame Sequence),并自带播放速率、循环标志。我们不依赖外部图片库,用BufferedImage加载本地 PNG,并用getSubimage()切割精灵图(Sprite Sheet):

// Sprite.java import java.awt.*; import java.awt.image.BufferedImage; public class Sprite { private BufferedImage spriteSheet; private int frameWidth, frameHeight; private int currentFrame = 0; private int frameCount; private int frameDelay; // 每帧停留毫秒数 private boolean isLooping = true; private long lastFrameTime = 0; public Sprite(BufferedImage sheet, int frameWidth, int frameHeight, int frameCount, int frameDelay) { this.spriteSheet = sheet; this.frameWidth = frameWidth; this.frameHeight = frameHeight; this.frameCount = frameCount; this.frameDelay = frameDelay; } public void update(long currentTime) { if (currentTime - lastFrameTime > frameDelay) { currentFrame = (currentFrame + 1) % (isLooping ? frameCount : Math.min(currentFrame + 1, frameCount - 1)); lastFrameTime = currentTime; } } public void draw(Graphics2D g2d, int x, int y) { int sheetX = (currentFrame % (spriteSheet.getWidth() / frameWidth)) * frameWidth; int sheetY = (currentFrame / (spriteSheet.getWidth() / frameWidth)) * frameHeight; g2d.drawImage( spriteSheet.getSubimage(sheetX, sheetY, frameWidth, frameHeight), x, y, null ); } public void setFrame(int frameIndex) { this.currentFrame = Math.max(0, Math.min(frameIndex, frameCount - 1)); } // getter/setter 略 }

参数设计逻辑:

  • frameDelay控制动画速度(马里奥奔跑设为 80ms,跳跃设为 120ms,体现动作重量感);
  • sheetX/sheetY计算基于精灵图行列布局,支持任意宽高比的 Sprite Sheet;
  • draw()中getSubimage()比drawImage的sx1/sy1/sx2/sy2更易读,且避免坐标越界异常。

3.2 输入处理:用 KeyListener 实现「按键按下即响应」而非「轮询」

Swing 的KeyListener易踩坑:组件必须有焦点才能接收事件,且keyPressed/keyReleased与keyTyped行为不同。我们只监听keyPressed和keyReleased,构建一个InputHandler管理键状态:

// InputHandler.java import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.util.HashSet; import java.util.Set; public class InputHandler implements KeyListener { private final Set<Integer> pressedKeys = new HashSet<>(); @Override public void keyPressed(KeyEvent e) { pressedKeys.add(e.getKeyCode()); } @Override public void keyReleased(KeyEvent e) { pressedKeys.remove(e.getKeyCode()); } @Override public void keyTyped(KeyEvent e) {} // 不处理字符输入 public boolean isKeyPressed(int keyCode) { return pressedKeys.contains(keyCode); } public void clear() { pressedKeys.clear(); } }

为什么不用KeyEvent.getKeyLocation()?
马里奥操作只需方向键(LEFT/RIGHT/UP)和动作键(SPACE),无需区分左右 Ctrl。getKeyCode()足够稳定,且避免KEY_LOCATION_NUMPAD等定位歧义。

3.3 Player 类:整合 Sprite、输入、物理属性的状态机

// Player.java import java.awt.*; import java.awt.geom.Rectangle2D; public class Player { private double x = 100, y = 400; // 世界坐标(非屏幕坐标) private double velocityX = 0, velocityY = 0; private static final double GRAVITY = 0.5; private static final double MAX_SPEED = 5.0; private static final double JUMP_FORCE = -12.0; private Sprite standingSprite, runningSprite, jumpingSprite; private Sprite currentSprite; private boolean isOnGround = false; private boolean facingRight = true; public Player(Sprite standing, Sprite running, Sprite jumping) { this.standingSprite = standing; this.runningSprite = running; this.jumpingSprite = jumping; this.currentSprite = standing; } public void update(InputHandler input, long currentTime) { // 1. 处理水平输入 if (input.isKeyPressed(KeyEvent.VK_LEFT)) { velocityX = Math.max(velocityX - 0.3, -MAX_SPEED); facingRight = false; } else if (input.isKeyPressed(KeyEvent.VK_RIGHT)) { velocityX = Math.min(velocityX + 0.3, MAX_SPEED); facingRight = true; } else { velocityX *= 0.85; // 摩擦力衰减 } // 2. 处理跳跃 if (input.isKeyPressed(KeyEvent.VK_UP) && isOnGround) { velocityY = JUMP_FORCE; isOnGround = false; } // 3. 应用重力 if (!isOnGround) { velocityY += GRAVITY; } // 4. 更新位置 x += velocityX; y += velocityY; // 5. 更新当前 Sprite if (!isOnGround) { currentSprite = jumpingSprite; } else if (Math.abs(velocityX) > 0.5) { currentSprite = runningSprite; } else { currentSprite = standingSprite; } currentSprite.update(currentTime); } public void draw(Graphics2D g2d) { // 镜像绘制:当 facingRight=false 时,水平翻转 Sprite AffineTransform oldTransform = g2d.getTransform(); if (!facingRight) { g2d.translate(x + currentSprite.getFrameWidth(), y); g2d.scale(-1, 1); g2d.translate(-currentSprite.getFrameWidth(), 0); } currentSprite.draw(g2d, (int) x, (int) y); g2d.setTransform(oldTransform); } // getter/setter 略,含 getBounds() 用于碰撞检测 public Rectangle2D getBounds() { return new Rectangle2D.Double(x, y, currentSprite.getFrameWidth(), currentSprite.getFrameHeight()); } }

关键设计点:

  • velocityX/Y用double而非int:避免整数截断导致移动卡顿;
  • facingRight控制镜像:AffineTransform比Graphics2D.rotate()更精准,且不影响后续绘制;
  • getBounds()返回Rectangle2D:为后续与砖块、管道的碰撞检测提供统一接口。

4. 碰撞检测与关卡构建:用 TileMap 实现可编辑的 2D 世界

4.1 TileMap:用二维数组定义世界,每个数字代表一种砖块类型

马里奥的世界本质是网格(Grid)。我们用int[][] tiles存储关卡数据,0为空气,1为普通砖块,2为问号箱,3为金币……加载时解析 CSV 或文本文件,生成可复用的TileMap对象:

// TileMap.java import java.awt.*; import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; public class TileMap { private int[][] tiles; private BufferedImage tileSet; private int tileSize = 32; // 每个瓦片像素尺寸 private int tileWidth, tileHeight; public TileMap(String mapPath, BufferedImage tileSet) { this.tileSet = tileSet; this.tileWidth = tileSet.getWidth() / tileSize; this.tileHeight = tileSet.getHeight() / tileSize; loadMap(mapPath); } private void loadMap(String path) { try (InputStream is = getClass().getResourceAsStream(path); BufferedReader reader = new BufferedReader(new InputStreamReader(is))) { String line; int row = 0; while ((line = reader.readLine()) != null) { if (line.trim().isEmpty()) continue; String[] values = line.split(","); if (tiles == null) { tiles = new int[values.length][]; } for (int col = 0; col < values.length; col++) { tiles[col][row] = Integer.parseInt(values[col].trim()); } row++; } } catch (Exception e) { throw new RuntimeException("Failed to load tile map: " + path, e); } } public void draw(Graphics2D g2d, int offsetX, int offsetY) { if (tiles == null) return; for (int x = 0; x < tiles.length; x++) { for (int y = 0; y < tiles[x].length; y++) { int tileId = tiles[x][y]; if (tileId == 0) continue; // 空气不绘制 int sx = (tileId - 1) % tileWidth * tileSize; int sy = (tileId - 1) / tileWidth * tileSize; g2d.drawImage( tileSet.getSubimage(sx, sy, tileSize, tileSize), x * tileSize - offsetX, y * tileSize - offsetY, null ); } } } public Rectangle2D getTileBounds(int x, int y) { return new Rectangle2D.Double(x * tileSize, y * tileSize, tileSize, tileSize); } public int getTileAt(int worldX, int worldY) { int tileX = (int) (worldX / tileSize); int tileY = (int) (worldY / tileSize); if (tileX < 0 || tileX >= tiles.length || tileY < 0 || tileY >= tiles[0].length) { return 0; // 边界外视为空气 } return tiles[tileX][tileY]; } }

为什么用 CSV 而非二进制?
CSV 可人工编辑(如用 Excel 调整关卡布局),便于快速迭代。"1,1,1,0,2"比十六进制字节流直观十倍。

4.2 碰撞检测:分离轴定理(SAT)简化版——AABB 矩形相交

马里奥与砖块的碰撞,本质是两个矩形(Axis-Aligned Bounding Box)是否重叠。我们不引入复杂物理引擎,用最简intersects()判断,并在Player.update()中调用:

// CollisionDetector.java import java.awt.geom.Rectangle2D; public class CollisionDetector { public static boolean checkCollision(Rectangle2D a, Rectangle2D b) { return a.getX() < b.getX() + b.getWidth() && a.getX() + a.getWidth() > b.getX() && a.getY() < b.getY() + b.getHeight() && a.getY() + a.getHeight() > b.getY(); } // 优化:只检测玩家周围 3x3 瓦片,避免遍历全图 public static void resolveCollision(Player player, TileMap tileMap, double offsetX, double offsetY) { Rectangle2D playerBounds = player.getBounds(); int startX = Math.max(0, (int) ((playerBounds.getX() - offsetX) / tileMap.getTileSize())); int startY = Math.max(0, (int) ((playerBounds.getY() - offsetY) / tileMap.getTileSize())); int endX = Math.min(tileMap.getTiles()[0].length, (int) ((playerBounds.getX() + playerBounds.getWidth() - offsetX) / tileMap.getTileSize()) + 1); int endY = Math.min(tileMap.getTiles().length, (int) ((playerBounds.getY() + playerBounds.getHeight() - offsetY) / tileMap.getTileSize()) + 1); for (int x = startX; x < endX; x++) { for (int y = startY; y < endY; y++) { int tileId = tileMap.getTileAt(x, y); if (tileId == 0) continue; Rectangle2D tileBounds = tileMap.getTileBounds(x, y); if (checkCollision(playerBounds, tileBounds)) { resolveOneCollision(player, tileBounds, tileId); } } } } private static void resolveOneCollision(Player player, Rectangle2D tileBounds, int tileId) { Rectangle2D playerBounds = player.getBounds(); double overlapLeft = tileBounds.getX() + tileBounds.getWidth() - playerBounds.getX(); double overlapRight = playerBounds.getX() + playerBounds.getWidth() - tileBounds.getX(); double overlapTop = tileBounds.getY() + tileBounds.getHeight() - playerBounds.getY(); double overlapBottom = playerBounds.getY() + playerBounds.getHeight() - tileBounds.getY(); // 选择最小重叠方向进行修正 double minOverlap = Math.min(Math.min(overlapLeft, overlapRight), Math.min(overlapTop, overlapBottom)); if (minOverlap == overlapLeft) { player.setX(tileBounds.getX() + tileBounds.getWidth()); player.setVelocityX(0); } else if (minOverlap == overlapRight) { player.setX(tileBounds.getX() - playerBounds.getWidth()); player.setVelocityX(0); } else if (minOverlap == overlapTop) { player.setY(tileBounds.getY() + tileBounds.getHeight()); player.setVelocityY(0); player.setOnGround(true); } else if (minOverlap == overlapBottom) { player.setY(tileBounds.getY() - playerBounds.getHeight()); player.setVelocityY(0); } } }

血泪经验:

  • resolveCollision中的startX/startY/endX/endY计算必须考虑offsetX/offsetY(摄像机偏移),否则滚动时碰撞失效;
  • minOverlap选择是为了防止「卡墙」:若同时触发左右碰撞,优先修正水平方向,避免垂直方向被错误覆盖。

5. 避坑指南:Swing 游戏开发中 5 个让你凌晨三点抓狂的真问题

5.1 现象:马里奥跑着跑着突然「瞬移」回起点,且只在 Windows 上发生

原因:Windows 的Timer默认精度为 15.6ms,当FRAME_TIME_MS=16.67时,实际间隔可能跳变为 31ms,导致逻辑帧累积误差爆发式增长,x += velocityX累加值爆炸。
解决:改用java.util.Timer+ScheduledThreadPoolExecutor替代javax.swing.Timer,或在actionPerformed中加入时间补偿:

private long lastUpdateTime = System.nanoTime(); private void updateGameLogic() { long now = System.nanoTime(); double deltaTime = (now - lastUpdateTime) / 1_000_000_000.0; // 秒 lastUpdateTime = now; player.update(input, now); player.setX(player.getX() + player.getVelocityX() * deltaTime * 60); // 归一化到 60FPS }

5.2 现象:按住方向键不放,马里奥只走一步就停,松开再按才继续

原因:KeyListener在 Windows/Linux 下默认启用「键盘重复延迟」,首次keyPressed后需等待 250ms 才触发连续事件,而 Swing 的Timer帧率远高于此。
解决:在GamePanel构造函数中禁用系统重复:

public GamePanel() { // ... 其他初始化 Toolkit.getDefaultToolkit().setDynamicLayout(false); // 关键 // 并在 main 中添加: // System.setProperty("sun.awt.noerasebackground", "true"); }

5.3 现象:问号箱被顶后,金币飞出但轨迹歪斜,且有时直接消失

原因:Graphics2D的drawImage在缩放/旋转时默认使用RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR,导致像素图变形;且金币Sprite的frameDelay未随速度动态调整。
解决:在GamePanel.paintComponent中统一设置插值:

g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);

金币类中根据飞行速度动态设置frameDelay:coinSprite.setFrameDelay((int)(100 - Math.abs(coinVelocityY) * 5));

5.4 现象:窗口最大化后,马里奥移动范围变窄,仿佛被「裁剪」

原因:JFrame.pack()计算尺寸时未考虑BorderLayout.CENTER的伸缩行为,且GamePanel.setPreferredSize()固定为 800x600,导致pack()后实际可用区域小于预期。
解决:重写GamePanel.getPreferredSize()动态返回:

@Override public Dimension getPreferredSize() { return new Dimension( Math.max(800, getParent().getWidth()), Math.max(600, getParent().getHeight()) ); }

并在GameLauncher中移除frame.pack(),改用frame.setSize(800, 600)+frame.setExtendedState(JFrame.MAXIMIZED_BOTH)。

5.5 现象:打包成 JAR 后,精灵图加载失败,报NullPointerException

原因:getClass().getResourceAsStream("/res/mario.png")在 IDE 中工作,但 JAR 内资源路径需以/开头且区分大小写;且BufferedImage加载失败时不抛异常,静默返回null。
解决:封装健壮资源加载器:

public static BufferedImage loadImage(String path) { InputStream is = GamePanel.class.getResourceAsStream(path); if (is == null) { throw new RuntimeException("Resource not found: " + path); } try { return ImageIO.read(is); } catch (IOException e) { throw new RuntimeException("Failed to load image: " + path, e); } }

并确保资源路径全小写、无空格,如/res/tiles.png。


6. 进阶技巧:用 Object Serialization 实现关卡编辑器与存档系统

6.1 把关卡数据变成可交互的「所见即所得」编辑器

与其手写 CSV,不如做一个简易 Swing 编辑器:左侧工具栏选砖块类型,右侧网格点击放置,Ctrl+S 保存为.mario文件。核心是将TileMap.tiles序列化为二进制:

// LevelEditor.java import java.io.*; public class LevelEditor { private int[][] tiles; public void saveLevel(String filename) { try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream(filename))) { oos.writeObject(tiles); } catch (IOException e) { throw new RuntimeException("Save failed", e); } } @SuppressWarnings("unchecked") public void loadLevel(String filename) { try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream(filename))) { tiles = (int[][]) ois.readObject(); } catch (Exception e) { throw new RuntimeException("Load failed", e); } } }

为什么不用 JSON?
int[][]直接序列化体积小、速度快,且 Java 原生支持。JSON 需引入 Jackson/Gson,对小游戏属过度设计。

6.2 存档系统:记录玩家位置、金币数、关卡进度,支持断点续玩

存档不是简单保存坐标,而是捕获完整游戏状态快照。我们定义GameSave类,仅序列化必要字段:

// GameSave.java import java.io.Serializable; public class GameSave implements Serializable { private static final long serialVersionUID = 1L; private double playerX, playerY; private int coins, lives, currentLevel; private long gameTimeMs; // 游戏内计时,用于动画同步 public GameSave(Player player, int coins, int lives, int level, long time) { this.playerX = player.getX(); this.playerY = player.getY(); this.coins = coins; this.lives = lives; this.currentLevel = level; this.gameTimeMs = time; } // getter/setter 略 }

关键细节:

  • serialVersionUID = 1L防止类结构变更后反序列化失败;
  • 不序列化Sprite对象(含BufferedImage,不可序列化),只存逻辑数据;
  • gameTimeMs用于恢复时同步动画帧,避免存档后金币飘行动画错位。

6.3 防作弊校验:用 CRC32 校验存档完整性,拒绝手动修改

玩家可能用记事本篡改存档文件。我们在保存时附加校验码:

public void saveWithChecksum(GameSave save, String filename) { try (DataOutputStream dos = new DataOutputStream( new FileOutputStream(filename))) { byte[] data = serializeToBytes(save); dos.write(data); // 计算 CRC32 CRC32 crc = new CRC32(); crc.update(data); dos.writeLong(crc.getValue()); } catch (IOException e) { throw new RuntimeException(e); } } public GameSave loadWithChecksum(String filename) { try (DataInputStream dis = new DataInputStream( new FileInputStream(filename))) { byte[] data = dis.readAllBytes(); long savedCrc = dis.readLong(); CRC32 crc = new CRC32(); crc.update(data, 0, data.length - 8); // 排除最后 8 字节校验码 if (crc.getValue() != savedCrc) { throw new RuntimeException("Save file corrupted or tampered!"); } return deserializeFromBytes(data); } catch (IOException e) { throw new RuntimeException(e); } }

为什么用 CRC32 而非 MD5?
CRC32 计算快、校验码短(8 字节),适合小游戏存档。MD5 过重,且对「防小白篡改」已足够。

我当年第一次实现存档时,没加校验,结果测试同事把lives:1改成lives:999,上线前才发现——那晚加班重写了三遍校验逻辑。现在我的习惯是:任何用户可接触的数据,写入前必加校验,读取后必验签。这不是 paranoid,是给未来省下 20 小时 debug 时间的后悔药。
希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询