☰
基于JAVA的华容道小游戏:从棋盘建模到BFS自动求解
2026/10/14 1:45:25 网站建设 项目流程

简介:基于JAVA的华容道小游戏是面向Java初学者的图形界面练手项目,重点覆盖Swing组件布局、事件监听、二维数组棋盘建模、棋子移动判定及胜利条件检测等核心知识点。压缩包仅含3个java源文件,大小2KB,分别对应游戏主窗口、棋子对象与核心逻辑类,结构精简,便于逐行研读与二次改造。已有414人学习下载,对于自学编程或课程设计都具备参考价值。通过该资源可掌握JFrame构建主窗口、JPanel绘制棋盘、ActionListener响应点击事件,以及如何用二维数组维护棋盘状态;在棋子移动过程中,还需处理边界判断、障碍物阻挡、动画刷新与非法操作提示等细节,能够串联起面向对象设计、事件驱动编程和基础算法思维。整体代码量小,却完整演示了从界面搭建到游戏闭环的常规流程,适合初学者模仿并扩展出更多玩法。

1. 基于JAVA的华容道小游戏,先别急着拖Swing控件

很多人接到“基于JAVA的华容道小游戏”这个题,第一反应是把窗口拖出来,往上面摆按钮。但某开发者当初先花两小时写了纯控制台的移动逻辑,再花一天接界面,最后交付得反倒最快;另一批同学边画界面边补规则,连曹操“怎么算逃出去”都写错了。这个标题真正要练的不是画界面,而是用JAVA把4×5棋盘上的二维状态变化抽象成数据,再用事件驱动把鼠标动作翻译成合法移动。适合想做课程设计或入门游戏开发的人,也适合想把集合、队列和搜索算法练熟的人。

2. 棋盘建模与移动判定:先把规则写成小白都能看懂的代码

2.1 二维数组还是棋子对象:我选了双结构

如果用int[][] board记录每个格子属于哪个棋子,移动时要把整块矩形区域重写,麻烦且容易出残影。我更建议双结构:一个List<Piece>存棋子对象,一个boolean[4][5]存占用标记。对象负责“谁是谁”,数组负责“能不能走”。

