☰
Java五子棋项目开发详解:从Swing棋盘到AI落子算法
2026/10/17 3:28:31 网站建设 项目流程

简介:面向Java学习者与游戏开发初学者的五子棋项目实现教程,围绕棋盘、棋子、玩家与AI算法展开,完整演示十五路或十九路棋盘的落子、胜负判定、人机对战等核心流程。压缩包共65个文件,大小仅2.73MB,内含14个头文件、12个源文件及多个工程配置与资源素材,如wav音效、bmp与ico图标等,可满足编译运行与二次修改需要。教程涵盖棋盘类、棋子类与玩家类的设计思路,重点解析Minimax算法与Alpha-Beta剪枝在AI落子中的应用,同时介绍基于Swing或JavaFX的事件驱动界面、合法移动验证及边界处理,并附带悔棋、存档、难度调节等扩展功能的实现说明。目前已有205人学习下载,借助该包可系统掌握Java面向对象设计、图形界面构建和基础博弈算法,适合作为课程设计或自学练手项目。

1. Java 五子棋游戏项目完整拆解:从棋盘渲染到 AI 落子,这份资源能省下你一晚上的排错时间

拿到一份标着“Java 五子棋游戏教程”的资源包,别急着双击 jar 乱点棋盘。我复盘过好几版同类项目,真正让人卡住的问题从来不在五子棋规则本身,而在三个细节:坐标换算偏移导致棋子总落不到交叉点上、胜负判定写成全盘扫描导致代码又慢又难维护、AI 在事件线程里跑评估把窗口直接卡到“假死”。这份教程资源恰好把这三块都拆平了:棋盘用 Swing 绘制,15×15 二维数组作数据模型,胜负判定走增量检测,只查落子点附近的四个方向;AI 不做暴力搜索,用评分表加候选点剪枝,防守意识已经能打赢不少乱点的玩家。适合三类人:拿 Java 做课程设计或期末项目的在校生、想系统练一遍 Swing 图形界面和算法配合的开发者,以及想快速接手别人棋局代码的维护者。

2. 棋盘模型与绘制:先把二维数组和像素坐标的换算焊死

五子棋界面看着简单,实际上一动手就会发现问题:棋盘是网格,程序内部是数组,鼠标点下去拿到的是像素坐标,三者之间得有一套统一换算规则。这一章先把地基打好,后面所有逻辑都围绕它转。

2.1 为什么用 Swing 而不是 JavaFX:选型与实际约束

我拆过的几个五子棋教程包,界面部分不约而同都选 Swing,原因很实际:JDK 自带的 javax.swing 包不需要任何第三方依赖,装好 JDK 就能直接编译运行。JavaFX 在新版本 JDK 里已经和标准包分离,要额外配模块路径,对初学者来说光是跑通环境就多一道坎。作为课程设计或练习项目,“能直接跑”比“界面更现代”重要得多。

Swing 的绘图机制也够用:JPanel 上重写 paintComponent,棋盘网格、棋子、坐标标记全在这里画。棋盘不需要精细的贴图资源,画线和画圆就能完成所有渲染。落子后的刷新靠 repaint() 触发,系统会把重绘请求合并到事件队列里批量处理,不会一帧帧地闪烁。资源包里通常还附了运行脚本,双击就能看到棋盘窗口弹出来,这一点对判断“代码有没有跑通”非常友好。

2.2 棋盘数据模型:15×15 数组存三态,常量别散落各处

棋盘的逻辑核心是一个二维数组,每个格子有三种状态:空、黑子、白子。用 int 还是 char 都行,关键是状态值必须用常量,不能写魔法数字。我看过有人直接在代码里写 board[3][5] == 1,过两天自己都不记得 1 是黑还是白。这份教程资源里的做法是定义三个常量,界面逻辑和判断逻辑共用同一套取值。

