ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java五子棋游戏教程:Swing界面、状态管理与启发式AI实战

Java五子棋游戏教程:Swing界面、状态管理与启发式AI实战

简介:一份面向Java编程学习者的五子棋游戏开发教程资源,围绕棋盘类、棋子类、玩家类三个核心模块展开,详细讲解二维数组建模、合法落子校验、五子连珠判断,以及基于Minimax与Alpha-Beta剪枝的AI算法,还覆盖了基于Swing或JavaFX的图形界面与事件驱动游戏循环,适合想借实战项目巩固面向对象设计、算法基础和GUI开发的开发者。压缩包共65个文件,以h与cpp源代码文件为主,包含工程编译产生的obj、pdb、idb等中间文件,并附带bmp、ico、wav等界面与音效资源,整体约2.73MB,工程文档与源码组织清晰,便于对照研读。目前已有205人学习下载。借助这份资源,读者不仅能贯通棋盘绘制、棋子落子、胜负判定的完整逻辑,还能进一步实现悔棋、保存加载、难度分级等人机对战增强功能,并对项目工程的文件组织、资源管理和构建流程获得直观认识。

1. 五子棋游戏教程:一个能把 Swing、算法和状态管理一次练完的 Java 项目

把「Java 实现的五子棋游戏教程」整套源码从头拆到尾,是我最近做过最有性价比的一件事。起因是团队里新来的同学要练桌面应用,市面上大部分 java 五子棋游戏教程要么只丢代码不讲设计,要么一上来就甩 Minimax 直接把新手劝退。这份资源卡在一个很好的中间位置:用二维数组管棋盘、事件监听管交互、启发式评分撑起电脑对手,还顺手把悔棋、重开这些最容易翻车的状态管理做全了。它适合两类人——刚学完 Java 语法、想找一个完整 GUI 项目练手的人,以及写过 CRUD 但没碰过桌面程序状态流转的人。拆完你会发现,五子棋虽小,该踩的坑一个不少。

2. 技术选型与项目骨架:Swing 界面层和二维数组棋盘模型怎么分层

2.1 项目目录结构与核心类职责

拿到这套 java 五子棋游戏教程的资源,我第一件事不是点开代码看逻辑,而是先把目录结构整体捋了一遍。它不是一个塞满注释的单文件演示,而是按 model / ai / ui / controller 四层拆开的 Maven 工程。这么拆有一个立竿见影的好处:model 层不依赖任何 Swing 类,以后想改成控制台版、网络版,模型和 AI 两层可以原封不动搬走。

gomoku/ ├── pom.xml // Maven 配置,一个 JDK 8 环境足够 ├── src/main/java/com/gomoku/ │ ├── App.java // 程序入口:初始化主窗口 │ ├── model/ │ │ ├── Board.java // 15x15 棋盘数据,落子、悔棋、胜负检测 │ │ ├── Piece.java // 棋子枚举:EMPTY / BLACK / WHITE │ │ └── GameState.java // 状态枚举:PLAYING / WIN / DRAW │ ├── ai/ │ │ └── GomokuAI.java // 启发式评分 AI,返回最佳落子点 │ ├── ui/ │ │ ├── MainFrame.java // 主窗口:菜单、按钮、面板组装 │ │ └── BoardPanel.java // 棋盘绘制与鼠标事件 │ └── controller/ │ └── GameController.java // 连接界面与模型的中枢

每个类单看都不复杂,合在一起就是一个标准的小型 MVC 变体。我看过不少同类的 java 五子棋游戏代码,最常见也最容易翻车的写法是:所有逻辑全部塞在 JPanel 里,画布既当模型又当控制器,改一个功能牵扯一片,测试更是无从下手。这份资源的做法是 BoardPanel 只管画棋盘和转发鼠标事件,GameController 负责裁决「能不能落子、落完该谁走、要不要触发 AI」,这样不启动界面也能对 model 层直接写单测,是我比较认可的分层习惯。

