☰
Java推箱子游戏开发:二维数组地图解析与Swing界面实现
2026/10/29 19:27:39 网站建设 项目流程

简介:一份基于Java语言实现的推箱子(Sokoban)经典益智游戏完整工程,面向Java初学者、课程设计及游戏开发入门者,演示从游戏地图读取、箱子移动逻辑到GUI界面搭建的完整过程。项目采用“炮炮兵”角色图片,界面美观,适合参考界面布局、事件响应与碰撞检测思路。压缩包共60个文件,包含36个map关卡地图、10个gif角色动画图片、java源码与class编译文件、doc开发/用户/测试文档,以及mid背景音乐和bat一键启动脚本,整体仅248KB,轻量易用。已有224人学习下载,适合用来对照源码理解Java Swing等桌面程序开发,也可直接运行体验或继续扩展关卡。除主程序外还附带程序文档,可帮助快速把握代码结构和运行方式。

1. 推箱子 java 项目在练什么:从一张字符画地图到一个能玩的界面

推箱子(Sokoban)在 Java 课程设计和面试练手项目里出现频率极高,网上搜“推箱子 java”会翻到大量打包成 rar 的源码工程,PP.rar 这种命名就常出现在这类搜索结果里。真正值得做的不是解压后直接跑,而是把地图数据结构、移动判定、界面刷新和撤销功能四个环节自己走通。这个项目能把二维数组、字符串处理、面向对象设计、键盘监听和 repaint 机制一次串起来。本文按我做这类小游戏惯用的顺序展开:先定义地图数据,再写移动与完成判定,然后用 Swing 搭界面,最后把最常见的坑逐个排掉。适合刚学完 Java 基础想巩固的开发者,也适合准备 Java 面试前想整理一个能讲清楚项目的人。

2. 地图数据结构与解析:一张字符画地图如何变成二维数组

2.1 关卡符号约定:用空字符、墙、箱子、目标点和玩家描述场景

经典的推箱子关卡长这样:

##### # # # $ # # @ # # . # #####

在 Java 工程里,我一般不让地图散落成零散字符串,而是按行放进 String[],然后在构造时统一解析成 char[][]。这样做的两个好处:一是关卡文件好维护,写错哪一行一眼能看出来;二是后续渲染和碰撞检测都只需要查 grid[r][c],不再碰字符串操作。符号约定要提前定死:

  • 表示空地,默认底色
  • #表示墙,玩家和箱子都不能进入
  • $表示箱子,可以被推动
  • .表示目标点,箱子最终要推进去的格子
  • @表示玩家
  • +表示玩家站在目标点上,该格同时具有玩家和目标两种属性
  • *表示箱子已经在目标点上

这个约定来自国际通行的 Sokoban 关卡格式,很多现成关卡资源都按它编码。后面做解析和渲染时,*和+这两个复合状态最容易被忽略,许多人在推箱子 java 实现里跑到一半发现“箱子明明在目标上但胜利判定不认”,多半就是没拆开这两个复合字符。所以从第一步解析开始,就要把“格子外观”和“格子属性”分开理解。

2.2 把字符画解析成 char[][],并记录玩家和箱子坐标

下面的解析代码,我按“一个类只负责地图数据”的思路拆分,玩家、箱子、目标点各自进入对应结构:

import java.util.ArrayList; import java.util.List; public class LevelData { private char[][] grid; private int playerRow; private int playerCol; private final List<int[]> boxes = new ArrayList<>(); private final List<int[]> targets = new ArrayList<>(); public void load(String[] lines) { int rows = lines.length; int cols = 0; for (String line : lines) { cols = Math.max(cols, line.length()); } grid = new char[rows][cols]; for (int i = 0; i < rows; i++) { java.util.Arrays.fill(grid[i], ' '); } boxes.clear(); targets.clear(); for (int i = 0; i < rows; i++) { String line = lines[i]; for (int j = 0; j < line.length(); j++) { char ch = line.charAt(j); if (ch == ' ' || ch == '#') { grid[i][j] = ch; } else if (ch == '$') { grid[i][j] = '$'; boxes.add(new int[]{i, j}); } else if (ch == '.') { grid[i][j] = '.'; targets.add(new int[]{i, j}); } else if (ch == '@') { grid[i][j] = '@'; playerRow = i; playerCol = j; } else if (ch == '*') { grid[i][j] = '*'; boxes.add(new int[]{i, j}); targets.add(new int[]{i, j}); } else if (ch == '+') { grid[i][j] = '+'; playerRow = i; playerCol = j; targets.add(new int[]{i, j}); } } } } public char getCell(int r, int c) { return grid[r][c]; } public void setCell(int r, int c, char ch) { grid[r][c] = ch; } public int getRows() { return grid.length; } public int getCols() { return grid[0].length; } public int getPlayerRow() { return playerRow; } public int getPlayerCol() { return playerCol; } public List<int[]> getBoxes() { return boxes; } public List<int[]> getTargets() { return targets; } }

