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

📅 发布时间:2026/10/17 6:57:20
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 ListMove 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 SwingWorkerint[], 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 代码都会强制走一遍「自动对弈加单步耗时」的验证再碰界面省了很多肉眼排查的时间。希望这套资源对你也有同样的帮助。本文还有配套的精品资源点击获取