public class Piece { int id; // 1=曹操, 2~5=将领, 6~20=小兵 int width; // 2或1 int height; // 2或1 int x, y; // 左上角列坐标和行坐标 Color color; // 渲染用 public Piece(int id, int width, int height, int x, int y, Color color) { this.id = id; this.width = width; this.height = height; this.x = x; this.y = y; this.color = color; } }

id不是必须从1连续编号,但建议按规则分配:曹操固定id=1;将领可以是id=2~5,小兵id=6往后。这样后面做胜负判定和求解时,不需要每次都比较width和height。width和height分开存,是为了区分“竖将”和“横将”,比如一个竖将占1列2行,一个横将占2列1行。

占用数组的刷新逻辑:

public class Board { List<Piece> pieces = new ArrayList<>(); boolean[][] occupied = new boolean[4][5]; public void rebuildOccupied() { for (boolean[] row : occupied) { Arrays.fill(row, false); } for (Piece p : pieces) { for (int i = p.x; i < p.x + p.width; i++) { for (int j = p.y; j < p.y + p.height; j++) { occupied[i][j] = true; } } } } }

棋盘坐标我用“x是列、y是行”,左上角是(0,0),右下角是(3,4)。这一点必须在整个项目里统一,否则画界面、判胜负、求解器各写各的,最后一定有一个方向是镜像的。每次移动后都调用一次rebuildOccupied(),简单粗暴,但数据量很小,完全够用。

| 参数 | 含义 | 取值范围 | | x | 棋子左上角的列坐标 | 0~3,且x+width不能超过4 | | y | 棋子左上角的行坐标 | 0~4,且y+height不能超过5 | | width/height | 棋子占几列/几行 | 曹操2/2,将领1/2或2/1,小兵1/1 |

有人问我为什么不用一维数组,其实一维数组也能做,就是当棋子从左往右走时,坐标换算是x = index % 4,y = index / 4,容易在边界判断上写错,我个人觉得二维写法更直白。

注意:坐标(x,y)全篇统一为“列x、行y”,Swing的x是横坐标、y是纵坐标,翻译成屏幕坐标时是px = x * SIZE,py = y * SIZE,别弄反。

2.2 canMove():出界只是第一关,第二关是“落点占用”

移动一个棋子前要做两个检查:新区域是否出界、新区域是否被别的棋子占住。新手只做第一个,就会出现棋子互相穿过的情况。

public boolean canMove(Piece p, int dx, int dy) { int nx = p.x + dx; int ny = p.y + dy; if (nx < 0 || ny < 0 || nx + p.width > 4 || ny + p.height > 5) { return false; // 第一步:出界直接拒绝 } // 第二步:把p自己从占用标记里临时摘掉 for (int i = p.x; i < p.x + p.width; i++) { for (int j = p.y; j < p.y + p.height; j++) { occupied[i][j] = false; } } for (int i = nx; i < nx + p.width; i++) { for (int j = ny; j < ny + p.height; j++) { if (occupied[i][j]) { return false; // 落点被别的棋子占了 } } } return true; }

这里最容易被忽略的是第二步的“把自己摘掉”。左移一格时,目标区域和当前区域有重叠,重叠部分属于自己,如果仍算占用,任何左移都会被误判为false,表现就是“曹操只能右移不能左移”。调用canMove的时机是在移动之前,所以棋盘占用数组还是旧状态,必须先清掉旧位置的格子,再检查新位置。

dx和dy只能传四个方向向量之一:{-1,0}、{1,0}、{0,-1}、{0,1}。华容道不允许斜向移动,也不允许一次跳两格,这是规则边界。如果你想把“横刀立马”这类经典开局做成关卡,移动判定这一层不需要改,只要改初始pieces列表就行。

2.3 胜利条件与步数统计:把“逃出”翻译成两个等号

曹操是2×2,棋盘宽4列、高5行,出口开在底边中间两列。也就是说,曹操的左上角走到(1,3)时,它覆盖的四个格子正好是(1,3)、(2,3)、(1,4)、(2,4),底边中央出口被它堵满,这就是胜利。

public boolean isWin() { for (Piece p : pieces) { if (p.id == 1) { return p.x == 1 && p.y == 3; } } return false; } public boolean movePiece(Piece p, int dx, int dy) { if (canMove(p, dx, dy)) { p.x += dx; p.y += dy; moveCount++; rebuildOccupied(); return true; } return false; }

moveCount是玩家有效操作次数,每次成功移动才加一。有些实现把“把一块2×1的将领横移一格”算两步,那是按“格子步”算的,玩家看不懂。游戏里显示玩家步数,求解器里单独算搜索步数,两者不要混在一个变量里。

isWin()每次移动后调用即可。注意不要写成用List.indexOf之类的方式找曹操,id是最稳定的标识。把棋子按id排好序存放,后面做状态指纹时会省很多事。

3. Swing界面这层皮,怎么缝才不显得廉价

3.1 用JPanel当画布,paintComponent里一次画完

不要用一堆JButton摆成棋盘,那样布局、选中状态、移动动画都非常难控制。我一般定义一块200×250或320×400的画布,根据格子大小计算。用4×5,CELL_SIZE=80,画布就是320×400。

public class BoardPanel extends JPanel { private Board board; private static final int SIZE = 80; public BoardPanel(Board board) { this.board = board; setPreferredSize(new Dimension(4 * SIZE, 5 * SIZE)); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; // 背景 g2.setColor(new Color(40, 42, 48)); g2.fillRect(0, 0, getWidth(), getHeight()); for (Piece p : board.pieces) { g2.setColor(p.color); g2.fillRoundRect(p.x * SIZE, p.y * SIZE, p.width * SIZE, p.height * SIZE, 10, 10); g2.setColor(Color.WHITE); g2.setFont(new Font("SansSerif", Font.BOLD, 18)); g2.drawString(p.name, p.x * SIZE + SIZE / 4, p.y * SIZE + SIZE / 2 + 6); } } }

paintComponent里只做渲染,不要放业务逻辑。比如不要在里面调用board.movePiece,更不要做文件读写。Swing的重绘机制是“由系统触发”,repaint()之后可能会合并多次调用来批量绘制,你在paintComponent里做状态变更,会出现一帧多次移动的灵异现象。

绘制时以棋子坐标乘以格子大小即可,坐标单位是“格”,渲染单位是“像素”,两者换算关系集中在paintComponent里。拖动过程中频繁调用repaint,现代硬件没压力,真正卡顿多半是重绘了整个窗口的无关区域。

想让界面更有质感,还可以在选中棋子时给它加一个描边,或者对“曹操”单独放大字体。这些都属于渲染层的细节,动了不会影响逻辑,新手可以放心试。

3.2 鼠标按下与释放,比拖动中实时移动更稳

实现拖拽有很多种姿势。我以前用mouseDragged里实时改变棋子坐标,结果发现:玩家手一抖,棋子从第2列直接跳到第1列,撤销逻辑也乱。后来改成按下时选中、释放时判定方向:

public class GamePanel extends JPanel implements MouseListener { private Board board; private Piece selected; @Override public void mousePressed(MouseEvent e) { if (SwingUtilities.isLeftMouseButton(e)) { int gx = e.getX() / BoardPanel.SIZE; int gy = e.getY() / BoardPanel.SIZE; selected = board.findPieceAt(gx, gy); } } @Override public void mouseReleased(MouseEvent e) { if (selected == null || !SwingUtilities.isLeftMouseButton(e)) { return; } int dx = e.getX() / BoardPanel.SIZE - selected.x; int dy = e.getY() / BoardPanel.SIZE - selected.y; if (Math.abs(dx) >= Math.abs(dy)) { board.movePiece(selected, Integer.signum(dx), 0); } else { board.movePiece(selected, 0, Integer.signum(dy)); } selected = null; repaint(); } }

mousePressed里通过坐标取模换算格子坐标:getX()除以格子大小再取整。findPieceAt要遍历pieces,判断(gx, gy)落在哪个棋子的矩形范围内,注意优先返回大块的棋子,比如曹操和小兵重叠判断时,曹操包含(1,3),小兵不可能同时站在曹操的格子里,所以顺序无所谓。

释放时用位移量决定移动方向,而不是用“拖到哪个格子”,这样玩家按下去不移开、原地松开,就不会误触发移动。这也呼应了标题重点:JAVA小游戏练的就是“输入到状态变化”的映射,映射写得干净,后面加什么功能都不慌。

这里还有个小细节:连续快速拖动时,mouseReleased里的坐标换算可能因为EventQueue积压事件而拿到旧坐标。解决办法是记录按下时的像素坐标,在释放时算差值,而不是重新除以格子大小。棋盘这么小,多数情况下不处理也没事,但如果你想做“丝滑拖拽”,这一点值得记住。

3.3 新游戏、计时器、步数面板的联动

把棋盘面板和侧边信息面板放在不同容器里,用BorderLayout或GridBagLayout布好。新游戏按钮重置棋子位置、步数为零、秒表归零。

JButton newGameBtn = new JButton("新游戏"); JLabel stepLabel = new JLabel("步数:0"); JLabel timeLabel = new JLabel("时间:0 s"); Timer timer = new Timer(1000, e -> { elapsed++; timeLabel.setText("时间:" + elapsed + " s"); }); timer.start(); newGameBtn.addActionListener(e -> { board.loadClassicLayout(); // 重置为经典布局 moveCount = 0; elapsed = 0; stepLabel.setText("步数:0"); boardPanel.repaint(); });

javax.swing.Timer是EDT上的定时器,回调也跑在事件线程,更新JLabel是安全的。elapsed秒数最好在重置方法里清掉,同时timer不用停,一直走就行。计时逻辑和步数逻辑都放在“外部按钮事件”里,不让棋盘panel内部自己处理,这样职责更清晰。新手最容易把“重置”逻辑写进paintComponent,再次强调不要在绘制方法里改状态。

4. 提示按钮与自动求解:从BFS到A*的取舍

4.1 状态指纹:把整个棋盘压成一个字符串

要让搜索算法判断“这个局面见过没有”,需要一个稳定且唯一的局面表示。常见做法是“棋子id+坐标”拼接成字符串。

public String fingerprint() { StringBuilder sb = new StringBuilder(); List<Piece> sorted = new ArrayList<>(pieces); sorted.sort(Comparator.comparingInt(p -> p.id)); for (Piece p : sorted) { sb.append(p.id).append(':').append(p.x).append(',').append(p.y).append(';'); } return sb.toString(); }

这里有个细节:如果两个小兵长得一样,交换它们的位置不该算新状态。所以我建议把相同尺寸的小兵坐标排序后再拼,或者保留id排序。如果你让每个小兵id都不同,BFS状态数会明显变大。另一个坑是坐标用逗号分隔时,x和y都可能是个位数,不必补齐,但分隔符不能省略,否则(12,3)和(1,23)会混淆。

4.2 BFS:从当前局面向外扩展,直到碰到底线

BFS保证第一次找到胜利状态时的路径最短,但代价是要把所有可能局面都展开。经典“横刀立马”开局在BFS下状态量大约在几十万级别,JAVA跑起来也就一两秒,可以接受。

写BFS前需要一个Board.copy()方法。我一般new一个Board,遍历原pieces,逐个new Piece并复制坐标和尺寸,不能直接共享对象引用,因为搜索时每扩展一步都要修改坐标。

public List<String> bfsSolution() { Queue<Board> queue = new LinkedList<>(); Map<String, String> parent = new HashMap<>(); Map<String, String> moveDesc = new HashMap<>(); String startKey = fingerprint(); parent.put(startKey, null); queue.offer(this); int maxStates = 200000; int expanded = 0; while (!queue.isEmpty()) { Board cur = queue.poll(); expanded++; if (expanded > maxStates) return null; if (cur.isWin()) { return buildPath(parent, moveDesc, startKey, cur.fingerprint()); } for (Piece p : cur.pieces) { for (int[] dir : DIRS) { if (cur.canMove(p, dir[0], dir[1])) { Board next = cur.copy(); Piece np = next.findPiece(p.id); np.x += dir[0]; np.y += dir[1]; next.rebuildOccupied(); String nk = next.fingerprint(); if (!parent.containsKey(nk)) { parent.put(nk, cur.fingerprint()); moveDesc.put(nk, p.id + " 向 " + dirName(dir) + " 移动"); queue.offer(next); } } } } } return null; }

参数说明:maxStates是搜索上限,防止无解局面把线程卡死。移动生成顺序会影响找到解的速度,但不会影响解的最短性。buildPath从终态沿parent回溯到起点,再把moveDesc反过来排好,就是给玩家看的移动序列。

BFS在提示功能上有个实际问题:玩家看到最短路径,但玩家不能一次只显示一步,否则就成了“AI代打”。常见做法是显示前三步,里面提示从哪块棋子开始移动,剩下的交给玩家自己思考。这样也保留了“华容道”的益智乐趣。

4.3 A*:启发式搜索在自定义关卡里的兜底

BFS的硬伤是状态多、手动跳过的空间少。A*在f = g + h中引入对剩余步数的估计,可以更快找到目标。华容道常用的h是曹操左上角到出口位置的曼哈顿距离:

public int h(Board b) { Piece cao = b.findPiece(1); return Math.abs(cao.x - 1) + Math.abs(cao.y - 3); }

这个h满足可采纳性:曹操不管怎么绕路,至少要走这么多格才能让左上角到达(1,3)。但华容道真正费步数的地方是清空曹操下方的通道,这个h完全没反映阻塞,所以有些局面A比BFS展开的节点还多。我的做法是先用BFS带上限跑一遍,超上限再换A,两条路都出不来就提示“这个布局可解性存疑”。

A*的代码骨架:

PriorityQueue<SearchNode> open = new PriorityQueue<>( Comparator.comparingInt(n -> n.f) ); Map<String, Integer> gScore = new HashMap<>(); String sk = fingerprint(); open.add(new SearchNode(this, 0, h(this))); gScore.put(sk, 0); while (!open.isEmpty()) { SearchNode cur = open.poll(); if (cur.board.isWin()) { return buildPathFromNode(cur); } for (Piece p : cur.board.pieces) { for (int[] dir : DIRS) { if (cur.board.canMove(p, dir[0], dir[1])) { Board next = cur.board.copy(); next.shiftPiece(p.id, dir[0], dir[1]); String nk = next.fingerprint(); int ng = cur.g + 1; if (ng < gScore.getOrDefault(nk, Integer.MAX_VALUE)) { gScore.put(nk, ng); open.add(new SearchNode(next, ng, ng + h(next))); } } } } }

注意SearchNode里除了board、g,还要存prev引用,用来回溯路径。open的重复更新问题在这里简单处理:gScore是全局最优,只有更优才重新入队。因为棋盘空间有限,内存占用不会太夸张。

实际使用中,我的默认策略是“提示”按钮先请求BFS,如果BFS在20万节点内没结果,再切A*并发消息给玩家“正在尝试更深的搜索”。玩家对等待时间很敏感,超过两秒就应该显示“先换个人工布局”。

5. 华容道避坑实录:五条翻车现场与修复方案

5.1 棋子穿模:canMove只检查出界,不检查落点

现象:游戏里两个棋子能叠在一起,曹操推过去,张飞的格子被覆盖,画面像重影。

原因:canMove只做了nx/ny边界判断,没检查目标区域占用。有的实现只检查目标格子左上角是否为空,漏掉了2×2棋子右下角。

解决:占用数组先剔掉自身,再对目标矩形逐格检查。曹操向右移动时,必须检查(2,3)(3,3)(2,4)(3,4)四个格子。忘了“先剔自身”的表现则是只能向右下移动、左上永远失败。

5.2 界面残影:重绘只画了移动的棋子

现象:棋子移动后,原位置留下一个色块,刷新几次才消失。

原因:paintComponent里没有先把整块面板清干净,或者只在移动分支里调用repaint的一块区域。

解决:每个绘制周期先调用super.paintComponent(g)全量重绘,再把所有棋子遍历一遍。Swing本身默认双缓冲,不需要手动擦除,你只需要保证每一次绘制都从空白背景开始。

5.3 鼠标点击无反应:监听器加到了错误的面板

现象:鼠标点棋盘完全没反应,但按钮可以点;或者第一次点击没反应,第二次才有。

原因:监听器加到了某个透明的容器上,实际接收点击的是覆盖在上面的子面板;或者重写了contains方法但没调用父类。

解决:把MouseListener加到真正画棋子的那个BoardPanel上。如果布局里又把信息面板叠在上面,让它setOpaque(false),并且不要盖住棋盘区域。用GlassPane做拖拽提示时,记得把事件转发到下层组件。

5.4 求解结果比手工最优还长:状态指纹合并了不该合并的布局

现象:BFS给出的路径,玩家自己走反而能更短。

原因:把同尺寸小兵的坐标排序后拼接,会让两个实际上不同的棋盘因小兵排列相同而变成同一个状态。严格说,如果小兵长得完全一样,交换位置本不改变体验,但会影响特定布局下的可达性判断,导致搜索漏掉路径。

解决:在求解器中给每个小兵一个隐藏的seq编号,指纹时按seq归一化顺序,而不是按坐标排序。可以再用“正方形小兵坐标排序+侧影校验”做二次判断。华容道求解对“等价状态”的定义要非常小心。

5.5 随机开局生成“死局”:玩家一上来就动不了

现象:点“新游戏”生成了随机棋子布局,曹操周围的空格全被围死,游戏没法玩。

原因:随机摆放只保证了“不重叠”,没保证“有解”。

解决:生成布局后先调一次BFS,限制节点数,在1万节点内找到解才保留这个开局,否则重新生成。经典布局“横刀立马”本身是公认可解的,我一般把经典布局作为默认“新游戏”,随机生成放在“挑战模式”里并附上“可解性检查”的提示。

6. 还能往上加什么料:自定义关卡片、步数回放与局部重绘

提示按钮和BFS已经让游戏“能玩”,但离“耐玩”还差几步。我一般会再加三样东西:自定义关卡、回放和更细致的重绘优化。

自定义关卡用文本描述最稳。一行一个棋子,格式是id width height x y。加载时读到这些参数,直接构造Piece列表。这样玩家可以自己排出“十字阵”“横刀立马变体”,求解器不必改一行代码,因为状态指纹和BFS都只依赖棋盘内容。

步数回放要一个操作栈。每次movePiece成功,把(pieceId, dx, dy)压栈;“悔棋”按钮弹栈,按反方向移动并递减步数。注意回放时不能直接调movePiece,否则会再次触发步数统计和胜利判断。这个细节不写对,玩家悔棋两次游戏就卡死。

重绘优化方面,Swing默认双缓冲,不需要自己实现缓冲。真正有效的优化是减少无意义的repaint调用,比如把鼠标拖动中的实时刷新改成忽略重复事件,或者只在mouseReleased后统一重绘。棋盘只有20格,全量重绘每秒几十次毫无压力,所以“性能瓶颈”在JAVA华容道里多数是自己吓自己。

我现在写游戏逻辑的习惯是:先用纯命令行的main方法把移动和胜负跑一遍,再写Swing。别觉得这多此一举,五成以上的“界面玄学”问题,最后查出来都是逻辑层没有独立验证。界面只是把棋盘状态画出来,状态对了,界面自然就对了。希望帮到你。

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

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

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

立即咨询