pom.xml 里只依赖 JDK 自带的 Swing 和 JUnit,没有引入第三方 UI 库。这一点对新手尤其友好,意味着不需要处理依赖冲突,加依赖最常踩的坑被直接绕开了。Maven 打包用 maven-jar-plugin 指定主类,打出来的 jar 双击能跑,不需要在 IDE 里配一堆 VM 参数。

2.2 棋盘数据模型:为什么用 int[][] 而不是对象数组

Board 是整个项目的地基,它选的数据结构直接决定了后续所有算法好不好写。教程里用的是 int[SIZE][SIZE],0 表示空位、1 表示黑子、2 表示白子。这个选择看着朴素,实际上很稳:二维数组按下标访问是 O(1),胜负检测和 AI 评估都要高频遍历整张棋盘,数组是最不吃亏的选择。

public class Board { public static final int SIZE = 15; private final int[][] grid = new int[SIZE][SIZE]; private int currentPlayer = 1; // 1=黑 2=白 private final List<Move> history = new ArrayList<>(); public boolean place(int row, int col) { if (row < 0 || row >= SIZE || col < 0 || col >= SIZE) { return false; // 越界直接拒绝 } if (grid[row][col] != 0) { return false; // 已有棋子,拒绝重复落子 } grid[row][col] = currentPlayer; history.add(new Move(row, col, currentPlayer)); currentPlayer = 3 - currentPlayer; // 1 <-> 2 轮换 return true; } }

place 方法做了三件事:边界与占位校验、落子、记录历史。这里的 currentPlayer = 3 - currentPlayer 是个很实用的小技巧,因为 3 减 1 得 2、3 减 2 得 1,一次减法完成回合轮换,比 if 判断更干净。history 列表是给悔棋功能预留的,后面做撤销时只需要从列表尾部弹出并置空对应格子,不用遍历棋盘找最后一手。

可能有读者想问:为什么不用 Piece 枚举数组,逻辑上更安全?这个思路没毛病,但这套教程里 int 有三个实际优势:可以直接当作数组下标、可以直接参与评分公式的加减乘除、和 AI 层大量的数值比较天然兼容。如果你坚持在重构时改枚举,记得给枚举加一个 toInt() 转换方法,否则 AI 那层代码会到处做类型转换,写起来非常别扭。

2.3 鼠标事件到棋盘坐标的换算

Swing 界面最容易写崩的地方不是画棋盘,而是把鼠标点击的像素坐标换算成棋盘行列号。BoardPanel 里棋盘左上角离面板边缘有一个 OFFSET 偏移量,每格大小是 CELL_SIZE,换算逻辑只有两行,但顺序或者类型写错,就会出现「棋子落在格子缝里」的经典问题。

this.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { int col = (int) Math.round((e.getX() - OFFSET) / (double) CELL_SIZE); int row = (int) Math.round((e.getY() - OFFSET) / (double) CELL_SIZE); if (controller.placeStone(row, col)) { repaint(); // 落子成功才重绘 } } });

这里有两个细节值得单独拿出来说。第一,除以 CELL_SIZE 之前一定要把被除数转成 double,否则两个 int 相除会直接截断小数,比如 12 除以 20 等于 0,结果是你点哪一行都会落到第 0 行附近。第二,用 Math.round 而不是 (int) 强转,因为鼠标点在两个格子中线附近时,四舍五入按就近原则选格,强转则一律向下取整,长期下来棋子会整体往左上角偏。

如果你后面调整了棋盘边距,记得 OFFSET 在 MainFrame 和 BoardPanel 中要保持一致,教程里把它们定义成 static final 常量就是为了避免魔法数字散落各处。落子成功才 repaint 是一个小优化,坐标换算出来是无效点时不触发重绘,减少无意义的绘制开销。另外 triggerAI 这块代码教程里特意单独抽了一个方法,就是为了方便换成下一章要讲的后台线程实现,这个伏笔先记着。

3. 核心模块拆解:落子判定、胜负检测与 AI 落子完整链路

前面把整体骨架讲完了,这一章沿着一条真实对局链路走一遍:玩家点击棋盘,鼠标事件经过坐标换算进入 GameController,通过合法性校验后写入 Board,然后触发胜负检测;如果还未分胜负且当前轮到电脑,则 GomokuAI 评估所有空点后落子,界面再刷新。这条链路是整套资源的主干,改造任何功能都绕不开它。

3.1 落子判定与状态流转

GameController 的 placeStone 是整个交互的闸门,所有落子请求都要过这一层。它最值得学习的一点是:任何情况下都先检查状态,再决定要不要放行,而不是等模型层报错。

public boolean placeStone(int row, int col) { if (state != GameState.PLAYING) { return false; // 对局已结束,拒绝一切落子 } if (!board.place(row, col)) { return false; // 越界或占位,模型层拒绝 } int player = board.getCurrentPlayer(); if (board.checkWin(row, col, player)) { state = GameState.WIN; mainFrame.showWinner(player); // 弹窗提示胜利方 } else if (board.isFull()) { state = GameState.DRAW; mainFrame.showDraw(); } else if (player == AI_PLAYER) { triggerAI(); // 轮到电脑,异步计算落点 } return true; }

placeStone 的逻辑分四层:状态闸门、模型落子、胜平判定、AI 触发。其中比较容易被忽略的是顺序——先判 PLAYING 再调 board.place,这样对局结束后任何点击都会被静默拒绝,而不是跑到模型层才被拦截。另一个细节是胜负检测放在落子之后,而且只用当前这一步的位置去查,而不是全盘扫描,棋盘越大这个差异越明显。

3.2 五子连珠检测:从最后一手往四个方向数

胜负检测是五子棋的核心算法,教程里实现得很克制:每次落子后,只以刚落下的那颗棋子为起点,沿四个方向各数一遍连续同色棋子数。因为五子连珠必然包含最后一手,所以完全没必要扫描全盘。四个方向分别是水平、垂直、主对角线、副对角线。

private static final int[][] DIRECTIONS = { {0, 1}, // 水平 {1, 0}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; public boolean checkWin(int row, int col, int player) { for (int[] d : DIRECTIONS) { int count = 1; count += countDirection(row, col, player, d[0], d[1]); count += countDirection(row, col, player, -d[0], -d[1]); if (count >= 5) { return true; } } return false; } private int countDirection(int row, int col, int player, int dr, int dc) { int count = 0; int r = row + dr; int c = col + dc; while (r >= 0 && r < Board.SIZE && c >= 0 && c < Board.SIZE && grid[r][c] == player) { count++; r += dr; c += dc; } return count; }

checkWin 里 count 初始为 1,也就是把当前落子本身算进去,再朝正反两个方向累加。比如刚落一颗黑子,水平方向左边连了 2 颗、右边连了 2 颗,加上自己就是 5 颗,立刻返回 true。DIRECTIONS 里 {1, -1} 是副对角线,斜率为负的那条,写的时候最容易漏,漏掉的结果是「右上到左下」这条线上的连五永远判不出来。

注意:DIRECTIONS 数组里的方向顺序不影响判定结果,但四个方向必须完整,漏掉副对角线是胜负误判最常见的原因。

countDirection 的边界条件要同时检查行列,只查单边会导致数组越界。把判断条件写在 while 头部而不是循环体内 break,逻辑更清晰,也不容易在空棋盘上翻车。这段算法既是胜负判定,也是后面 AI 评分的基础——AI 要在每个空位上模拟四个方向的连子数和成活状态,用的就是同一套方向遍历。

3.3 启发式 AI:从「进攻」和「防守」两个视角打分

教程里的 AI 没有上 Minimax,而是用启发式评分:遍历棋盘每个空位,分别模拟「黑子落在这里」和「白子落在这里」能形成多大的威胁,综合得到该点的得分,取最高分落子。这种做法的优点是代码短、速度快,15x15 棋盘上毫秒级出结果,对入门项目来说体验比深搜好得多。

public int[] findBestMove(int aiPlayer) { int bestScore = Integer.MIN_VALUE; int[] bestMove = {-1, -1}; for (int row = 0; row < Board.SIZE; row++) { for (int col = 0; col < Board.SIZE; col++) { if (grid[row][col] != Piece.EMPTY) { continue; // 只评估空位 } int attackScore = evaluatePoint(row, col, aiPlayer); int defendScore = evaluatePoint(row, col, 3 - aiPlayer); int score = Math.max(attackScore, defendScore); if (score > bestScore) { bestScore = score; bestMove = new int[] {row, col}; } } } return bestMove; }

findBestMove 的双层循环扫过全部 225 个格子,跳过非空位,剩下的每个点都算两次分:一次站在 AI 自己视角看进攻价值,一次站在对手视角看防守价值,取较大者。这个「攻防各算一遍取 max」的思路是启发式 AI 不犯呆的关键——如果只看进攻,AI 会死命冲自己的棋,对面快连五了它都不管;如果只看防守,AI 又会永远跟着对手走,自己永远造不出杀棋。

private int evaluatePoint(int row, int col, int player) { int score = 0; for (int[] d : DIRECTIONS) { int count = 1; count += countDirection(row, col, player, d[0], d[1]); count += countDirection(row, col, player, -d[0], -d[1]); boolean openTwoEnds = isOpen(row, col, player, d); if (count >= 5) { score += 100000; // 成五,最高优先级 } else if (count == 4 && openTwoEnds) { score += 10000; // 活四,基本必胜 } else if (count == 3 && openTwoEnds) { score += 1000; // 活三,需要处理 } else if (count == 2 && openTwoEnds) { score += 100; // 活二,潜在威胁 } } return score; }

evaluatePoint 把四个方向各自的连子数拿出来,按「成五 > 活四 > 活三 > 活二」的档位叠加分数。这里的分值差设计成 10 倍递进,目的是让高优先级棋型在求和后不会被大量低优先级棋型淹没。isOpen 判断方向两端是否仍有空位,如果一端被堵死,「活三」就降级成「眠三」,威胁程度完全不同,这个细节直接决定了 AI 会不会在边角地带犯蠢。如果觉得打分太抽象,可以在评分循环里临时加一行 System.out.printf 把每个空位的得分打出来,落子前看一遍 top 5 候选点的分布,能直观理解 AI 为什么选进攻而不是盲目堵。

4. 避坑与常见问题排查:坐标偏移、线程卡死、悔棋不同步

能跑通不等于没踩坑。这套资源在教程部分把几个高频问题单独拎出来讲过,我自己在跟着复现时也亲自翻过几次车。下面几条是我觉得对二次开发最有参考价值的排查记录,每条按现象、原因、解决三步梳理。

4.1 棋子全部偏向左上角,点击和落子位置对不上

现象:鼠标点在棋盘第 8 列,画出来的棋子却落在第 6 列,越靠近右下角偏差越大。

原因:坐标换算时直接写了 (e.getX() - OFFSET) / CELL_SIZE,两个 int 相除截断小数,所有结果都向下取整,等价于每一格都往左上偏了半格;累积起来右下角偏差最明显。

解决:把被除数转成 double 再除,最后套 Math.round。也就是 2.3 节那段代码的写法,改动只有一行,但视觉效果天差地别。排查时先在面板四个角落各点一次,看落点和鼠标是否重合,能快速定位是换算问题还是绘制偏移问题。

4.2 AI 思考时整个窗口无响应,标题栏出现「未响应」

现象:轮到电脑走棋,点击棋盘后窗口冻结,转圈或白屏,好一会儿才恢复;评分越复杂、局面越靠后的情况越明显。

原因:AI 的 findBestMove 被直接放在鼠标事件回调里执行,也就是 Swing 的事件分发线程(EDT)上。EDT 负责所有界面绘制和事件处理,一旦被双层 for 循环占住,重绘、鼠标、键盘全被堵死。

解决:用 SwingWorker 把 AI 计算丢到后台线程,计算完再回到 EDT 更新棋盘。

new SwingWorker<int[], Void>() { @Override protected int[] doInBackground() throws Exception { return ai.findBestMove(aiPlayer); // 后台线程,不阻塞界面 } @Override protected void done() { try { int[] move = get(); board.place(move[0], move[1]); boardPanel.repaint(); GameController.this.checkEndAndTurn(); } catch (Exception ex) { ex.printStackTrace(); // 计算异常也要恢复界面状态 } } }.execute();

SwingWorker 是 Swing 自带的后台任务工具,doInBackground 跑耗时逻辑,done 在 EDT 上执行,里面拿结果更新界面。注意 catch 里不要留空,AI 计算如果抛异常,界面会停在一个「轮到电脑却没人走」的黑匣子状态,打印栈是排查的第一手信息。

提示:判断一个 AI 实现合不合格,先让它自己和上一版对弈,再看单步耗时,不要凭肉眼感觉下结论。

4.3 胜负已分还能继续落子,棋盘还能接着下

现象:黑棋已经连成五颗并弹出胜利提示,再点空白处,棋子照样落下,游戏状态混乱。

原因:placeStone 入口没有检查 state,胜利之后所有点击仍然进入模型层落子。

解决:在控制器入口加一行 state != GameState.PLAYING 就返回 false。这个坑的深层原因是「状态校验应该放在入口统一做」,而不是依赖每个调用方自觉,属于状态机设计里的常见教训。检查时可以把 GameState 的流转画出来,你会发现所有操作只有 PLAYING 状态下才允许。

4.4 悔棋后 AI 立刻又走一子,等于白悔

现象:玩家点「悔棋」撤掉自己最后一手,AI 马上又落一子,棋盘几乎没有变化。

原因:悔棋只回退了玩家那一步,AI 的上一手还留在棋盘上;轮换逻辑检测到当前是 AI 回合,又自动触发了 AI 落子。

解决:悔棋要一次性回退两步——AI 的上一手和玩家的上一手,同时把 currentPlayer 和 state 复位到玩家落子前的状态。如果实现的是「悔棋持续可用」,每点一次都要成对回退,直到 history 为空。这个坑在联机版里同样存在,回退时还要同步把远端对手的棋盘状态也撤回。

4.5 重开一局后旧棋子残留

现象:点「新游戏」后棋盘上还留着上一局的棋子,或者偶尔少几个。

原因:重开逻辑只把 grid 置零,没有清空 history;更隐蔽的是,如果复用同一个 Board 引用而旧面板还在监听事件,两局之间的状态会互相串。

解决:完整重置方法要同时做三件事:grid 全置 0、currentPlayer 回到 1、history.clear()。面板侧做一次 repaint 即可,因为绘制只读取 grid,数据清了画面自然就清。重开功能的验证也很简单:下一局结束后再看一眼棋盘,然后连续重开三次,确认没有残留。

5. 二次开发:把启发式 AI 升级成搜索树,再往联机方向走一步

到手源码能跑只是第一步,真正值钱的改造空间在两个方向:AI 强度和联机对战。

5.1 替换 AI 核心

把 findBestMove 里的启发式顶分替换成 alpha-beta 剪枝搜索,只需要保留 evaluatePoint 作为叶子节点的估值函数,外层加一个深度 4 的递归搜索。棋盘状态用 Board 的深拷贝,每层递归落子后剪枝,返回带位置的最佳分支。改造完成后建议先做对比验证:让新旧 AI 互下 20 局,统计胜率,同时记录单步耗时。新 AI 如果深度大于 6 且不加开局库,反而会因搜索太慢失去实战价值,深度和速度的平衡要靠实测数据说话。

5.2 往联机方向改造

把 GameController 的 placeStone 从「本地落子」改成「发送给远端并等待确认」,棋盘数据序列化成 JSON 或简单的坐标字符串,用 Java 原生的 ServerSocket 起一个最小房间,两个客户端通过固定端口通信。注意 4.4 节说的悔棋同步问题,远端也要有对应的回退协议,否则本地撤一步、远端还留着那颗子,棋盘状态就对不上了。

5.3 验证习惯

我从这个项目里学到的习惯是:每次改完 AI 或状态逻辑,先写一个不依赖 Swing 的测试入口——直接用 Board 和 GomokuAI 跑自动对弈,输赢数据比肉眼盯界面可靠得多。从那以后,我每次动这类小游戏的棋盘或 AI 代码,都会强制走一遍「自动对弈加单步耗时」的验证再碰界面,省了很多肉眼排查的时间。希望这套资源对你也有同样的帮助。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进