这段代码的逻辑说明:先把二维数组按最大行宽初始化,避免某些行短导致后续访问越界。然后逐行逐字符扫描,遇到@就把玩家坐标记下来,遇到$把箱子坐标存进列表。特别要注意*和+:它们同时属于“箱子/玩家”和“目标”两类,必须同时写入 boxes/targets 列表和 grid 对应位置。参数上,坐标统一用int[] {row, col}存储,不用字符串拼接,因为后面排序、比较、快照都会用到坐标数值。

2.3 为什么坐标要单独存列表,而不是只改 grid 里的字符

新手常犯一个错:把箱子位置直接写死在 grid 里,每次移动就改 grid 上的字符,看起来省事,但一旦想做撤销、步数统计、自动求解,箱子的历史位置就得再从字符里“反向解析”一次。把箱子坐标单独存进 List<int[]>,grid 只保留当前渲染状态的字符,逻辑和界面就分开了。对比两种做法的差异:

数据方式移动时改动撤销难度求解器扩展
只改 char[][]查改 grid 两格需要拷贝整张地图状态转换靠拷贝地图
坐标列表 + grid 双写同时改列表和网格栈里存坐标快照状态直接用列表组合

实际工程里我会用双写:移动时先更新 boxes 列表,再根据箱子是否在目标点,决定原格和目标格写$还是*。渲染代码只读 grid,逻辑代码只改列表,谁都不越权。这里还有一个 Java 数据类型的细节:坐标用int[2]还是Point类?Point自带 hashCode 和 equals,放 HashSet 方便,但它是可变对象,存进历史栈后容易被后续修改污染;int[]没有值比较,放进集合要自己包装。所以我倾向于在状态快照里用int[]加clone(),把它当不可变数据用。

读到这你应该也发现了:先定数据结构、再做逻辑、最后上界面,这个顺序本身就是在练面向对象。地图类管地图,状态类管玩家和箱子,界面类只管绘制,三个类各干各的,后面加功能不会互相踩。Java 基础里的集合、数组操作在这里全都能用上,比如用 ArrayList 存箱子列表、用 Deque 做撤销栈,比硬背 Java 面试八股文记得牢得多。

3. 移动判定与状态更新:让箱子按照你的方向键走起来

3.1 方向映射与边界检查:数组越界异常为什么常发生在这里

先把方向变成坐标偏移量。我见过有人用 switch 写四个 if,也没问题;不过我习惯把方向收敛成一个动作入口。比较规范的写法是用枚举管理方向,这也是 Java 枚举类型使用的经典场景:

public enum Direction { UP(-1, 0), DOWN(1, 0), LEFT(0, -1), RIGHT(0, 1); final int dr; final int dc; Direction(int dr, int dc) { this.dr = dr; this.dc = dc; } }

枚举比硬编码数字安全得多:不会出现dir = 5这种越界值,而且每个方向自带坐标偏移量,后续 tryMove 逻辑只需要面向Direction编程。下面是移动判定主方法:

public class GameState { private final LevelData level; private int playerRow; private int playerCol; private final List<int[]> boxes; public GameState(LevelData level) { this.level = level; this.playerRow = level.getPlayerRow(); this.playerCol = level.getPlayerCol(); this.boxes = new ArrayList<>(); for (int[] b : level.getBoxes()) { boxes.add(b.clone()); } } public boolean tryMove(Direction dir) { int nr = playerRow + dir.dr; int nc = playerCol + dir.dc; // 第一次边界检查:防止 grid[nr][nc] 越界 if (nr < 0 || nr >= level.getRows() || nc < 0 || nc >= level.getCols()) { return false; } char next = level.getCell(nr, nc); if (next == '#') { return false; } // 前方是箱子或箱子占目标格 if (next == '$' || next == '*') { int boxR = nr + dir.dr; int boxC = nc + dir.dc; // 第二次边界检查:箱子要被推出地图外也不行 if (boxR < 0 || boxR >= level.getRows() || boxC < 0 || boxC >= level.getCols()) { return false; } char beyond = level.getCell(boxR, boxC); if (beyond == '#' || beyond == '$' || beyond == '*') { return false; // 箱子后面是墙或另一个箱子,推不动 } moveBox(nr, nc, boxR, boxC); } // 玩家走向新格子,旧格根据是否目标点恢复 '.' 或 ' ' level.setCell(playerRow, playerCol, isTarget(playerRow, playerCol) ? '.' : ' '); playerRow = nr; playerCol = nc; level.setCell(nr, nc, isTarget(nr, nc) ? '+' : '@'); return true; } private boolean isTarget(int r, int c) { for (int[] t : level.getTargets()) { if (t[0] == r && t[1] == c) { return true; } } return false; } }

这段代码的逻辑说明:先算玩家要走到的下一步坐标,如果下一步是墙就直接返回 false;如果下一步是箱子,则要再往前看一格,确认箱子后面能不能落位。这里最容易出问题的就是边界检查:只有next == '#'判断,没有先检查nr/nc是否在地图内,消息队列一到最后一行按方向键就直接触发 java 数组越界异常,这是推箱子 java 实现里最常见的翻车原因之一。

3.2 moveBox 与目标点恢复:记住*和+的存在

moveBox 的实现要同时维护 boxes 列表和 grid 渲染层:

private void moveBox(int fromR, int fromC, int toR, int toC) { for (int[] b : boxes) { if (b[0] == fromR && b[1] == fromC) { b[0] = toR; b[1] = toC; break; } } // 原格:如果本身是目标点,恢复成 '.' level.setCell(fromR, fromC, isTarget(fromR, fromC) ? '.' : ' '); // 新格:如果是目标点写成 '*' level.setCell(toR, toC, isTarget(toR, toC) ? '*' : '$'); }

这部分是“推箱子 java”的核心边界条件:箱子被推走后,它原来所在的格子不一定是空地,因为那个格子可能是目标点。如果不做 isTarget 判断,目标点就会被箱子污染成普通格子,后期完成判定必挂。箱子进入新格时同理:如果进入的是目标点,grid 要写成*而不是$,否则渲染层看不出箱子已归位。

完成判定我放在 GameState 里,逻辑非常直接:

public boolean isWin() { for (int[] t : level.getTargets()) { boolean covered = false; for (int[] b : boxes) { if (b[0] == t[0] && b[1] == t[1]) { covered = true; break; } } if (!covered) { return false; } } return true; }

注意这里只看 boxes 列表,不看 grid。之前有人把完成判定写成“扫描 grid 里*的数量等于目标点数”,这在玩家推进最后一步时没问题,但一旦玩家又把箱子从目标点推出来,grid 上*消失,判定又会变成未胜利,逻辑上没问题。真正坑的是只数*而不考虑箱子位置与目标点逐一对应,导致两个箱子互换位置后统计出错。用 boxes 和 targets 两个列表逐一比对,语义最清晰。

3.3 tryMove 为什么返回 boolean:为界面层和撤销功能留下钩子

tryMove 返回布尔值,不只是告诉外部“能不能走”,还方便界面层决定要不要刷屏、要不要计入步数。如果移动失败,步数不增加;移动成功,步数加一,同时把移动前的状态压入撤销栈。这个返回值的语义一定不能省略。后续写撤销功能时,每次尝试移动前先 pushSnapshot,移动成功后把快照保留,移动失败就把快照弹出丢掉,这样才不会有“撤销了一次没生效”的玄学问题。

4. 用 Swing 把推箱子从控制台搬到窗口:JPanel 重绘与方向键交互

4.1 按格子尺寸计算像素坐标:CELL_SIZE 与坐标映射

控制台里可以用字符一格一格渲染,换成 Swing 窗口后要把逻辑坐标换算成像素。我一般固定一个 CELL_SIZE,每个逻辑格对应一块正方形区域:

public class BoardPanel extends JPanel { private static final int CELL_SIZE = 48; private final GameState game; public BoardPanel(GameState game) { this.game = game; setPreferredSize(new Dimension( game.getLevel().getCols() * CELL_SIZE, game.getLevel().getRows() * CELL_SIZE )); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); LevelData level = game.getLevel(); for (int r = 0; r < level.getRows(); r++) { for (int c = 0; c < level.getCols(); c++) { int x = c * CELL_SIZE; int y = r * CELL_SIZE; char ch = level.getCell(r, c); switch (ch) { case '#': g.setColor(new Color(90, 60, 40)); g.fillRect(x, y, CELL_SIZE, CELL_SIZE); break; case '$': g.setColor(new Color(210, 150, 30)); g.fillRoundRect(x + 4, y + 4, CELL_SIZE - 8, CELL_SIZE - 8, 12, 12); break; case '*': g.setColor(new Color(210, 150, 30)); g.fillRoundRect(x + 4, y + 4, CELL_SIZE - 8, CELL_SIZE - 8, 12, 12); g.setColor(new Color(30, 200, 120)); g.fillOval(x + 14, y + 14, CELL_SIZE - 28, CELL_SIZE - 28); break; case '.': g.setColor(new Color(120, 120, 120)); g.fillOval(x + 14, y + 14, CELL_SIZE - 28, CELL_SIZE - 28); break; case '@': g.setColor(new Color(40, 80, 220)); g.fillOval(x + 8, y + 8, CELL_SIZE - 16, CELL_SIZE - 16); break; case '+': g.setColor(new Color(40, 80, 220)); g.fillOval(x + 8, y + 8, CELL_SIZE - 16, CELL_SIZE - 16); g.setColor(new Color(30, 200, 120)); g.fillOval(x + 14, y + 14, CELL_SIZE - 28, CELL_SIZE - 28); break; default: g.setColor(new Color(240, 235, 220)); g.fillRect(x, y, CELL_SIZE, CELL_SIZE); } } } } }

逻辑说明:paintComponent 每次重绘都会遍历整个二维数组,因此地图数据变化后只要 repaint 一次,画面就会按最新坐标刷新。CELL_SIZE 是这里最重要的参数,所有图形偏移量都围绕它换算;想做成窗口自适应缩放,可以在 setPreferredSize 之外再叠加比例计算,但小项目里固定 48 像素最省事。

4.2 键盘监听:KeyListener 挂哪里,方向键才会响应

Swing 的焦点机制很容易让新手栽跟头:KeyListener 加在 JFrame 上没反应,加在 JPanel 上还是没反应,原因多半是组件没有焦点。我的做法是明确把监听挂到 JPanel 上,并在窗口显示后主动请求焦点:

public class GameWindow extends JFrame { private final BoardPanel boardPanel; private final GameState game; public GameWindow(GameState game) { this.game = game; this.boardPanel = new BoardPanel(game); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setTitle("推箱子"); add(boardPanel); pack(); setLocationRelativeTo(null); // 关键三连:可聚焦、请求焦点、挂监听 boardPanel.setFocusable(true); boardPanel.requestFocusInWindow(); boardPanel.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { Direction dir = null; switch (e.getKeyCode()) { case KeyEvent.VK_UP: dir = Direction.UP; break; case KeyEvent.VK_DOWN: dir = Direction.DOWN; break; case KeyEvent.VK_LEFT: dir = Direction.LEFT; break; case KeyEvent.VK_RIGHT: dir = Direction.RIGHT; break; default: return; } if (game.tryMove(dir)) { boardPanel.repaint(); } if (game.isWin()) { JOptionPane.showMessageDialog(GameWindow.this, "你已把全部箱子推到目标点"); } } }); } public static void main(String[] args) { String[] map = { "#####", "#@$.#", "#####" }; LevelData level = new LevelData(); level.load(map); GameState game = new GameState(level); SwingUtilities.invokeLater(() -> { GameWindow window = new GameWindow(game); window.setVisible(true); window.boardPanel.requestFocusInWindow(); }); } }

这段代码里最重要的不是 switch,而是requestFocusInWindow()的调用时机。它必须在窗口 setVisible(true) 之后调用,否则窗口还没显示,焦点请求被忽略,按键自然没反应。把窗口显示动作放进SwingUtilities.invokeLater是为了保证在事件分发线程上创建界面,避免线程安全问题。

4.3 repaint 与重绘机制:按一次方向键,画面变化多少

很多人会把 repaint 理解成“立刻调用 paintComponent”。实际上 repaint 只是向 Swing 的事件队列发一个重绘请求,真正绘制发生在后续的事件分发循环中。对推箱子这种低频交互游戏没有影响,但如果做自动演示、动画播放,就要注意连续 repaint 可能被合并为一次,动画看起来一跳一跳。正确做法是启动一个 Swing Timer,按固定间隔刷新。这个知识点经常出现在 Java 面试题里,能和动画、游戏循环关联起来讲,会比单纯背概念有说服力。

5. 推箱子 java 常见问题排查:五个高频踩坑与解法

5.1 箱子离开目标点后,目标点凭空消失

现象:箱子推到目标点上显示成*,再把它从目标点推走,原来的格子变成普通空地,目标点没了,最终胜利判定永远不触发。

原因:使用单个 char 网格存储所有状态,移动箱子时没有检查原格是否也是目标点。箱子离开后,原格被直接写成空格' ',覆盖了目标点的标识。

解决:在 moveBox 里对原格和目标格分别做 isTarget 判断。原格是目标点就写成.,不是就写空格;新格是目标点就写成*,不是就写$。我在 3.2 节给出的 moveBox 已经处理了这个。写代码时养成“格子状态要有依据:目标点列表为准”的习惯,凡是目标点,渲染时永远优先恢复目标符号。

5.2 方向键没反应:焦点根本没在画布上

现象:窗口正常显示,地图也画出来了,但按方向键完全没反应;有些机器上点击一下画面才恢复响应。

原因:Swing 的键盘事件只会派发给当前拥有焦点的组件。JFrame 本身默认不可聚焦,JPanel 默认可聚焦但焦点可能在别的控件上,或者你用了addKeyListener但没设置组件可聚焦。

解决:按 4.2 的顺序处理:setFocusable(true)加在 BoardPanel 上,窗口显示后主动调用requestFocusInWindow()。更稳妥的办法是使用 Swing Key Bindings,把按键绑定到 JPanel 的WHEN_IN_FOCUSED_WINDOW条件,这样即使焦点不在画布上,只要焦点还在窗口内就能触发。工作量大一点,但手感稳定:

boardPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke("pressed UP"), "moveUp"); boardPanel.getActionMap().put("moveUp", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { if (game.tryMove(Direction.UP)) { boardPanel.repaint(); } if (game.isWin()) { JOptionPane.showMessageDialog(boardPanel, "通关!"); } } });

这里用pressed UP而不是UP,是为了区分“按下瞬间”和“按住不放”的重复事件。若想做按住连续移动,就用getKeyStroke("UP")配合 Timer 控制移动间隔,否则连击速度取决于操作系统按键重复率,不稳定。

5.3 数组越界异常:只在朝地图边缘移动时报错

现象:正常推箱子到中间都没问题,一旦把玩家或箱子推到地图边缘并继续按方向键,控制台抛出 java 数组越界异常。

原因:tryMove 里判断grid[nr][nc]前没有检查nr和nc是否在合法范围内。当地图最上面一行是墙时还好,一旦最上面一行或最边缘是空地,nr可能变成 -1,直接访问负数下标必定崩溃。

解决:把所有对 grid 的下标访问都收口到带边界检查的方法中,或者在 tryMove 开头加守卫条件:

private boolean outOfBounds(int r, int c) { return r < 0 || r >= level.getRows() || c < 0 || c >= level.getCols(); }

之后每次处理新坐标,第一件事就是if (outOfBounds(nr, nc)) return false;。这段代码我在 3.1 节已经放过,排查时先检查 tryMove 里是不是所有分支都过了一次边界校验,而不是只在撞墙分支里校验。Java 的数组越界异常很常见,养成“访问数组前先验下标”的习惯,这类问题基本清零。

5.4 中文注释和窗口标题乱码

现象:代码里写的中文注释变成乱码,编译时提示“编码 GBK 的不可映射字符”,窗口标题也显示成问号。

原因:Windows 中文环境默认使用 GBK 编码读取 .java 文件,而源代码文件是 UTF-8 保存的。javac 编译时没指定编码,误按 GBK 解析 UTF-8 字节流,中文字符自然被破坏。

解决:统一编码。IDE 里把项目编码设为 UTF-8,命令行编译时显式加参数:

javac -encoding UTF-8 SokobanGame.java java SokobanGame

如果 javac 找不到,先检查 Java 环境变量配置是否完整,PATH 有没有指向 JDK 的 bin 目录。另一个容易被忽略的点:Windows 控制台输出中文乱码,是控制台代码页问题,可以在运行前执行chcp 65001切换到 UTF-8 代码页。这个小坑在多人协作时尤其明显,代码提交时也要保证 Git 的 core.autocrlf 和 encoding 配置一致,否则换行符和编码一起出问题。

5.5 撤销功能踩坑:快照存的是引用,不是拷贝

现象:做了撤销功能,按撤销键后地图并没有回到上一步;继续操作几次后,撤销栈里的状态全变成当前状态。

原因:每次移动前把 boxes 列表的引用直接放进撤销栈,而不是复制一份。移动时通过同一个引用修改了箱子坐标,栈里保存的“历史快照”跟着被改,历史就变成当前状态的复制品。这是 Java 对象拷贝方向最容易出错的点。

解决:保存快照时做浅拷贝加元素克隆。箱子坐标是 int[],需要逐元素克隆:

public class Snapshot { final int playerRow; final int playerCol; final List<int[]> boxes; public Snapshot(GameState game) { this.playerRow = game.getPlayerRow(); this.playerCol = game.getPlayerCol(); this.boxes = new ArrayList<>(); for (int[] b : game.getBoxes()) { boxes.add(b.clone()); } } }

这就是 Java 对象深度拷贝的典型场景:只复制 ArrayList 外壳不够,内部每个 int[] 也要 clone,否则新旧列表共享同一批坐标数组。恢复快照时同理,从栈里弹出快照后要把坐标再克隆回当前 boxes 列表,避免下一步移动又污染快照。真正写撤销时,用 Deque 当栈,push保存、pop恢复,一步到位。

6. 进阶验证:用 BFS 检查关卡可解性,并在 main 里做自检

6.1 给每个箱子建立状态空间:BFS 搜索关卡是否可解

推箱子写到最后一个绕不开的问题是:我怎么知道自己设计的关卡一定解得了?尤其从网上下载的关卡,看似对齐了符号,实际配置可能无解。一个很实用的验证手段是写一个 BFS 搜索:把“玩家坐标 + 所有箱子坐标”看作一个状态,从初始状态出发枚举每一步移动,直到找到某个状态满足所有目标点被占用,或者状态空间被遍历完。核心循环如下:

public boolean isSolvable(GameState game) { State start = new State(game.getPlayerRow(), game.getPlayerCol(), game.getBoxes()); Set<State> visited = new HashSet<>(); Queue<State> queue = new LinkedList<>(); queue.offer(start); visited.add(start); while (!queue.isEmpty()) { State cur = queue.poll(); if (allOnTargets(cur, game.getLevel())) { return true; } for (Direction dir : Direction.values()) { State next = simulate(cur, dir, game.getLevel()); if (next != null && visited.add(next)) { queue.offer(next); } } } return false; }

这段代码的关键在 State 的 equals 和 hashCode,必须基于玩家坐标和排序后的箱子坐标生成,否则 HashSet 去重无效,BFS 会陷入指数级爆炸。对 10×10 以内的小关卡,BFS 能在秒级返回结果;更大关卡需要引入 A* 或死锁剪枝,否则状态空间会非常大。这种“状态压缩 + 广度优先搜索”的套路,在蓝桥杯 Java 组和面试算法题里也经常出现,做完推箱子再去写这类题会轻松很多。

6.2 在 main 方法里做自检断言:不给 JVM 留默认关闭断言的后悔药

除了 BFS,我习惯在每个关卡加载后做基础自检:箱子数量必须等于目标点数量、初始状态不应直接胜利、每个箱子坐标都在地图内。用 assert 要小心,Java 默认关闭断言,写成普通 if 条件更可靠:

public static void selfCheck(GameState game) { if (game.getLevel().getBoxes().size() != game.getLevel().getTargets().size()) { throw new IllegalStateException("箱子与目标点数量不一致"); } if (game.isWin()) { throw new IllegalStateException("初始状态已经是胜利状态"); } System.out.println("自检通过,当前关卡可正常启动"); }

我在做推箱子 java 项目时还习惯把 BFS 验证也放进 main 里跑一次,关卡加载完就告诉自己是可解还是无解。这个习惯帮我挡掉过不少手工测试发现不了的死锁关卡。遇到设计上确实有问题的地图,优先去调墙体和箱子排列,而不是靠玩家硬试。希望这份落地整理的思路对你也有用,照着这个路径把地图解析、移动判定、Swing 渲染和撤销栈串起来,你得到的会是一个结构清晰、能继续扩展的 Java 小游戏作品。

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

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

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

立即咨询