☰
Java五子棋人机对战项目:从Swing界面到权值AI完整实现
2026/10/17 15:41:43 网站建设 项目流程

简介:一份围绕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判定胜负、检查越界和重子GameBoard
AIEngine评估空点分数,返回落子坐标GameBoard
BoardPanel绘制棋盘和棋子,捕获鼠标点击GameBoard
GameController串联玩家落子、胜负判断、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.Stack<int[]> 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

代码中用一个Map<String, Integer>保存这些规则,例如"11111"对应连五,"011110"对应活四,"011112"或"211110"对应冲四。字符串中0表示空位,1表示黑棋,2表示白棋。这种表示法非常直观,比用分支判断连续数要清晰得多。

private static final Map<String, 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发送给客户端B;B收到后在自己的棋盘上落子并重绘。服务端只做一个转发,不参与游戏逻辑。我建议在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",希望也能帮到你。

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

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

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

立即咨询