资讯动态

Java五子棋人机对战项目:从Swing界面到权值AI完整实现

发布时间:2026/10/17 15:41:47 来源:尧图企业网站定制
简介一份围绕Java五子棋游戏实现教程整理的学习资源面向掌握基础语法、希望用完整项目提升面向对象与博弈算法能力的开发者。压缩包共65个文件以头文件、源码和编译中间文件为主搭配wav、bmp、ico等音频与界面素材同时包含dsw、dsp等工程配置便于直接打开查看整体结构理解各模块的调用关系。整包仅2.73MB已有205人学习。内容涉及棋盘类设计、合法落子校验、五子连珠胜负判断、玩家交互流程以及Minimax/AI决策思路同时涵盖棋盘边缘操作、悔棋、进度保存与难度设置等扩展功能。借助其中的txt与doc说明文档可以更清晰地梳理项目设计脉络适合作为课程设计、面试练习或入门AI博弈开发的参考。1. Java五子棋游戏从控制台Demo到可对局的人机对战项目很多人拿到一个Java五子棋项目第一反应是这不就是二维数组加个边框吗真正动手才发现界面画出来了、棋子能落下了AI却像个随机数生成器胜负判断还会偶尔漏判。这个项目把Java五子棋游戏做到了一套可复现的完整形态15×15标准棋盘、黑白交替落子、四方向胜负扫描、带棋型权值的人机对战AI以及完整的交互面板重开、悔棋、最后一手高亮。它适合两类人一类是刚学完Java SE、想做一个小而完整实战项目的学生另一类是正在做课程设计、需要讲清楚数据结构—规则判定—AI决策全链路的开发者。你拿到的不只是能跑的代码更是一份可以照着讲、照着改的教学工程。2. 架构设计Swing界面与规则AI分离的三层结构2.1 为什么把棋盘、规则、AI拆成三个类初学Swing时几乎所有人都会把一切都塞进JFrame里画格子、监听鼠标、判断输赢、AI落子的代码全部堆在同一个类中。跑起来没问题但是一旦你想加悔棋禁手或者联机对战就会发现自己根本改不动因为所有功能都耦合在UI事件里。这个项目第一个值得读的设计是强制按模型-视图-控制做了分层把界面和逻辑彻底剥开。目录结构非常清晰gobang-tutorial ├── src │ ├── Main.java // 程序入口启动JFrame │ ├── model/GameBoard.java // 棋盘数据模型 │ ├── rule/RuleChecker.java // 胜负判定、落子合法性校验 │ ├── ai/AIEngine.java // 人机AI决策引擎 │ ├── view/BoardPanel.java // 棋盘绘制与鼠标事件 │ └── controller/GameController.java // 游戏流程控制 └── README.md // 运行说明和算法讲解文档关键类的职责和依赖关系如下表类名职责依赖GameBoard保存二维数组棋盘提供落子与读取接口无RuleChecker判定胜负、检查越界和重子GameBoardAIEngine评估空点分数返回落子坐标GameBoardBoardPanel绘制棋盘和棋子捕获鼠标点击GameBoardGameController串联玩家落子、胜负判断、AI回手以上全部这样的分层最直观的好处是AI和规则完全不需要碰Swing相关代码。我曾经在做类似项目时先写了一个纯控制台的TextMain入口把落子、判胜、AI全部在命令行跑通最后再加BoardPanel做界面调试效率高了一个量级。因为你不需要每次改判胜逻辑都去点一遍图形界面直接跑控制台测试就行。2.2 一局游戏从鼠标点击到AI回手的完整时序五子棋是事件驱动的而不是像控制台程序那样用while(true)轮询。整局游戏的驱动核心在GameController它把鼠标点击、棋盘变更、AI计算串成一条链。简化后的主线逻辑如下public class GameController { private GameBoard board; private BoardPanel panel; private AIEngine ai; private boolean isPlayerTurn true; // 由BoardPanel的鼠标监听器调用 public void handleClick(int x, int y) { if (!isPlayerTurn) return; // AI思考中忽略点击 if (!board.place(x, y, GameBoard.BLACK)) return; // 非法落子忽略 if (RuleChecker.checkWin(board, x, y)) { panel.showMessage(黑方获胜); // 玩家执黑先手 return; } isPlayerTurn false; // 切换回合 aiMove(); } private void aiMove() { int[] move ai.findBestMove(board, GameBoard.WHITE, GameBoard.BLACK); board.place(move[0], move[1], GameBoard.WHITE); isPlayerTurn true; // 恢复玩家回合 panel.repaint(); // 触发重绘 } }这段代码里最关键的是回合标志位isPlayerTurn。很多五子棋项目翻车就翻在这里AI落子后没有把标志位切回玩家结果玩家疯狂点击棋盘都没反应或者玩家能连续落多颗黑子。另外注意board.place返回boolean落子在越界或者重复位置时直接拒绝不需要在Controller里再写一长串判断。2.3 棋盘面板绘制和坐标换算BoardPanel继承JPanel重写paintComponent绘制整个棋盘。核心绘制逻辑是画15×15的网格线再根据棋盘数据画黑白棋子。这里有一个很多新手会踩的坑鼠标点击得到的坐标是像素值必须换算成棋盘上的行列索引换算公式为col (pixelX - MARGIN) / CELL_SIZE如果没有减去左边距MARGIN棋子会整体偏移一格。public class BoardPanel extends JPanel { private static final int CELL_SIZE 40; // 格子边长 private static final int MARGIN 20; // 棋盘边距 protected void paintComponent(Graphics g) { super.paintComponent(g); for (int i 0; i GameBoard.SIZE; i) { g.drawLine(MARGIN, MARGIN i * CELL_SIZE, MARGIN (GameBoard.SIZE - 1) * CELL_SIZE, MARGIN i * CELL_SIZE); g.drawLine(MARGIN i * CELL_SIZE, MARGIN, MARGIN i * CELL_SIZE, MARGIN (GameBoard.SIZE - 1) * CELL_SIZE); } // 遍历棋盘画棋子 for (int x 0; x GameBoard.SIZE; x) { for (int y 0; y GameBoard.SIZE; y) { int player board.getPiece(x, y); if (player GameBoard.BLACK) { g.fillOval(MARGIN y * CELL_SIZE - 16, MARGIN x * CELL_SIZE - 16, 32, 32); } } } } }注意绘制棋子时我用了MARGIN y * CELL_SIZE对应列坐标MARGIN x * CELL_SIZE对应行坐标。因为二维数组第一维是行第二维是列而图形坐标的横轴是列方向。这个行列互换的问题很隐蔽常见表现是棋子落在你点击位置的对角线方向稍后避坑章节会专门展开。3. 核心实现拆解二维数组棋盘与胜负判定算法3.1 棋盘模型用int数组表示三种状态GameBoard是整盘游戏的数据核心它做的事情并不复杂用一个15×15的二维int数组记录每个交叉点的状态0表示空1表示黑棋2表示白棋。之所以用int而不是char或boolean是因为后续AI评估需要对黑白双方分别打分用数字常量可以直接做比较。public class GameBoard { public static final int SIZE 15; public static final int EMPTY 0; public static final int BLACK 1; public static final int WHITE 2; private final int[][] board new int[SIZE][SIZE]; private final java.util.Stackint[] history new java.util.Stack(); public boolean place(int x, int y, int player) { if (!isValidPoint(x, y)) return false; if (board[x][y] ! EMPTY) return false; board[x][y] player; history.push(new int[]{x, y, player}); // 记录落子历史便于悔棋 return true; } public boolean isValidPoint(int x, int y) { return x 0 x SIZE y 0 y SIZE; } public int getPiece(int x, int y) { return board[x][y]; } }这里的history栈是很多人容易忽略的设计点。如果要支持悔棋就必须知道每步棋落子的坐标和颜色。用栈记录后悔棋操作只需要弹出栈顶的坐标把对应位置重置为EMPTY即可。我在写这个功能时吃过亏一开始没记录颜色悔棋时把对手的棋子也清掉了后来反省才明白历史栈里必须同时保存坐标和玩家标识。3.2 落子校验越界、重子与回合控制落子校验从字面上看很简单实际上有三个独立的检查坐标是否越界、该位置是否已有棋子、当前是否轮到对应玩家。其中越界和重子的检查应该在模型层完成而回合控制属于流程控制放在GameController中。这样划分的好处是将来做联机对战时服务端可以直接复用GameBoard.place的校验逻辑不依赖任何界面状态。public boolean move(int x, int y, int player) { if (!board.isValidPoint(x, y)) { System.out.println(落子越界 x , y); return false; } if (board.getPiece(x, y) ! GameBoard.EMPTY) { System.out.println(该位置已有棋子不能重复落子); return false; } return board.place(x, y, player); }我看到过一些项目把越界检查放在鼠标监听器里比如判断x 15、y 15。这在界面层面没问题但一旦代码被复用比如写单元测试这些检查就丢了。更稳妥的做法是让GameBoard.place内部自校验无论从GUI、控制台还是网络协议进来的落子请求都会经过同一道关卡。这种防御性编程的习惯在后续做网络对战时能省掉大量脏数据排查时间。3.3 胜负判断四方向连续计数算法胜负判断是五子棋的核心算法。思路是每次落子后只需要检查以落点为中心、四个方向水平、垂直、主对角线、副对角线上与当前棋子同色的连续棋子数只要某个方向连续数量达到5就判定获胜。因为只有刚落下这颗棋子的位置可能导致赢棋所以不需要全盘扫描性能开销非常小。public class RuleChecker { private static final int[][] DIRECTIONS { {0, 1}, // 水平方向 {1, 0}, // 垂直方向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; public static boolean checkWin(GameBoard board, int x, int y) { int player board.getPiece(x, y); if (player GameBoard.EMPTY) return false; for (int[] dir : DIRECTIONS) { int count 1; count countInDirection(board, x, y, dir[0], dir[1]); count countInDirection(board, x, y, -dir[0], -dir[1]); if (count 5) return true; } return false; } private static int countInDirection(GameBoard board, int x, int y, int dx, int dy) { int count 0; int nx x dx; int ny y dy; while (board.isValidPoint(nx, ny) board.getPiece(nx, ny) board.getPiece(x, y)) { count; nx dx; ny dy; } return count; } }这里的countInDirection是关键从一个方向延伸扫描遇到边界或者不同颜色就停止。注意我在while循环里先判断isValidPoint再取棋子状态如果把顺序反了getPiece传入越界坐标就会抛出ArrayIndexOutOfBoundsException。这个方向数组的设计也很容易扩展到其他五子棋规则比如检查长连禁手时只需要增加一个统计两端封闭情况的辅助函数即可。4. 人机AI基于棋型权值的贪心落子决策4.1 贪心打分的核心思想人机对战的AI不用做得很深也能达到能玩的体验。本项目采用的是贪心评估方案遍历棋盘上所有空点对每个空点分别计算如果我黑方下这步棋的攻击力和如果白方下这步棋的防守需求两者加权求和作为此点的总分最后选总分最高的点落子。这种方案没有搜索树计算量极小在15×15棋盘上每次评估大约只要几千次操作界面完全无感。为什么用贪心而不是Alpha-Beta搜索因为这是一个教学项目贪心算法最容易讲清楚也最容易调试任何一个空点的分数都可以打印出来人工检查AI到底在防谁、攻谁。我见过不少把MinMax写得一塌糊涂的人机对战项目发现所谓智能还不如一个权值表来得稳定。如果你想往深度学习方向靠这个贪心内核也能改成特征输入但那就是另一个量级的改造了。4.2 棋型权值表量化威胁度评估的核心是一张权值表描述不同棋型对局面的影响。比如活四两端都开放的四连几乎必胜权值最高冲四一端被封的四连下一步就能成五威胁其次活三如果对手不理下一步会变成活四。权值表按以下方式定义棋型组合标记权值连五51000000活四4两端开放100000冲四4一端封闭10000活三3两端开放8000眠三3一端封闭1000活二2两端开放500眠二2一端封闭100活一1两端开放50代码中用一个MapString, Integer保存这些规则例如11111对应连五011110对应活四011112或211110对应冲四。字符串中0表示空位1表示黑棋2表示白棋。这种表示法非常直观比用分支判断连续数要清晰得多。private static final MapString, Integer PATTERN_SCORES new HashMap(); static { PATTERN_SCORES.put(11111, 1000000); PATTERN_SCORES.put(011110, 100000); PATTERN_SCORES.put(011112, 10000); PATTERN_SCORES.put(211110, 10000); PATTERN_SCORES.put(0101110, 8000); PATTERN_SCORES.put(011100, 1000); // 其余棋型按相同方式补充 }评估单个点时会以该点为中心沿四个方向各截取一段长度为9的字符串含落点本身再匹配这些棋型字符串进行加分。需要注意权值表只是经验参数不同人的AI强弱差异很大程度来自这套参数的调校。你完全可以自己调把活三的权重调高一点AI会更倾向进攻把防守系数调高一点AI会更谨慎。4.3 攻防结合的空点评估与决策AI的决策方法看起来像两倍工作量对每个空点先模拟放上自己的棋子计算进攻得分再模拟放上对手的棋子计算防守得分也就是如果不堵对手下这步能得到多少分。最终分数是进攻分和防守分的加权组合public int[] findBestMove(GameBoard board, int aiPlayer, int humanPlayer) { int bestScore -1; int[] bestMove new int[]{-1, -1}; for (int x 0; x GameBoard.SIZE; x) { for (int y 0; y GameBoard.SIZE; y) { if (board.getPiece(x, y) ! GameBoard.EMPTY) continue; // 模拟AI自己落子计算进攻价值 int attack evaluatePoint(board, x, y, aiPlayer); // 模拟玩家落子计算防守需求 int defense evaluatePoint(board, x, y, humanPlayer); int total attack (int)(defense * 0.9); if (total bestScore) { bestScore total; bestMove[0] x; bestMove[1] y; } } } return bestMove; }这里我把防守分乘了0.9的系数让AI在同等机会下稍微偏向进攻一点。这是一个非常玄学的参数但实战中确实有效纯防守型AI会很被动总是疲于堵人但赢不了棋纯进攻型AI又容易被对手偷家。如果你发现AI总是守不住对手的活三可以把0.9改成1.2让防守权重更高。实际测试时我建议打印每个候选点的攻防分这样能直观看到AI的决策依据比黑盒调参高效得多。5. 避坑记录五子棋开发里的四个翻车现场5.1 棋子画不进交叉点坐标换算漏了边距现象用鼠标点击某个棋盘交叉点棋子却画在旁边一格的位置越靠近棋盘边缘偏移越明显。原因鼠标事件的坐标是相对JPanel的像素坐标而棋子绘制需要的是棋盘行列号。如果没有减去棋盘左边距MARGIN就直接除以格子大小CELL_SIZE得到的索引会比实际大。把像素坐标直接当棋盘坐标使用是初学Swing最常见的错误之一。解决换算公式必须是col (pixelX - MARGIN) / CELL_SIZE并且对棋盘四边留同样的MARGIN。同时要注意画棋子时圆心坐标也要基于MARGIN col * CELL_SIZE保证绘制和点击使用同一套坐标基准。5.2 胜负判断数组越界边界检查的顺序不能换现象两边各连成四子、即将五子连珠时程序突然抛出ArrayIndexOutOfBoundsException或者胜负判断漏判。原因在countInDirection方法里我一开始写的是while (board.getPiece(nx, ny) board.getPiece(x, y))然后在循环体里判断nx 0 nx SIZE。当nx等于15或-1时getPiece先执行数组已经越界了。这也是很多游戏项目里快到边界时崩溃的典型原因。解决边界判断必须放在数组访问之前即while (board.isValidPoint(nx, ny) board.getPiece(nx, ny) player)。Java的有短路机制只要isValidPoint返回false后面的getPiece根本不会执行。这条规则适用于一切数组遍历时的边界检查顺序错了就是血泪教训。5.3 悔棋后AI回合错乱历史栈没有清理现象玩家悔棋后多点了两下棋盘没反应或者AI下一步落子颜色不变棋盘上出现两颗白棋。原因悔棋只清理了玩家最后一颗黑子但AI在玩家悔棋前已经落过白子历史栈里还留着AI那步回合标志位isPlayerTurn仍然是false。游戏状态停留在AI思考中可AI并没有在思考。解决悔棋操作必须同时回退两步先弹出玩家的最后一子再弹出AI的最后一子并把isPlayerTurn重置为true。代码上是这么做的public void undo() { if (history.size() 2) return; // 没有足够步数可悔 restoreLastMove(); // 弹出并清除玩家棋子 restoreLastMove(); // 弹出并清除AI棋子 isPlayerTurn true; panel.repaint(); }顺便提醒如果你的游戏支持双人对战悔棋只需要回退一步。所以实现悔棋之前先想清楚当前模式是单人还是双人否则逻辑会很混乱。5.4 重开对局时老棋子还在棋盘对象没重建现象点重新开始后界面看起来干净了但落第一颗子后棋盘暴露出残留棋子或者AI以为某些位置已经被占据行为完全不可理喻。原因重开功能只是清空了画布但GameBoard内部的board数组没有被重置或者只重置了数组没有清空历史栈。解决最稳妥的做法是直接销毁旧棋盘对象new一个新对象而不是想方设法去清空数组。我在GameController里加了resetGame()方法它做的事只有三行board new GameBoard()、isPlayerTurn true、panel.repaint()。用新对象替代旧对象避免了数组残留和历史栈遗留两个问题这是我最常用的后悔药。6. 二次开发从单人游戏到可存档、可联机的完整应用6.1 存档改造把棋盘序列化为JSON想要保存棋局最简单的方式是把15×15的二维数组逐行拼接成字符串比如用0、1、2表示三种状态一行15个字符中间用逗号分隔。不过我更推荐直接用JSON可读性好调试方便。如果不想引入Gson自己用StringBuilder拼JSON也只需要十行public String toJson() { StringBuilder sb new StringBuilder({\board\:[); for (int x 0; x SIZE; x) { sb.append(Arrays.toString(board[x])); if (x SIZE - 1) sb.append(,); } sb.append(]}); return sb.toString(); }对应地加载存档时解析JSON并逐格回填数组。注意回填后要清空历史栈或者把历史栈也序列化否则悔棋功能会用错误的栈数据。6.2 联机改造用Socket转发落子坐标联机对战的协议比想象中简单客户端A落子后把坐标x,y通过Socket发送给客户端BB收到后在自己的棋盘上落子并重绘。服务端只做一个转发不参与游戏逻辑。我建议在GameController里加入一个RemoteMove接口本地落子和远程落子都走同一套move逻辑只是来源不同。public void onRemoteMove(String message) { String[] parts message.split(,); int x Integer.parseInt(parts[0]); int y Integer.parseInt(parts[1]); if (move(x, y, getRemotePlayer())) { panel.repaint(); } }这里有个坑Socket消息是在非Swing线程到达的直接操作UI组件会违反Swing的单线程规则可能造成界面卡顿或绘制错乱。正确做法是用SwingUtilities.invokeLater(() - handleRemoteMessage(msg))把UI更新切回事件派发线程。6.3 验证与习惯为胜负判断补一个单元测试最后我想分享一个自己的习惯凡是涉及规则判定的代码我都会在改动之前先写几个单元测试。比如构造一个横四连的局面验证checkWin返回true再构造一个散子局面验证返回false。这个测试只需要几行JUnit却能让后续改动有安全感。Test public void testHorizontalWin() { GameBoard board new GameBoard(); for (int y 3; y 6; y) { board.place(7, y, GameBoard.BLACK); } assertTrue(RuleChecker.checkWin(board, 7, 6)); }特别是改AI权值或者存储结构时如果跑一遍测试发现胜负判断挂了就能立刻定位是算法问题还是数据结构问题而不是在GUI上点半天棋盘靠肉眼找原因。从那以后我每改一次规则代码都会强制自己先跑一遍那几条边界测试用例尤其是边界越界和五连判定。这个习惯帮我挡掉了太多玄学bug希望也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