Java五子棋游戏教程:Swing界面、AI评分与悔棋实现全解析 简介这是一份面向Java初学者与游戏开发入门者的五子棋实现教程以可运行的完整项目演示如何从零构建双人对战与人机对战游戏覆盖棋盘建模、棋子逻辑、玩家交互到AI决策的完整开发链路。内容具体讲解棋盘类、棋子类和玩家类的面向对象设计15x15棋盘的落子与五子连珠胜负判定基于Swing的图形界面与鼠标事件处理以及AI中常用的Minimax和Alpha-Beta剪枝算法并通过搜索深度限制来平衡AI强度与响应速度。配套资源包共65个文件大小约2.73MB除源代码外还包含可执行程序、音频.wav、位图.bmp、图标.ico和工程配置.ini等解压后可直接运行体验或对照调试。针对边界移动校验、悔棋、存档、难度分级等常见需求代码给出了完整实现思路也梳理了事件驱动游戏循环的关键步骤。已有205人学习下载内容结构清晰适合作为课程设计或编程训练参考。1. Java五子棋游戏教程一个能跑通、能改、能答辩的Swing项目如果你正打算找一套 Java 五子棋游戏教程用来做课程设计、补 Swing 短板或者给简历添一个完整项目那这套东西值得你花半小时认真拆一遍。它不只是一个会弹窗的“画棋盘Demo”而是把双人对战、悔棋、AI 落子、胜负判定全部串起来的完整工程界面是 Swing 写的棋盘用二维数组做核心数据结构AI 走的是棋型评分加贪心选点路线。我拿到手第一件事不是看代码而是直接编译跑起来确认它能开局、能悔棋、能赢。适合三类人被课设要求卡住的学生、想用非框架方式理解 Java GUI 的开发者、以及要给 AI 思路做验证的入门者。2. 棋盘与落子先搭一个不抖不花屏的Swing界面五子棋这类棋盘游戏界面层最容易出现的问题不是布局而是坐标换算和重绘。很多新手把棋子画到格子线上、画到棋盘外面或者一刷新整张棋盘就闪。这一章先把棋盘数据结构定下来再把鼠标点击和绘制流程讲清楚。2.1 棋盘数据结构与状态常量棋盘我建议直接用int[][]二维数组不要用布尔数组。布尔数组只能表达“有没有子”表达不了“这个子是谁的”五子棋后面所有判胜、AI、悔棋逻辑都要区分黑白双方一个三态数组从第一行代码开始就省很多事情。public class Board { public static final int GRID_SIZE 15; // 15x15 标准五子棋棋盘 public static final int EMPTY 0; // 空位 public static final int BLACK 1; // 黑子 public static final int WHITE 2; // 白子 private final int[][] grid new int[GRID_SIZE][GRID_SIZE]; public boolean isPlaceable(int row, int col) { return row 0 row GRID_SIZE col 0 col GRID_SIZE grid[row][col] EMPTY; } public void place(int row, int col, int player) { grid[row][col] player; } public void remove(int row, int col) { grid[row][col] EMPTY; } public int get(int row, int col) { return grid[row][col]; } }三个常量分别用 0、1、2 表示空位、黑子、白子。isPlaceable先做越界检查再做空位检查这个顺序很关键后面 AI 和鼠标事件都会大量调用它。place和remove是对数组操作的封装这样上层调用方不必关心二维数组下标写法也能避免直接把数组暴露出去被外部乱改。2.2 鼠标事件与坐标换算Swing 里鼠标坐标是像素值棋盘交叉点是行列索引两者必须通过格子尺寸换算。常见做法是棋盘四周留出边距交叉点之间的间距固定为CELL_SIZE然后用像素除以格子宽度得到行列号。我一般会在换算时加上一半格子宽度做四舍五入避免点击在两个交叉点中间时落到错误位置。int margin 24; // 棋盘左侧和上侧的留白像素 int cellSize 40; // 两个交叉点之间的距离单位像素 int col (e.getX() - margin cellSize / 2) / cellSize; int row (e.getY() - margin cellSize / 2) / cellSize; if (board.isPlaceable(row, col)) { board.place(row, col, currentPlayer); history.push(new Step(row, col, currentPlayer)); if (checkWin(row, col)) { showWinner(currentPlayer); } else { switchPlayer(); } repaint(); }这里e.getX()和e.getY()是鼠标相对当前组件左上角的坐标。减掉margin是因为棋盘网格不是从组件原点开始的。加cellSize / 2是为了做就近取整比如点击位置换算下来是 6.6会按 7 处理。棋盘可视区域全部落在margin到margin cellSize * 14这个区间内点击区域超出这个范围时不满足isPlaceable不会误落子。2.3 双缓冲与 paintComponent 重绘Swing 默认开了双缓冲但在paintComponent里要注意别做耗时操作或反复创建对象。棋盘的每次重绘都应该走同一个流程先画网格线再遍历二维数组把所有棋子一次性画出来。不要在鼠标事件里直接调用getGraphics()去画一个棋子那样刷新窗口、最小化或者拖动时棋盘会残缺。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setColor(new Color(220, 179, 92)); // 棋盘底色 g2.fillRect(margin - 10, margin - 10, cellSize * 14 20, cellSize * 14 20); g2.setColor(Color.BLACK); for (int i 0; i Board.GRID_SIZE; i) { g2.drawLine(margin i * cellSize, margin, margin i * cellSize, margin 14 * cellSize); g2.drawLine(margin, margin i * cellSize, margin 14 * cellSize, margin i * cellSize); } for (int r 0; r Board.GRID_SIZE; r) { for (int c 0; c Board.GRID_SIZE; c) { int stone board.get(r, c); if (stone Board.EMPTY) continue; g2.setColor(stone Board.BLACK ? Color.BLACK : Color.WHITE); g2.fillOval(margin c * cellSize - cellSize / 2 2, margin r * cellSize - cellSize / 2 2, cellSize - 4, cellSize - 4); } } }fillRect的边界要覆盖网格最外圈否则边缘棋子会画在棋盘底色外面。drawLine时两个端点的间距必须是14 * cellSize因为 15 条线围出 14 个格子。棋子圆心应正好落在交叉点上所以用margin c * cellSize - cellSize / 2 2这个偏移量加 2 是为了让棋子稍微内缩、边框线不被完全盖住。这套绘制逻辑在新增棋子、悔棋、重新开始时都被触发只要保证重绘入口统一界面就不会出现闪烁或残留。3. 胜负判定与悔棋把对局逻辑做成闭环棋盘能落子之后下一个绕不开的点就是“怎么知道谁赢了”和“下错了怎么反悔”。这两块逻辑看着小实际是后面 AI 能否立住的地基。AI 评估棋型时也要反复用到方向扫描的思路所以判胜算法越干净后续就越省事。3.1 四方向连珠检测五子棋的胜利条件是任意方向出现连续五个己方棋子。检测时只需扫描四个方向水平、垂直、主对角线、副对角线。因为连子本身没有方向性从某个落点出发往两端都数一遍就能覆盖所有可能情形。private boolean checkWin(int row, int col) { int player board.get(row, col); int[][] directions { {1, 0}, // 水平方向 {0, 1}, // 垂直方向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; for (int[] d : directions) { int count 1; for (int step 1; ; step) { int r row d[0] * step; int c col d[1] * step; if (r 0 || r Board.GRID_SIZE || c 0 || c Board.GRID_SIZE) { break; } if (board.get(r, c) ! player) { break; } count; } for (int step 1; ; step) { int r row - d[0] * step; int c col - d[1] * step; if (r 0 || r Board.GRID_SIZE || c 0 || c Board.GRID_SIZE) { break; } if (board.get(r, c) ! player) { break; } count; } if (count 5) { return true; } } return false; }count初始为 1因为当前落子本身就算一个连子。每次向一个方向步进时先检查越界再检查棋子颜色顺序反了会导致数组下标越界。四个方向只要有一个方向的累计连续数量达到 5 就说明当前玩家获胜。这个算法的时间复杂度是 O(1)因为步进最多 14 次对每次落子都调用也毫无压力。3.2 栈式悔棋设计悔棋的本质是撤销最近落子。Swing 应用里的一个常见错误是只把棋盘数组里的当前棋子移除却不恢复当前玩家、不把 AI 的上一手一起撤销。五子棋一回合有两个动作悔棋必须把“当前玩家最后一步”和“对手刚走的一步”同时弹出来棋局状态才正确。ArrayDequeStep history new ArrayDeque(); public static class Step { int row, col, player; public Step(int row, int col, int player) { this.row row; this.col col; this.player player; } } public boolean undo() { if (history.size() 2) { return false; } Step last history.pop(); Step prev history.pop(); board.remove(last.row, last.col); board.remove(prev.row, prev.col); currentPlayer prev.player; return true; }这段代码把history当成一个栈来用push在每次落子后执行pop取出最近两步。栈顶是刚下的那手第二层是上一手两个都撤掉之后currentPlayer恢复成倒数第二步的玩家这样轮次才对得上。如果只想撤销一步比如人机对战时只撤销玩家的一步就把 AI 的步单独记录但双人模式下按两格撤回更合理。history.size() 2的检查防止开局时无功而返。3.3 落子、判胜、换手的状态机对局闭环不只是落子和判胜更准确地说是一个状态机当前玩家落子、检查胜利、若有人获胜则结束、否则切换玩家。这个流程必须在同一个入口里串行执行不能把换手逻辑散落在鼠标事件、AI 回调、按钮事件中否则会出现“同一方连下两子”这种低级问题。public void playOneTurn(int row, int col) { if (gameOver || !board.isPlaceable(row, col)) { return; } board.place(row, col, currentPlayer); history.push(new Step(row, col, currentPlayer)); if (checkWin(row, col)) { gameOver true; statusLabel.setText(黑子获胜 (currentPlayer Board.BLACK ? 1 : 2)); return; } currentPlayer (currentPlayer Board.BLACK) ? Board.WHITE : Board.BLACK; if (currentPlayer Board.WHITE aiEnabled) { Point aiMove ai.nextMove(board); playOneTurn(aiMove.row, aiMove.col); } }playOneTurn是一个统一的动作入口鼠标点击和 AI 落子都走它。落子后立刻检查胜负获胜则置gameOver并停止继续换手。AI 回合的触发条件放在人类回合结束后而不是放在鼠标事件里单独调用这样逻辑不会出现竞态。aiEnabled控制是否开启人机模式关闭后就变成双人同屏对战一套代码覆盖两种玩法。4. AI落子基于棋型评分的贪心选点五子棋 AI 的入门路线不是深度学习也不是开局库而是“给每个空位打分、选分最高的位置下”。这个方法在中小型项目里非常够用而且对新人特别友好因为它把棋力转化为一张棋型表权重调一调棋风就变了你能直观感受到评分算法的效果。4.1 棋型打分AI 要落哪个位置取决于该位置对攻防两端的贡献放在这里能不能帮自己形成活四、冲四、活三同时能不能堵住对方的活三、冲四。一种容易理解和调试的做法是给不同棋型定权重数值差距拉开AI 才会优先做关键动作。public class ScoreTable { public static final int LIVE_FOUR 100000; // 活四两端都不堵 public static final int RUSH_FOUR 10000; // 冲四一端被堵 public static final int LIVE_THREE 8000; // 活三再下一步变活四 public static final int SLEEP_THREE 1000; // 眠三形成冲四 public static final int LIVE_TWO 200; // 活二潜在的三 public static final int SLEEP_TWO 50; // 眠二 }权重的单位不需要物理意义只需要拉开量级。比如活四给 100000冲四给 10000这样 AI 在有活四点可选时几乎不会去下别的低价值位置。实际项目中我常看到新人把权重设成 1、2、3、4、5AI 就表现得很“贪吃”总选最近的一粒棋而不是最关键的棋。拉开量级是让 AI 变聪明的第一步。4.2 对空位遍历打分有了棋型定义下一步就是对棋盘上每个空位做四方向扫描。扫描逻辑和判胜类似但这里不只要数连子数量还要看两端是否被堵从而确定棋型是“活”还是“眠”。private int evaluatePoint(int row, int col) { if (board.get(row, col) ! Board.EMPTY) { return 0; } int totalScore 0; for (int[] d : directions) { totalScore evaluateDirection(row, col, d, Board.BLACK); totalScore evaluateDirection(row, col, d, Board.WHITE); } return totalScore; } private int evaluateDirection(int row, int col, int[] d, int player) { int count 1; boolean blocked1 false; boolean blocked2 false; for (int step 1; ; step) { int r row d[0] * step; int c col d[1] * step; if (isOutOfBounds(r, c)) { blocked1 true; break; } if (board.get(r, c) ! player) { if (board.get(r, c) ! Board.EMPTY) blocked1 true; break; } count; } for (int step 1; ; step) { int r row - d[0] * step; int c col - d[1] * step; if (isOutOfBounds(r, c)) { blocked2 true; break; } if (board.get(r, c) ! player) { if (board.get(r, c) ! Board.EMPTY) blocked2 true; break; } count; } if (blocked1 blocked2) return 0; if (count 5) return ScoreTable.LIVE_FOUR; if (count 4 !blocked1 !blocked2) return ScoreTable.LIVE_FOUR; if (count 4) return ScoreTable.RUSH_FOUR; if (count 3 !blocked1 !blocked2) return ScoreTable.LIVE_THREE; if (count 3) return ScoreTable.SLEEP_THREE; if (count 2 !blocked1 !blocked2) return ScoreTable.LIVE_TWO; if (count 2) return ScoreTable.SLEEP_TWO; return 0; }这里evaluatePoint把某个空位分别当成黑子和白子试放累加两个颜色下的分数。分数越高说明这个位置“对己方越有利、对对手越有威胁”。evaluateDirection数出的连子数和两端阻挡情况共同决定棋型等级。值得注意的是count 5直接返回活四分数这个点位实际上已经必胜了。由于每个空位最多扫描四个方向、每个方向最多 14 步整个棋盘 225 个空位的总耗时在毫秒级完全不需要优化。4.3 AI 选点与防守权重单次评分的 AI 本质上是个“贪婪策略”在当前局面下找一个综合分最高的空位。它不会想三步以后的事但因为权重表已经包含了攻防信息所以实战中已经能压制完全随机落子的新手。为了让 AI 不只会进攻而忽略防守评分时必须把对方颜色的棋型也纳入计算——也就是evaluateDirection中分别用BLACK和WHITE各算一遍的原因。public Point nextMove(Board board) { int bestScore -1; int bestRow -1; int bestCol -1; for (int r 0; r Board.GRID_SIZE; r) { for (int c 0; c Board.GRID_SIZE; c) { int score evaluatePoint(r, c); if (score bestScore) { bestScore score; bestRow r; bestCol c; } } } return new Point(bestRow, bestCol); }当 AI 落在左上角时纯进攻评分会偏向自己颜色连子多的区域导致 AI 看不见对手的威胁。把黑白两色的评分同时加起来就能让一个位于“对手活三中间”的空位获得很高分数AI 会优先去堵棋而不只是铺自己的路。防守权重的本质就是“对手分高的位置我也要让它对我分更高”的对称逻辑。若队伍要求 AI 棋力再高一档那就是第六章会说到的深度搜索而非换评分表。5. 避坑五子棋Swing开发中常见的四类翻车现场我拆过不少新人提交的 Java 五子棋作业下面这四类问题出现频率最高。每一条都是实际跑出来的血泪经验把现象、原因和解决办法写清楚你踩坑时可以对着排查。5.1 落子位置总是斜一格或偏到格线上现象鼠标点在一个交叉点上棋子却跑到旁边一格或者落在两条线之间。原因坐标换算公式错误。常见写法是int col e.getX() / cellSize直接拿像素值除以格子宽度没有先减去棋盘边距或者虽然减了边距但没有加cellSize / 2做四舍五入导致点在交叉点左侧时会落到左边格子。解决把公式固定成col (e.getX() - margin cellSize / 2) / cellSize。margin是网格起点相对组件左上角的偏移cellSize / 2用来做就近取整。注意margin不要设成 0否则边缘棋子的圆心会贴在组件边上视觉上不对称。5.2 窗口拖动后棋盘部分消失或棋子残缺现象窗口最小化恢复后棋盘网格线还在但棋子少了几个或者整块棋盘变白。原因重绘逻辑没有走paintComponent而是在鼠标落子事件里用getGraphics()画了一个棋子。getGraphics()拿到的画布不会被系统重绘窗口一旦被遮挡或尺寸变化系统只会重新触发paintComponent之前临时画的内容全部丢失。解决所有绘制动作都收敛到paintComponent里。鼠标事件只改数据模型棋盘数组和悔棋栈然后调用repaint()。Swing 会在事件队列空闲时自动重绘整个组件数据模型与显示分离后这类问题就能根治。5.3 胜负判断漏判或误报现象明明已经有五连子却提示未获胜或只有四连子就提示获胜。原因判胜循环里count的初始化位置不对。有人把count 1写在方向循环外面导致每次累计多条方向的连子数四连子加一个别的方向棋子也能凑成 5。还有人在反向扫描时忘了重置count把正反向的连子数重复叠加。解决每个方向扫描前单独初始化count 1正反向扫描共用同一个count累计结果只属于当前方向。判胜函数返回后立刻用break结束状态切换避免赢棋后玩家还能继续落子。调试时可以在checkWin里临时打印row、col和返回值比对棋盘实际局面。5.4 悔棋后轮次错乱玩家或 AI 多下一手现象点击悔棋后棋盘上撤掉了两个子但接下来 AI 又立刻走了一步再轮到自己时连续下了两手。原因悔棋逻辑只撤销了一个棋子或者撤销棋子后没有恢复currentPlayer。还有的人在undo()里把 AI 落子单独弹出但人类的落子保留着导致本轮没走完就到了下轮。解决使用栈保存每一步完整的落子信息包括行列和玩家标识悔棋时一次弹出两步并让currentPlayer恢复到倒数第二步的玩家。在方法开头加history.size() 2的兜底判断避免开局空栈时pop抛异常。出手测试时连续悔棋三次以上再对弈观察轮次是否始终合法。5.5 AI 分数变态高却下了个废棋现象AI 选中某点时的评分高达几十万落下去之后既没有形成必胜也没堵住对手的冲三。原因评分函数没有判断棋子落点与连子之间的“实际走势”。一种典型误判是把相隔很远的棋子通过某个方向硬凑成连子因为中间隔了空位也能被count累计。五子棋的连子要求紧密相邻中间隔空位就不算“活”或“眠”棋型。解决在扫描方向时遇到空位先继续步进但把blocked状态区分成“被对手堵住”和“被空位中断”两种。更简单的做法是限定向一个方向扫描的连续步数扫到空位就停止不把空位当作可跳过的落点。给棋型打分前先用手工摆出的活三、冲四局面做单测分数必须和预期一致再整体联调。6. 进阶给 AI 加一重搜索验证你的下棋正确性单点评分算法虽然能跑但在关键对攻局面容易“只盯着眼前一步”。想确认这套 AI 的棋力上限一个可落地的做法是给它的候选点排序然后只在前五个候选点里做两层深度搜索模拟自己下一步、对手再下一步后重新计算局面总分。深度不用开太大两层就能显著减少“送活三”这种失误。private ListPoint topCandidates(int limit) { return allEmptyPoints().stream() .sorted((a, b) - evaluatePoint(b) - evaluatePoint(a)) .limit(limit) .collect(Collectors.toList()); } private int search(int depth, int currentPlayer) { if (depth 0) { return evaluateBoard(); } ListPoint candidates topCandidates(depth 1 ? 1 : 5); int best Integer.MIN_VALUE; for (Point p : candidates) { board.place(p.row, p.col, currentPlayer); best Math.max(best, -search(depth - 1, opponent(currentPlayer))); board.remove(p.row, p.col); } return best; }topCandidates用单点评分先过滤掉大概率没用的点再对每个候选点做深度搜索。-search是对手视角取负值因为“对手最好的结果”等于“自己最差的结果”。这个写法不需要引入复杂的 alpha-beta也能让 AI 看到两步棋后的走向。验证时我喜欢做一件事把 AI 同时当作黑方和白方关掉界面自动对弈二十局统计黑方胜率。如果 AI 执黑胜率低于七成问题多半出在评分表的防守权重上。还可以故意摆出“活三被堵一头”的局面让 AI 走一步看它能不能马上找到补成冲四的位置。从那以后我每次给 AI 调完参数都强制跑一遍盲打对局不然棋盘上看着聪明一换先手就露馅。这套思路同样能移植到你的五子棋项目里找到手感了再回头调评分表希望帮到你。本文还有配套的精品资源点击获取