
简介一份基于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 Listint[] boxes new ArrayList(); private final Listint[] 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 Listint[] getBoxes() { return boxes; } public Listint[] getTargets() { return targets; } }这段代码的逻辑说明先把二维数组按最大行宽初始化避免某些行短导致后续访问越界。然后逐行逐字符扫描遇到就把玩家坐标记下来遇到$把箱子坐标存进列表。特别要注意*和它们同时属于“箱子/玩家”和“目标”两类必须同时写入 boxes/targets 列表和 grid 对应位置。参数上坐标统一用int[] {row, col}存储不用字符串拼接因为后面排序、比较、快照都会用到坐标数值。2.3 为什么坐标要单独存列表而不是只改 grid 里的字符新手常犯一个错把箱子位置直接写死在 grid 里每次移动就改 grid 上的字符看起来省事但一旦想做撤销、步数统计、自动求解箱子的历史位置就得再从字符里“反向解析”一次。把箱子坐标单独存进 Listint[]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 Listint[] 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 Listint[] 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()); SetState visited new HashSet(); QueueState 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 小游戏作品。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。