public class GameBoard { public static final int EMPTY = 0; public static final int BLACK = 1; public static final int WHITE = 2; public static final int SIZE = 15; private int[][] grid = new int[SIZE][SIZE]; public int get(int row, int col) { return grid[row][col]; } public void place(int row, int col, int role) { grid[row][col] = role; } public boolean isEmpty(int row, int col) { return grid[row][col] == EMPTY; } }

这段代码把棋盘数据从界面里剥离出来,AI、胜负判断、悔棋都只跟 GameBoard 打交道,不碰任何 Swing 组件。SIZE 固定为 15,这是标准五子棋棋盘规格,15 条线交叉形成 225 个落子点。place 方法直接在数组上赋值,不需要做边界检查,因为调用方在落子前已经过滤过坐标和状态。

2.3 落子坐标换算:鼠标点上去的过程,误差要控制在半个格子内

棋盘界面里有一个经常翻车的地方:棋盘面板通常有留白,网格不是从 (0,0) 开始的。如果忽略 PADDING,换算出来的行列会整体偏移,越靠右下角偏移越明显。正确做法是建立两组互逆的方法,像素转行列、行列转像素都走同一套公式。

public class BoardRenderer { private static final int CELL = 40; // 每个格子的像素宽 private static final int PADDING = 30; // 棋盘四周留白 public int toPixel(int index) { return PADDING + index * CELL; } public int toIndex(int pixel) { return (pixel - PADDING + CELL / 2) / CELL; } public boolean isValidPoint(int px, int py) { int row = toIndex(py); int col = toIndex(px); return row >= 0 && row < 15 && col >= 0 && col < 15; } }

换算公式的核心是 toIndex 里的“加半格偏移”。鼠标点下去的位置不总是正好落在交叉点上,加 CELL/2 之后再整除,等效于四舍五入到最近的交叉点,误差不超过半个格子。这个细节新手经常遗漏,结果就是棋子像喝醉了一样歪歪扭扭地排成一斜线。isValidPoint 负责拦截越界点击,比如点到留白区或窗口边缘,直接忽略处理,避免落到数组的非法下标上。

3. 胜负判定:增量检测代替全盘扫描,四方向延伸最多四步

五子棋的胜负判定是很多人写崩的第一块。直观想法是每次落子后把整个棋盘扫一遍,统计每个位置的横向、纵向、斜向连续棋子数,超过五个就算赢。这种“每次全盘扫描”的写法不是不能跑,而是做了大量和本轮落子无关的计算——棋盘上大部分区域根本没变化,又得重新数一遍。

3.1 为什么用增量检测:胜负只可能围绕刚落的这颗子展开

新落下的棋子是棋局最新的变化点,胜负条件成立时,这颗棋子必须参与其中。换句话说,检查以落子位置为中心、四个方向上的连续同色棋子数,数量达到五个就判胜。这比全盘扫描少了一个数量级的遍历,15×15 的棋盘上也就是每个方向延伸四格,总共几十次数组访问。判定方法还能顺带知道是哪一方赢了,因为刚落子的角色就是赢家。

这种设计还有一个隐藏优点:判断逻辑不依赖棋盘全貌,只依赖落子点和当前局面,天然适合做单元测试。你可以不启动界面,直接构造一个数组状态,调用判定方法验证结果。后面第六章的验证流程还会用到这个特性。

3.2 四方向延伸实现:从落子点出发,朝两边数到边界为止

每个方向的检查逻辑是固定的:从落子点朝某个方向走,数一数多少颗连续同色棋子,再掉头朝反方向数,两次数值相加,再加上落子本身这一颗,就是该方向上的连续棋子总数。代码里用两个循环分别处理两个方向,比一个循环里来回切换方向清晰得多。

private static final int[][] DIRECTIONS = { {0, 1}, // 水平,列方向 {1, 0}, // 垂直,行方向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; public boolean checkWin(int row, int col, int role) { for (int[] dir : DIRECTIONS) { int count = 1; count += countDirection(row, col, role, dir[0], dir[1]); count += countDirection(row, col, role, -dir[0], -dir[1]); if (count >= 5) { return true; } } return false; } private int countDirection(int row, int col, int role, int dr, int dc) { int count = 0; for (int step = 1; step < 5; step++) { int nr = row + dr * step; int nc = col + dc * step; if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15) { break; } if (board.get(nr, nc) != role) { break; } count++; } return count; }

count 初始值为 1,代表刚落下的这颗棋子自身。每次沿一个方向走一格,遇到同色棋子就 count 加一,遇到空位、对方棋子或棋盘边界立刻停止。走四步是刻意控制的,因为五子棋只需要连续五颗,从落子点出发走到四格以外还没形成五连,这一路就不可能再凑出五颗了。DIRECTIONS 数组里四个方向互不重叠,每个方向单独统计后再汇总,互不影响。

这个实现还有一个值得注意的参数细节:step 从 1 开始而不是从 0 开始。如果从 0 开始,等于把落子点本身算了两遍,count 会出现虚高,四连会被误判成五连。这种错误在代码审查阶段很难发现,但一旦出现,对战表现为“明明只连了四颗却判我赢”,非常打击信任度。教程资源里把 countDirection 和 checkWin 分开封装,目的就是让这种基础逻辑单独可测。

3.3 平局判定与长连规则:>=5 和 ==5 的背后是规则分歧

胜负判定写完,还得处理两个边界情况。第一个是棋盘下满但仍未分出胜负,这就是平局,需要在落子循环里检查剩余空位数,归零时停止对局。第二个是长连规则:如果双方约定“五子连珠即为胜”,那么连续六颗甚至七颗也应该算赢,判定条件要写成 count >= 5 而不是 count == 5。有些带禁手规则的对局会禁止黑棋形成长连,但普通对局教程不会做这么深的规则限制。

我在资源里看到的惯例是:判定方法返回 boolean,调用方在落子后立即检查。如果赢,弹窗显示赢家并禁用棋盘;如果棋盘已满,提示平局。这两个分支分别处理,避免赢棋提示被平局提示覆盖。曾有开发者把两个判断写成 else 关系,结果最后一子既触发胜利又触发平局,弹了两个对话框。解决方式很简单:先判胜,胜局拿到结果后直接 return,不再走平局分支。

4. 人机对战 AI:评分表加候选点剪枝,零搜索也能有章法

五子棋的人机 AI 是这份资源里最让人好奇、也最容易写跑偏的部分。有人一上来就想上极大极小搜索,写着写着递归深度太深算到天荒地老。这个教程包的做法务实得多:不做搜索树,只用单层评分表,也能让 AI 具备堵活三、占活四的基本对战能力。

4.1 先算清楚账:单层评估为什么够用

15×15 棋盘上空位超过两百个,MINIMAX 哪怕只搜两层,每次也要评估几百个落子点,每个点还要算四个方向的棋型,计算量瞬间爆炸。Swing 事件线程又承担着界面刷新,稍不留神窗口就“假死”。这份资源把 AI 的定位设成“让玩家有对抗感”,而不是“虐杀所有人类”,单层评分表完全够用。

单层评估的逻辑是:对每个候选空位,假设 AI 在这里落子,计算它形成的棋型价值;同时假设对手在这里落子,计算对手形成的棋型价值;两者按权重相加,选总分最高的点。进攻价值高了会导致 AI 只顾自己连子不管对手,防守权重高了又会变成纯堵截打不死人。参数调校是这章最后要说的重点。

4.2 棋型评分表:把连续棋子数和开放端转成分数

评估的核心是计算“如果某方在此落子,四个方向上分别能形成什么棋型”。一个方向的棋型质量由两个参数决定:连续同色棋子的数量,以及该方向两端是否仍为空位——也就是开放端的数量。开放端是活的棋型,两端都被堵的死棋型价值大打折扣。

public int evaluatePoint(int row, int col, int role) { if (!board.isEmpty(row, col)) { return -1; } board.place(row, col, role); int score = 0; for (int[] dir : DIRECTIONS) { int[] line = buildLine(row, col, dir); score += lineScore(line, role); } board.place(row, col, EMPTY); // 评估完必须还原棋盘 return score; } private int[] buildLine(int row, int col, int[] dir) { int[] line = new int[9]; int center = 4; line[center] = board.get(row, col); for (int step = 1; step <= 4; step++) { line[center + step] = getSafely(row + dir[0] * step, col + dir[1] * step); line[center - step] = getSafely(row - dir[0] * step, col - dir[1] * step); } return line; } private int lineScore(int[] line, int role) { int center = 4; int count = 1; int openEnds = 0; for (int step = 1; step < 5; step++) { if (line[center + step] == role) { count++; } else { if (line[center + step] == EMPTY) { openEnds++; } break; } } for (int step = 1; step < 5; step++) { if (line[center - step] == role) { count++; } else { if (line[center - step] == EMPTY) { openEnds++; } break; } } return patternScore(count, openEnds); } private int patternScore(int count, int openEnds) { if (count >= 5) return 100000; if (count == 4 && openEnds == 2) return 10000; if (count == 4 && openEnds == 1) return 8000; if (count == 3 && openEnds == 2) return 5000; if (count == 3 && openEnds == 1) return 800; if (count == 2 && openEnds == 2) return 300; if (count == 2 && openEnds == 1) return 50; return 0; }

buildLine 取出以落子点为中心、左右各四格的一条线,长度 9,越出棋盘边界的位置用 EMPTY 填充,这样后续统计时边界当作开放端处理,不会因为下标越界报错。这个“边界视作开放端”的细节要留意:靠近棋盘边缘的三四连和下在正中间的活三是同等价值,实战中边缘棋型往往更难被堵死,评分略微偏高没有大问题。

lineScore 统计中心点两边的连续同色棋子数,遇到第一种非同类棋子就停下,并记录这端是开放还是被堵。这里的 count 没有把两步之间断开的情况算进去,比如“黑黑白黑”这种断层棋型,它只会计最靠近中心的连续段。对单层评分来说足够了,真要支持跳子棋型会让实现复杂两个量级,收益却很低。

4.3 候选点剪枝:不扫全盘,只看有棋子的邻域

评分函数写完后,如果对 225 个位置全部跑一遍评估,虽然不至于卡死,但明显浪费。实战中没人会把棋子下到离战场十万八千里的位置,候选点只需要围绕棋盘上已有的棋子展开。资源包里的做法是只检查已有棋子周围一小圈未落子的位置,棋盘越下到中盘,这个剪枝节省的计算量越大。

public int[] findBestMove(int aiRole) { int bestScore = Integer.MIN_VALUE; int bestRow = -1, bestCol = -1; for (int row = 0; row < SIZE; row++) { for (int col = 0; col < SIZE; col++) { if (!board.isEmpty(row, col) || !nearPiece(row, col)) { continue; } int attackScore = evaluatePoint(row, col, aiRole); int defendScore = evaluatePoint(row, col, opposite(aiRole)); int totalScore = (int)(attackScore * 1.1 + defendScore * 0.9); if (totalScore > bestScore) { bestScore = totalScore; bestRow = row; bestCol = col; } } } return new int[]{bestRow, bestCol}; } private boolean nearPiece(int row, int col) { for (int dr = -2; dr <= 2; dr++) { for (int dc = -2; dc <= 2; dc++) { int nr = row + dr; int nc = col + dc; if (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE && !board.isEmpty(nr, nc)) { return true; } } } return false; }

nearPiece 检查以当前空位为中心、5×5 范围里是否存在任意棋子。这个范围是合理的折中:比 3×3 更能捕捉到稍远的连续性棋型,比 7×7 又不会把太多无关空位拉进候选集合。棋盘刚落第一颗黑子时,白方候选点只有那一点周围一小圈,开局响应速度非常快。

4.4 权重调参:进攻 1.1、防守 0.9,先保证不输再谈赢

AI 的落子口味由 attackScore 和 defendScore 的系数决定,这是典型的“调参玄学”环节。进攻权重高于防守权重的意思是,当某个点对 AI 形成活三的威胁比对对手形成的威胁更严重时,AI 会选择抢先进攻而不是去堵别人。这个配比在普通对局里表现是“有攻有守”,大多数玩家能明显感受到压力。

但有个情况必须要防:如果进攻权重太高,AI 可能会无视对手已经活四的点,只顾自己冲四,结果对手下一步直接五连带走。我的习惯是在 findBestMove 开头加一个紧急拦截判断:如果对手在任何一个方向已经形成四连,AI 必须第一时间下在对应的堵截点,不再走评分流程。教程资源里的基础版本没加这个逻辑,你拿到后可以自行补充——它本质上是把“冲四威胁”的优先级强行拉到最高。

5. 常见问题排查:线程假死、坐标偏移与悔棋翻车实录

任何代码写成后都会撞上各种怪问题,五子棋项目里最典型的就是这一章这几个。每个坑我都标了现象、原因和解决手段,照着排查能省不少时间。

5.1 点击落子后界面卡死:AI 在事件线程里跑评分

现象:点下鼠标,棋子没显示,窗口变成“白板”,过几秒或十几秒才恢复。连点几次后系统提示“程序未响应”。

根子:Swing 的鼠标事件和 repaint 都在事件分发线程(EDT)里排队执行。AI 评分是 CPU 密集运算,如果直接在 mouseClicked 回调里调用 findBestMove,整个界面的重绘事件全被堵在后面,窗口自然像死掉一样。

解决:常见做法是把 AI 计算扔到后台线程,计算完成后通过 SwingUtilities.invokeLater 回到 EDT 更新界面。代码上不复杂,重点是要形成习惯:凡是超过几十毫秒的计算,一律不直接放在事件回调里。教程资源里用了一个最朴素的 Thread 包一层,算完后调 boardPanel.repaint()。

5.2 棋子总是画偏:PADDING 参与了换算却没人统一

现象:鼠标点在网格交叉点上,画出来的棋子圆心偏离交叉点几个像素,越往棋盘右下角偏得越厉害。

根子:像素转行列的公式和行列转像素的公式用了不同的 PADDING 值,或者其中一个忘了加半格偏移。最常见的是 toIndex 里减了 PADDING,toPixel 里却用的是另一个留白变量;两边各差几个像素,累积下来就是十几像素的偏移。

解决:把 PADDING 定义成同一个常量,两个方向的计算都用它。toIndex 保留“加半格再整除”的取整方式,toPixel 直接乘格子数加留白。改完画一条参考线验证:鼠标移到棋盘四个角落的交叉点,落子位置必须和交叉点完全重合。

5.3 悔棋之后 AI 不落子:历史栈只记了一半

现象:玩家悔一步棋后,棋盘上棋子消失,但 AI 不动了。再点一步,AI 才开始响应。

根子:悔棋逻辑只弹出了玩家上一次落子,没同时处理 AI 的上一步棋。五子棋是双方轮流落子,悔棋要撤销两步才能回到“玩家行动前”的状态。历史栈如果只压入玩家落子、AI 落子直接改棋盘数组,悔棋时栈里已经没有 AI 的位置信息了。

解决:每次落子都往栈里压入包含行、列、角色三个字段的记录,AI 落子也不例外。悔棋时连续弹出两步,把对应的数组位置恢复为 EMPTY。这个记录结构在后面做“重新开局”也能复用——把栈清空并重置数组,比一遍遍遍历恢复残局优雅得多。

5.4 双方各成四连时误判胜局:方向循环没在对方棋子处停下

现象:对局中出现离奇胜利——玩家右上角明明只有三连,系统却提示赢了。

根子:写 countDirection 时少了对“对方棋子”的处理,直接循环取下一个位置判断,遇到对方棋子没有 break,而是继续往后数。棋盘数组被割裂开的两段同色棋子被算进了同一个 count,三连加两连被拼成五连。

解决:循环里判定顺序必须是:先检查越界,再看当前格是否等于目标角色,不等于目标角色一律 break。这里也暴露了一个测试盲区:胜利判定要多写几个反例用例,专门构造“己方棋子中间夹着对手棋子”的棋型,验证系统不会乱判。这是所有五子棋项目里最应该补的测试。

5.5 “再来一局”后棋盘上还有残留:数组与界面没有同步重置

现象:点了“新游戏”,界面还有上局留下的棋子;再点落子,AI 的行棋逻辑开始出现很奇怪的选点。

根子:重置逻辑只调用了 repaint(),没清理底层二维数组。界面画出来的数据还在,只是之前被新对局盖住了,一旦有新棋子落下来,旧棋子参与评分和判定,整个局面就乱套了。

解决:重置顺序必须是先清数组、再清历史栈、最后调 repaint()。清数组用双层循环重新赋 EMPTY,不要 new 一个新数组再替换,因为界面里可能有人持有了旧数组的引用。把这套顺序写进 startNewGame 方法里,按钮、菜单项、快捷键都调用同一个入口,避免重置逻辑分散成多处互相覆盖。

6. 落地验证与打包:判定逻辑先测过,再封装成双击可跑的 jar

教程资源拿到手,最后要做两件事:验证核心逻辑没被界面代码带偏,以及把工程打成可直接运行的 jar。这两个步骤做完,这份源码才算真正落地到你自己手上。

先写一个不依赖窗口的单元测试,把胜负判定单独拉出来验证。由于 GameBoard 和 checkWin 都不碰 Swing 组件,测试可以直接构造棋局状态:

public class GameCoreTest { @Test public void testHorizontalWin() { GameBoard board = new GameBoard(); for (int col = 2; col <= 5; col++) { board.place(7, col, GameBoard.BLACK); } assertTrue(GameCore.checkWin(board, 7, 5, GameBoard.BLACK)); } @Test public void testNoWinWhenBroken() { GameBoard board = new GameBoard(); board.place(7, 2, GameBoard.BLACK); board.place(7, 3, GameBoard.BLACK); board.place(7, 4, GameBoard.WHITE); board.place(7, 5, GameBoard.BLACK); assertFalse(GameCore.checkWin(board, 7, 5, GameBoard.BLACK)); } }

只要这种测试能跑通,判定代码就不会出现“夹心棋型乱判胜”这种低级漏洞。

打包阶段,工程不依赖第三方库的话,用 JDK 自带命令就可以完成,不需要引入 Maven 或 Gradle:

javac -encoding UTF-8 -d out src/com/course/gobang/*.java jar cfe gobang.jar com.course.gobang.GameMain -C out . java -jar gobang.jar

javac 指定输出目录到 out,把所有源码编译进去。jar 的 cfe 参数里,“e”指定主类入口,主类必须是包含 main 方法的那个类,写错会在双击运行时直接报“找不到主类”。-C out . 表示把 out 目录下的内容作为 jar 根目录。打包完成后,把 gobang.jar 和一份运行说明放在同一层目录,整个项目就算交付了。

最后说一个我的习惯:任何五子棋相关改动,做完都会先跑一遍那组判定测试,再打开界面手动走几手。有一次为了调 AI 攻防系数,硬是把一个四连误判成五连的 bug 放进了发布包,玩家玩到一半“提前获胜”,那叫一个尴尬。从那以后我每次改完都要把测试和手工验证强制走一遍,确认无误再整理打包。希望你把这份资源拿到手后,也能先跑测试再玩界面,这样能避开我踩过的坑,直接看到棋盘和 AI 都正常工作起来。

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

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

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

立即咨询