尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java实战:从零实现数字华容道与石头迷阵源码解析

Java实战:从零实现数字华容道与石头迷阵源码解析 简介一份面向Java初学者的课程设计实战项目以数字华容道又称石头迷阵游戏为载体完整涵盖从界面绘制到游戏逻辑判定的核心环节适合用于Java GUI、事件监听、二维数组与状态管理等知识点综合训练。压缩包共22个文件以18张png图片素材为主配合3个Java源文件与1个IDEA模块配置文件整体仅372KB结构简洁便于快速导入和定位核心代码。目前已有1014人学习下载经过较多学习者检验。资源内含可运行的完整源代码图片资源已分类放入image目录工程内按包名组织清晰可直接在IntelliJ IDEA中打开运行。通过阅读源码可了解数字块随机打乱、移动判定、胜利检测及界面刷新等常见游戏开发思路对完成Java课程设计或进阶学习小游戏开发都有实际参考价值。 花了一个周末用 Java 把一个数字华容道从零写到了能玩的版本过程中最让我意外的是我以为写个 3x3 的拼图游戏挺简单结果第一版打完却发现有些牌局天生就是无解的怎么排都差一步。后来才搞清楚是“随机排列”生成棋盘惹的祸。这篇就用完整的 Java 项目实战来拆解数字华容道 / 石头迷阵游戏的源码实现从棋盘模型、打乱算法到控制台版和 Swing 图形界面版全讲清楚适合学完 Java 基础想找项目练手的同学也适合面试前想快速补一个“有算法含量”的小项目的朋友。1. 为什么我推荐“数字华容道”作为 Java 实战入门项目很多初学者学完变量、循环、数组、面向对象之后最大的困惑不是语法不会而是“不知道拿这些语法做什么”。做一个图书管理系统吧全是增删改查绕不开数据库做一个贪吃蛇吧又涉及线程和键盘监听新手容易一头扎进细节里。数字华容道这个项目最大的优点是它不需要任何第三方库一个 JDK 就够但麻雀虽小五脏俱全。它几乎把 Java 基础里的核心知识点都串了一遍用数组存储棋盘状态用一维数组还是二维数组本身就是一道设计题移动和打乱依赖集合框架比如ArrayList、Random还能练到边界判断面向对象在这里不再是课本概念你会自然地把棋盘、绘制、逻辑拆成不同类或方法图形界面版引入 Swing 的事件监听属于 Java 桌面开发里最典型的“点击-刷新”模式理解了它以后写任何图形工具都用得上。至于标题里的“数字华容道 / 石头迷阵”两种叫法其实是对同一个游戏的不同包装。数字华容道通常指棋盘上排列 1 到 8或 1 到 15的数字块而“石头迷阵”这类变体一般是把数字块换成图片碎片或主题图标本质逻辑完全一样。也就是说只要把数字的布局逻辑写稳后面想换成石头图案就是改渲染层的事核心算法一行都不用动。我做这个项目时的定位也很明确先出一个纯控制台版本把逻辑跑通再做一个 Swing 图形界面版让游戏真正“拿得出手”。这样既不会一开始就被界面细节拖住又能在最后获得实实在在的成就感。就算你是零基础只要数组的基础过关跟着往下写就没有太大障碍。2. 棋盘数据模型与移动逻辑先想清楚再动手2.1 为什么我选择一维数组而不是二维数组新手最容易直接写int[][] board new int[3][3]这样确实直观但是做移动判定的时候你得频繁通过row offset去计算目标格子写起来反而不够顺手。我最终用的是一维数组 索引换算行列的方案。以 3x3 棋盘为例数组长度为 9数字 0 表示空格其余位置放 1 到 8下标: 0 1 2 3 4 5 6 7 8如果要找下标为index的元素所在行和列只需要int row index / 3; int col index % 3;反过来已知行列也可以快速得到下标row * 3 col。这个换算规则在移动判定里面极其重要。我选择一维数组的核心原因是数字华容道里的移动本质上就是“空格”和相邻数字交换下标用一维数组左右相邻就是下标差 1上下相邻就是下标差 3棋盘宽度代码写起来非常短。而且如果你后续想把这个项目从 3x3 扩展到 4x4、5x5只需要把数组长度和行列宽度的计算方式改一下模型逻辑不用推翻重来。2.2 移动判定先看“相邻性”再看“交换”用户点击或输入一个数字后程序要判断这个数字能不能移动。我总结为一个更朴素的规则只能移动与空格相邻的格子上的数字也就是目标数字所在的位置与空格下标之间的曼哈顿距离必须等于 1。用代码来写就是public boolean move(int index) { int emptyRow emptyIndex / col; int emptyCol emptyIndex % col; int targetRow index / col; int targetCol index % col; // 曼哈顿距离等于 1 才表示相邻否则不合法 if (Math.abs(emptyRow - targetRow) Math.abs(emptyCol - targetCol) ! 1) { return false; } // 交换空格和目标的数字 board[emptyIndex] board[index]; board[index] 0; emptyIndex index; return true; }这段代码不需要知道用户想“上滑”还是“下滑”只要判断目标格子与空格是否相邻即可这种设计在图形界面里尤其好用。后面做成 Swing 版本时用户点击任意按钮程序直接拿按钮下标走这个move方法如果返回false就提示“不能移动”或者干脆不做任何响应。2.3 完整模型类的骨架下面是我项目里实际使用的棋盘模型类包含了重置、移动、打乱、胜利判定、打印棋盘五个核心方法。所有图形界面和控制台版本都基于它来构建import java.util.ArrayList; import java.util.List; import java.util.Random; public class PuzzleBoard { private final int row; private final int col; private int[] board; private int emptyIndex; private Random random new Random(); public PuzzleBoard(int row, int col) { this.row row; this.col col; reset(); } public void reset() { board new int[row * col]; for (int i 0; i board.length; i) { board[i] i 1; } board[board.length - 1] 0; emptyIndex board.length - 1; } public boolean move(int index) { if (index 0 || index board.length) { return false; } int emptyRow emptyIndex / col; int emptyCol emptyIndex % col; int targetRow index / col; int targetCol index % col; if (Math.abs(emptyRow - targetRow) Math.abs(emptyCol - targetCol) ! 1) { return false; } board[emptyIndex] board[index]; board[index] 0; emptyIndex index; return true; } public void shuffle(int steps) { for (int i 0; i steps; i) { ListInteger candidates new ArrayList(); int er emptyIndex / col; int ec emptyIndex % col; if (er 0) { candidates.add(emptyIndex - col); } if (er row - 1) { candidates.add(emptyIndex col); } if (ec 0) { candidates.add(emptyIndex - 1); } if (ec col - 1) { candidates.add(emptyIndex 1); } int pick candidates.get(random.nextInt(candidates.size())); move(pick); } } public boolean isSolved() { for (int i 0; i board.length - 1; i) { if (board[i] ! i 1) { return false; } } return true; } public void print() { for (int i 0; i row; i) { for (int j 0; j col; j) { int v board[i * col j]; System.out.print(v 0 ? * : String.format(%3d , v)); } System.out.println(); } System.out.println(); } public int[] getBoard() { return board; } public int getRow() { return row; } public int getCol() { return col; } public int getSize() { return row * col; } }这里我留了一个print()方法方便控制台版调试。后面做图形界面时getBoard()会把棋盘数组暴露给 UI 层刷新按钮文字。3. 打乱算法的“死局”陷阱随机排列为什么会生成无解棋盘3.1 我踩过最隐蔽的坑我第一个版本偷懒直接Collections.shuffle(list)把 1 到 8 随机打乱再放回棋盘。表面上看没什么问题可实际玩的时候发现有些局怎么都拼不回去始终差一对数字的顺序。后来一查资料才知道数字华容道并不是任意排列都能通过合法移动还原的。原因要从“逆序数”说起。把一个棋盘状态按从左到右、从上到下的顺序读成一维序列忽略空格 0逆序数就是序列中所有“前面的数比后面的数大”的数对数量。例如序列[3, 1, 2]3 比 1 大算一组3 比 2 大算一组1 比 2 小不算。所以逆序数是 2。3x3 数字华容道的核心结论是如果逆序数是偶数则有解如果逆序数是奇数则无解。也就是说随机排列的 40320 种情况里只有一半能还原。你玩到“怎么排都差一步”的棋局不是手残是这局从出生起就是死局。3.2 为什么移动不会改变逆序数的奇偶性这个结论听起来玄其实证明思路不复杂。水平移动一个数字进入空格只是这个数字和自己左边的邻居交换位置逆序数变化量必然是 1 或 -1奇偶性会翻转但是水平移动必须借助空格空格也会换位置综合来看整个棋盘的逆序数奇偶性保持不变。垂直移动一个数字进入空格跨越的是奇数个数字最典型的是 3x3 跨越 2 个数字每跨过一个数字就产生一次交换两个交换的效果叠加奇偶性同样不变。所以不管你怎么走可达状态的逆序数奇偶性永远不可能发生改变。4x4 的华容道规则稍微复杂一点因为垂直方向跨越了 3 个数字逆序数奇偶性会受空格行号影响。但本项目以 3x3 为主先把 3x3 的规则吃透后面要扩展 4x4 时再补充一条“逆序数加上空格从底部数所在行号”的判定即可。3.3 解决方案用“合法移动”代替“随机排列”既然随机排列可能生成无解棋盘那就反向操作从有序棋盘出发随机执行几百次合法的移动。由于每一步都是合法移动所以最后得到的棋盘一定逆向可达有序状态也就一定有解。我在shuffle(int steps)方法里实现了这个思路每轮先枚举空格上下左右的候选下标再随机选一个执行move。一个可以优化的点是某些情况下空格来回抖动会导致局部数字没被打散比如这一步空格左移紧接下一步又右移等于原样返回。稳健的做法是在打乱时尽量避免选择上一步的反方向或者在代码里记录上一次移动的方向。不过实测下来3x3 棋盘只要随机移动 100 步以上打乱效果已经足够新手可以直接用简单版。3.4 用逆序数做“裁判”的工具方法如果你还是想用随机排列生成棋盘也不是完全不行但生成后必须加一道校验public static boolean isSolvable(int[] flat, int col) { int inv 0; for (int i 0; i flat.length; i) { if (flat[i] 0) { continue; } for (int j i 1; j flat.length; j) { if (flat[j] 0) { continue; } if (flat[i] flat[j]) { inv; } } } if (col % 2 1) { return inv % 2 0; } // 对偶数列棋盘比如 4x4还需要加上空格从底部数所在行号 int emptyRowFromBottom 0; // 这里需要根据空格位置补充计算 return (inv emptyRowFromBottom) % 2 0; }不过在实战项目里如果你已经用了shuffle(steps)合法移动方案这一步就可以省略。我后来做 4x4 扩展时保留了这个校验作为保险绳。4. 控制台版核心代码200 行跑通完整游戏流程4.1 主流程设计控制台版是逻辑验证的最快路径。主流程就是一个while循环打印棋盘、检查是否胜利、读取用户输入、尝试移动、更新步数。用户输入的方式我设计成“输入想要移动的数字”而不是方向键。这样实现最简单也最符合控制台场景。下面是我的完整主类import java.util.Scanner; public class PuzzleConsoleMain { public static void main(String[] args) { Scanner scanner new Scanner(System.in); PuzzleBoard board new PuzzleBoard(3, 3); board.shuffle(100); int steps 0; while (true) { board.print(); if (board.isSolved()) { System.out.println(恭喜拼图完成用了 steps 步。); break; } System.out.print(输入要移动的数字(1-8)输入 q 退出); String line scanner.nextLine().trim(); if (q.equalsIgnoreCase(line)) { System.out.println(再见); break; } int num; try { num Integer.parseInt(line); } catch (NumberFormatException e) { System.out.println(输入无效请输入数字或 q。); continue; } int index -1; int[] data board.getBoard(); for (int i 0; i data.length; i) { if (data[i] num) { index i; break; } } if (index -1) { System.out.println(棋盘上没有这个数字。); continue; } if (board.move(index)) { steps; } else { System.out.println(这个数字和空格不相邻无法移动。); } } scanner.close(); } }几个小细节值得说明scanner.nextLine()读取整行避免nextInt()后回车的换行符残留问题使用try-catch对非数字输入做保护而不是直接让程序崩溃查找数字下标用for循环遍历一维数组简单直接棋盘只有 9 个格子性能完全不是瓶颈。4.2 控制台版运行效果运行程序后终端会输出类似这样的棋盘1 2 3 4 * 6 7 5 8玩家输入5空格在第 1 行第 1 列下标 4数字 5 在下标 7四个方向里只有上方的数字 5 能移动到空格于是移动成功棋盘变更为1 2 3 4 5 6 7 * 8如果输入8因为 8 是空格右方相邻的数字也能移动如果输入7则 7 离空格不是一步距离程序会提示无法移动。这种交互虽然简陋但对理解棋盘模型和移动逻辑已经足够了。5. 升级图形界面用 Swing 让游戏真正能拿得出手5.1 UI 结构设计控制台版跑通之后我马上开始做 Swing 界面。整体结构是一个JFrame中间用GridLayout(3, 3)放 9 个JButton顶部用JLabel显示步数底部再放一个提示标签。关键设计是每个按钮的下标与棋盘数组下标一一对应。比如棋盘下标 0 的格子就是第一个按钮这样点击按钮i时可以直接调用board.move(i)。棋盘数组刷新后再遍历所有按钮更新文字。import javax.swing.*; import java.awt.*; import java.awt.event.ActionEvent; public class PuzzleSwingMain extends JFrame { private PuzzleBoard board; private JButton[] buttons; private JLabel tipLabel; private int steps 0; private long startTime; public PuzzleSwingMain() { super(数字华容道 / 石头迷阵); board new PuzzleBoard(3, 3); board.shuffle(100); initUI(); startTime System.currentTimeMillis(); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); pack(); setLocationRelativeTo(null); setVisible(true); } private void initUI() { setLayout(new BorderLayout()); JPanel gridPanel new JPanel(new GridLayout(board.getRow(), board.getCol(), 6, 6)); gridPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); int size board.getSize(); buttons new JButton[size]; for (int i 0; i size; i) { JButton btn new JButton(); btn.setFont(new Font(Dialog, Font.BOLD, 28)); int index i; btn.addActionListener((ActionEvent e) - onButtonClick(index)); buttons[i] btn; gridPanel.add(btn); } add(gridPanel, BorderLayout.CENTER); tipLabel new JLabel(点击与空格相邻的数字来移动, SwingConstants.CENTER); add(tipLabel, BorderLayout.SOUTH); refresh(); } private void onButtonClick(int index) { if (board.move(index)) { steps; refresh(); if (board.isSolved()) { long cost (System.currentTimeMillis() - startTime) / 1000; JOptionPane.showMessageDialog(this, 恭喜完成步数: steps 用时: cost 秒); } } else { tipLabel.setText(这个数字不能移动请选择与空格相邻的数字); } } private void refresh() { int[] data board.getBoard(); for (int i 0; i data.length; i) { if (data[i] 0) { buttons[i].setText(); buttons[i].setEnabled(false); } else { buttons[i].setText(String.valueOf(data[i])); buttons[i].setEnabled(true); } } tipLabel.setText(已移动 steps 步); } public static void main(String[] args) { SwingUtilities.invokeLater(PuzzleSwingMain::new); } }5.2 按钮点击、步数统计和胜利弹窗点击按钮的响应方法onButtonClick(int index)是整个 UI 的核心它先调用board.move(index)如果移动成功步数加一并刷新界面如果移动非法只更新提示文字不改变棋盘。这样用户点击一个离空格较远的数字时不会产生任何破坏性操作体验很流畅。胜利判定放在了移动成功之后。一旦board.isSolved()返回true就会弹出一个JOptionPane消息框显示步数和用时。这里的用时是从窗口创建时开始计算的用System.currentTimeMillis()换算成秒即可。5.3 刷新逻辑里的一个隐藏细节我特别想提醒一个刚开始容易漏掉的问题点击按钮后必须在逻辑层修改完成后再刷新 UI而且刷新要覆盖所有按钮。我踩过一次坑是只更新了被点击按钮和空格按钮的文字结果一个移动后其他按钮的显示还是旧状态棋盘就花了。后来干脆统一写一个refresh()方法不管棋盘有没有变化每次都遍历全部按钮重新赋值简单可靠。Swing 的线程规则也值得提一句按钮点击事件本身运行在事件分发线程 EDT 上所以直接在onButtonClick里修改组件属性是安全的。为了保证万无一失我还是用了SwingUtilities.invokeLater来创建窗口这是 Swing 程序的规范写法。6. 实测中踩过的坑与后续扩展想象空间6.1 我在开发中踩过的三个坑第一个坑在上面已经说了随机排列生成棋盘导致一半的局无解。排查过程也很曲折我一度以为是move方法的边界条件写错了后来写了个独立的逆序数校验工具才发现问题出在初始状态上。这提醒了我一个道理当你反复调试逻辑都没问题时要回头审视数据来源是否合法。第二个坑是打乱步数太少。刚开始我只打乱了 20 步结果一局游戏里数字基本还是按顺序排列空格只是绕着右下角转了几圈玩起来毫无挑战。后来把步数加到 100 到 200 步分布才足够散。如果你做 4x4 版本建议至少打乱 300 步。第三个坑是 Swing 刷新不及时。我前面讲到只更新部分按钮会导致界面显示错乱这个问题排查起来特别容易让人误以为是布局问题。最终解决方案就是统一刷新的refresh()方法不做局部优化符合“先正确再性能”的原则。6.2 把数字换成图片的“石头迷阵”玩法标题里的“石头迷阵”完全可以落地准备一张图片用ImageIO.read()加载后按 3x3 切割成 9 个小图每个格子显示一块小图空格显示空白。最后一块拼图就是右下角的图片碎片。这样数字华容道就变成了真正的“石头迷阵”玩法逻辑一行都不用改只改按钮的渲染部分。我实现的思路是在refresh()中判断data[i]的数值根据数值找到对应的ImageIcon用buttons[i].setIcon(...)替换文字。切图代码大致是ImageIcon icon new ImageIcon(stone.jpg); Image img icon.getImage().getScaledInstance(80 * 3, 80 * 3, Image.SCALE_SMOOTH); BufferedImage buffered new BufferedImage(240, 240, BufferedImage.TYPE_INT_RGB); buffered.getGraphics().drawImage(img, 0, 0, null); // 再按 col、row 切出每个格子如果你只是拿来练手数字版已经足够如果要做成一个能给人演示的小作品图片版会立刻加分不少。6.3 下一步还能怎么扩展这个项目想要继续进阶还有几条很有意思的路线增加难度选项把 3x3 扩展到 4x4 或 5x5只需要改一行new PuzzleBoard(4, 4)但要注意 4x4 的逆序数判定规则不同移动步数也要相应加大把步数和耗时写入本地文件做成简单的排行榜用KeyListener支持键盘方向键控制提升操作手感加入自动求解用广度优先搜索 BFS 或 A* 搜索给出最少步数解法这会直接把你从“玩游戏”带入“研究算法”的层面。我在实际使用中最大的体会是一个小游戏并不意味着只能“小打小闹”它背后牵扯到的建模、边界情况、算法校验、界面刷新几乎覆盖了日常开发中的全部思考方式。哪怕你的目标是面试八股文能亲手写一个带界面、带算法细节的小项目讲起来也比背一百道题更有说服力。本文还有配套的精品资源点击获取
返回列表