ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java五子棋游戏开发实战:从棋盘建模到AI对战全解析

Java五子棋游戏开发实战:从棋盘建模到AI对战全解析

简介:面向Java开发者的五子棋游戏学习工程包,覆盖面向对象设计、Swing图形界面构建、AI博弈算法和完整项目开发流程,并给出可直接运行的程序示例。包内已实现棋盘类、棋子类、玩家类以及基于极小极大算法与Alpha-Beta剪枝的电脑AI,代码包含合法落子判断、五子连珠检测、人机对战、悔棋、保存与加载进度等功能,并附带一份论文式教程文档,可引导读者从零搭建项目,理解递归搜索、评估函数与剪枝优化等核心机制。资源共65个文件,以14个头文件、12个C++源文件、11个目标文件为主体,同时包含可执行程序、音效、位图资源、图标、配置文件和Visual Studio工程文件,目录结构清晰,方便查看源码、编译调试或直接运行体验。整个RAR压缩包仅2.73MB,已有205人学习下载,适合用作课程设计、毕业设计或Java进阶练手项目。

1. 从能玩到能赢:Java 五子棋游戏到底要拆成几块

java 五子棋游戏大概是课程设计里出镜率最高的小项目,但多数人第一次写完只做到「能落子」,悔棋和电脑对手都做得稀碎。我拆的这套资源是一份完整可运行的五子棋工程:15×15 棋盘、双人对战、人机对战、悔棋、复盘、存档都有,配套教程和课程设计文档把每个类和判定函数拆开讲,适合交课程设计的学生,也适合想拿小项目练 Swing 和二维数组的开发者。

它解决的是「看着简单、动手全是坑」:棋盘建模、鼠标坐标换算、五连判定、AI 怎么写、为什么窗口会花屏。这些问题过一遍,Java 基础、面向对象和 Swing 事件机制就都练到了。

2. 把棋盘建模做扎实:二维数组、坐标映射与五子判定

2.1 棋盘与棋子:为什么用二维数组而不是一组按钮

很多网上的例子会把棋盘做成JButton[][] buttons = new JButton[15][15],每个按钮挂一个ActionListener。这种写法第一眼很直观,点哪个按钮就在哪个位置落子,但用不了几步就会卡住:棋盘背景要画线,按钮数量一多布局就很丑;AI 要从按钮数组里反查位置,代码绕一圈又回到二维下标。更麻烦的是,人机对战、悔棋、复盘都要频繁读写盘面,让按钮同时承担「显示」和「数据」两个职责,最后界面层一改,业务逻辑跟着崩。

我一般会把棋盘数据和界面彻底分开。先写一个Board类,内部只存一个int[][],用 0、1、2 分别表示空、黑棋、白棋。这样界面层只负责把数组画出来,AI 层只负责读数组、算分数,各自都不需要知道对方怎么实现。

public class Board { 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[][] grid = new int[SIZE][SIZE]; private int moveCount; public boolean put(int x, int y, int player) { if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) { return false; } if (grid[x][y] != EMPTY) { return false; } grid[x][y] = player; moveCount++; return true; } public void clear(int x, int y) { if (grid[x][y] != EMPTY) { grid[x][y] = EMPTY; moveCount--; } } public int get(int x, int y) { return grid[x][y]; } public boolean isFull() { return moveCount >= SIZE * SIZE; } }

这段代码的逻辑很直白:put先做边界检查,再做占位检查,都通过了才写数组并让moveCount加一。clear是为悔棋和 AI 的模拟落子准备的,它同步减掉moveCount,否则棋盘满了判断会失效。

两个参数值得说清楚:player传的是 1 或 2,不是Color对象,颜色映射完全留给界面层;SIZE是static final,如果作业要求改成 13 路或 19 路棋盘,只需要改这一处,其他代码基本不用动。

2.2 落点换算与合法性校验:Math.round 解决半格偏移

棋盘画在JPanel上之后,鼠标点击拿到的是像素坐标,比如(px, py),而棋盘逻辑用的是格子下标。两者之间必须有换算。最常见的翻车点是直接用整数除法:(px - MARGIN) / CELL。整数除法是向下取整,点落在格子线右侧半个格子内也会被判成前一格,整个棋盘像被平移了半格。

正确的做法是把除法转成double,再用Math.round取最近的格子线。如果棋盘左边距是 20 像素、格距是 36 像素,那screenToGrid应该这样写:

public int[] screenToGrid(int px, int py) { int x = (int) Math.round((px - MARGIN) / (double) CELL); int y = (int) Math.round((py - MARGIN) / (double) CELL); if (x < 0 || x >= Board.SIZE || y < 0 || y >= Board.SIZE) { return null; } return new int[]{x, y}; }

MARGIN和CELL一定要与画棋盘时用的常量完全一致,我见过有人画的时候用 20 和 36,换算的时候用 18 和 35,结果整个棋盘点哪都错位。方法返回null表示点击点在棋盘外,调用方直接忽略就好。

还有一个细节:这里的MARGIN是棋盘第一条线到面板边缘的距离,不是棋盘中线的偏移。如果窗口大小会被用户调整,最好固定setPreferredSize,不要一边拖动窗口一边动态改CELL,否则换算和绘制很难同步。

2.3 五子判定:四个方向累计而不是八方向

五子连珠的判定看起来只要数「横竖斜」五个方向,但很多新手写成八方向:上下左右、四个对角,每个方向单独写一遍,写完已经绕晕。其实一个方向上的正向和反向是同一根线,只需要检查四个主方向,每个方向里往两头数。

判定时有一个前提:只需要检查当前落子的那一个点,而不需要全盘扫描。因为只有刚落的这颗子可能形成五连,其他区域没有新增棋子,结果不会变。

private static final int[][] DIRECTIONS = { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; public boolean checkWin(int x, int y, int player) { for (int[] d : DIRECTIONS) { int dx = d[0], dy = d[1]; int count = 1; for (int i = 1; i < 5; i++) { int nx = x + dx * i; int ny = y + dy * i; if (nx < 0 || nx >= SIZE || ny < 0 || ny >= SIZE || grid[nx][ny] != player) { break; } count++; } for (int i = 1; i < 5; i++) { int nx = x - dx * i; int ny = y - dy * i; if (nx < 0 || nx >= SIZE || ny < 0 || ny >= SIZE || grid[nx][ny] != player) { break; } count++; } if (count >= 5) { return true; } } return false; }

这个写法的关键是「遇到边界、空格或对方棋子就 break」,不能跳过对方棋子继续数。很多抄来的代码会把count数过对方棋子,导致隔子也算赢。四个方向里,{1,-1}是从左上到右下的斜线扫描,如果你用{-1,1}也可以,但方向数组里不要同时放互为反向的两项,否则每条线会被统计两遍。

count >= 5而不是count == 5,是因为存在 5 连以上连成的长连。标准无禁手规则下,长连也算赢;如果你们作业规定长连无效,就要在判定后额外检查两端,课程设计里一般不需要这么细。

3. 把界面画对:JPanel 重绘、鼠标落子与悔棋栈

3.1 界面选型:JPanel + paintComponent 而不是 JFrame 里画

有人会把绘制代码直接写在JFrame的paint()方法里,窗口一拖动、一最小化就花屏。原因是JFrame本身有 content pane,直接覆写paint会绕过 Swing 的重绘机制,画出来的内容经常被其他层级盖住。

正确做法是单独写一个BoardPanel extends JPanel,把绘制逻辑放在paintComponent(Graphics g)里。这个方法会在需要重绘时被 Swing 自动调用,我们只负责画,不负责决定什么时候画。

public class BoardPanel extends JPanel { private static final int MARGIN = 20; private static final int CELL = 36; private final Board board; public BoardPanel(Board board) { this.board = board; setPreferredSize(new Dimension( MARGIN * 2 + (Board.SIZE - 1) * CELL, MARGIN * 2 + (Board.SIZE - 1) * CELL)); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int i = 0; i < Board.SIZE; i++) { g2.drawLine(MARGIN, MARGIN + i * CELL, MARGIN + (Board.SIZE - 1) * CELL, MARGIN + i * CELL); g2.drawLine(MARGIN + i * CELL, MARGIN, MARGIN + i * CELL, MARGIN + (Board.SIZE - 1) * CELL); } for (int x = 0; x < Board.SIZE; x++) { for (int y = 0; y < Board.SIZE; y++) { int player = board.get(x, y); if (player == Board.EMPTY) { continue; } g2.setColor(player == Board.BLACK ? Color.BLACK : Color.WHITE); g2.fillOval(MARGIN + x * CELL - 14, MARGIN + y * CELL - 14, 28, 28); } } } }

绘制分两层:先画 15 条横线和 15 条竖线,形成棋盘;再遍历整个Board数组,把有棋子的格子画成圆。fillOval的第三个参数 28 是棋子直径,这里取CELL - 8,让棋子之间留一点空隙,观感更接近真实棋盘。

代码里第一行super.paintComponent(g)不能省,它负责清掉上一次绘制留下的内容,否则窗口重绘时可能出现残影。setPreferredSize配合JFrame.pack()使用,窗口会自动按棋盘大小调整,不用手动 setSize。

3.2 鼠标落子与回合控制:状态字段挡住重复点击

鼠标交互挂在BoardPanel上,用MouseAdapter就够了。要注意监听器应该加在棋盘面板上,而不是加到JFrame上,否则点击事件会被子面板「吃掉」,控制台一点反应都没有。

boardPanel.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (gameState != GameState.PLAYING) { return; } int[] pos = boardPanel.screenToGrid(e.getX(), e.getY()); if (pos == null) { return; } int x = pos[0]; int y = pos[1]; if (!board.put(x, y, currentPlayer)) { return; } moveStack.push(new int[]{x, y, currentPlayer}); boardPanel.repaint(); if (board.checkWin(x, y, currentPlayer)) { gameState = GameState.GAME_OVER; JOptionPane.showMessageDialog(boardPanel, currentPlayer == Board.BLACK ? "黑棋胜" : "白棋胜"); } else if (board.isFull()) { gameState = GameState.GAME_OVER; JOptionPane.showMessageDialog(boardPanel, "平局"); } else { currentPlayer = 3 - currentPlayer; maybeRobotMove(); } } });

事件处理的顺序很重要:先判断状态,再换算坐标,再调用put落子。put已经把越界和占位都验过了,所以这里拿到false可以直接 return,界面不用弹错误框,体验反而是自然的。

currentPlayer = 3 - currentPlayer是一个取巧写法:因为黑棋常量是 1、白棋常量是 2,加起来正好是 3,3 减去当前值会得到另一个玩家。这个写法在 AI 代码里也常用,但是可读性略差,介意的话写 if-else 也行。moveStack在成功落子后才 push,顺序千万不能反,否则错的点也会被记录。

3.3 悔棋、复盘与回放:用栈把每一步都留住

悔棋的本质是「撤销最近一次落子」,用Stack<int[]>天然合适。每步棋存{x, y, player},悔棋时从栈顶弹出一个点,把棋盘上对应位置清空。看起来很简单,但人机对战模式下有个大坑:如果悔棋只弹玩家这一步,AI 的下一步还留在棋盘上,棋局等于没悔。

我一般会这样处理:玩家悔棋时,先把玩家的那步弹掉,再判断当前模式是否人机对战。如果是,且栈顶还有一步 AI 的棋,就一起弹掉,再把currentPlayer设回玩家颜色。

public void undo() { if (moveStack.isEmpty()) { return; } int[] last = moveStack.pop(); board.clear(last[0], last[1]); if (isHumanVsAi && last[2] == AI_PLAYER && !moveStack.isEmpty()) { int[] aiMove = moveStack.pop(); board.clear(aiMove[0], aiMove[1]); } currentPlayer = HUMAN_COLOR; boardPanel.repaint(); }

这个方法的边界在于:如果玩家刚落子、AI 还没来得及响应,栈顶是玩家的棋,last[2] != AI_PLAYER,就只弹一步。但课程设计里 AI 通常在同一线程内同步计算,玩家落子后 AI 立刻落子,不会出现这种中间状态。

复盘回放就更简单了:既然每步棋都记录在moveHistory里,清空棋盘后用一个Timer每隔几百毫秒放一颗子,就能做出最简单的回放效果。真正的工程量不在回放,而在「棋盘清空后,悔棋栈和历史记录也要同步重置」,很多项目做完这一步才发现旧记录还在,一按悔棋就把复盘时的子给清了。

4. 人机对战:评分表权值 AI 加一步杀棋检查

4.1 为什么选权值法,而不是一上来就 Alpha-Beta

课程设计里最常见的要求是「电脑会下棋」,但没说一定要下得多聪明。很多人一上来就抄最小最大搜索,15×15 的棋盘分支数大得离谱,深度调到 4 就可能卡死,最后交上去的演示现场翻车。

对这个小项目,我推荐用权值法:给每个空点打分,认为对己方连子越有利的点分数越高,然后选分数最高的点落子。它的优点是实现量小、运行快、每一步棋都能解释,而且调整难度只需要改打分规则。Alpha-Beta 搜索听着高级,但搜索树、棋盘恢复、评估函数三件事同时出错时,你根本不知道该调哪里。

4.2 评估函数:四个方向的连子数乘上开放端权值

权值法核心是评估函数。我的做法是:假设某个空点已经放了玩家棋子,然后沿四个方向数连子,再统计这条线的两端是否为空。两端都空叫「活」,只有一端空叫「眠」,被对方堵死的那条线直接不记分。

一张表先把权值定下来:

落子后棋形两端情况权值
5 连及以上无所谓100000
4 连两端都通50000
4 连一端通10000
3 连两端都通4000
3 连一端通500
2 连两端都通500
2 连一端通50

这些数值不是标准答案,是我调过几轮之后的经验值。关键是 5 连的优先级必须远远高于其他棋形,否则电脑可能为了做活三而错过直接赢的机会。

private int scoreDirection(int x, int y, int player, int dx, int dy) { int count = 1; int openEnds = 0; int nx = x + dx; int ny = y + dy; while (nx >= 0 && nx < Board.SIZE && ny >= 0 && ny < Board.SIZE && board.get(nx, ny) == player) { count++; nx += dx; ny += dy; } if (nx >= 0 && nx < Board.SIZE && ny >= 0 && ny < Board.SIZE && board.get(nx, ny) == Board.EMPTY) { openEnds++; } nx = x - dx; ny = y - dy; while (nx >= 0 && nx < Board.SIZE && ny >= 0 && ny < Board.SIZE && board.get(nx, ny) == player) { count++; nx -= dx; ny -= dy; } if (nx >= 0 && nx < Board.SIZE && ny >= 0 && ny < Board.SIZE && board.get(nx, ny) == Board.EMPTY) { openEnds++; } if (count >= 5) return 100000; if (count == 4) { return openEnds == 2 ? 50000 : openEnds == 1 ? 10000 : 0; } if (count == 3) { return openEnds == 2 ? 4000 : openEnds == 1 ? 500 : 0; } if (count == 2) { return openEnds == 2 ? 500 : openEnds == 1 ? 50 : 0; } return 0; }

注意count从 1 开始,因为这代表「当前空点放上棋子后的连子数」,而这里并没有真正把棋子写进棋盘,所以不需要put和clear。这也是这个实现不容易把棋盘改乱的原因。

单方向分数算完之后,把四个方向加起来就是这个点对某一方的价值:

private int scorePoint(int x, int y, int player) { return scoreDirection(x, y, player, 1, 0) + scoreDirection(x, y, player, 0, 1) + scoreDirection(x, y, player, 1, 1) + scoreDirection(x, y, player, 1, -1); }

落子时,把「对自己 的价值」和「对对手的价值 × 2」相加,防守权重略大于进攻权重。权重太小电脑会只顾进攻,被对手连环堵死;权重太大电脑又过于保守,棋盘上全是防守子。我一般用 2.0 这个系数起步。

public int[] bestMove(int player) { int bestScore = 0; int[] best = null; for (int x = 0; x < Board.SIZE; x++) { for (int y = 0; y < Board.SIZE; y++) { if (board.get(x, y) != Board.EMPTY) { continue; } if (!nearStone(x, y)) { continue; } int score = scorePoint(x, y, player) + scorePoint(x, y, 3 - player) * 2; if (score > bestScore) { bestScore = score; best = new int[]{x, y}; } } } if (best == null) { return new int[]{Board.SIZE / 2, Board.SIZE / 2}; } return best; }

nearStone是扫描当前空点周围两格内有没有已落棋子。棋盘空的时候没有任何候选点,AI 直接走天元;棋局中后期,这个过滤能大幅减少要打分的点,AI 的计算量从 225 个点降到几十个点,人机对战完全感觉不到卡顿。

private boolean nearStone(int x, int y) { for (int i = -2; i <= 2; i++) { for (int j = -2; j <= 2; j++) { int nx = x + i; int ny = y + j; if (nx >= 0 && nx < Board.SIZE && ny >= 0 && ny < Board.SIZE && board.get(nx, ny) != Board.EMPTY) { return true; } } } return false; }

4.3 一步杀棋检查与难度分级:给普通权值法再加两道保险

纯权值法有个明显弱点:它只看当前局面评分,不看「下一步能不能赢」。更弱智的写法甚至不会堵对手的「四三」,对手快赢了还自顾自做活二。补救办法是在bestMove之前加一个一步杀棋检查。

public boolean isWinningMove(int x, int y, int player) { board.put(x, y, player); boolean win = board.checkWin(x, y, player); board.clear(x, y); return win; }

这个方法的本质是「先落子试一下,赢了就恢复棋盘」。用完之后必须clear还原,否则棋盘状态被污染,后面所有评分都会错。我见过有人把这个函数写在循环里,忘了clear,AI 下着下着棋盘上凭空多出好几颗子。

综合起来的决策顺序是这样:

public int[] decideMove(int player) { for (int[] p : candidatePoints()) { if (isWinningMove(p[0], p[1], player)) { return p; } } int opponent = 3 - player; for (int[] p : candidatePoints()) { if (isWinningMove(p[0], p[1], opponent)) { return p; } } return bestMove(player); }

先看自己能不能一步成五,能就直接赢;再看对手下一步会不会成五,会就必须堵;两件事都没有就按权值选点。这个顺序让 AI 有最基础的攻防意识,做一个课程设计的人机对战已经够看了。

难度分级也在这里做:普通权值法直接选最高分;简单难度可以在分数最高的五个候选里随机挑一个;困难难度可以在权值法基础上再模拟一轮对手的回应,相当于一层极浅的搜索。课程设计通常做到「简单 / 普通」两档就足够答辩了。

5. 五子棋项目避坑:从空指针到界面卡死的五个现场

5.1 现象:点击棋盘没有反应,控制台也不报错

很多人把MouseListener加到了JFrame上,却把棋盘画在BoardPanel里。Swing 的鼠标事件会优先派发给最上层的子组件,BoardPanel挡住了事件,JFrame上的监听器根本收不到,所以点哪里都没反应。

原因就是监听器挂错了对象。解决方法是把监听器加到BoardPanel上,或者在JFrame上设置一个透明玻璃面板拦截事件。课程设计里直接用第一种,简单不容易出错。另外确认screenToGrid返回的不是null,如果点击偏到棋盘外,事件会静默忽略,看起来也像没反应。

5.2 现象:窗口最小化再还原,棋盘出现残影和错位

这是把绘制代码写在错误组件里的典型症状。直接在JFrame.paint()里画棋盘、不调用super.paint(),窗口每次重绘都不会先清理旧内容,上一帧的线条和棋子会残留在新帧下面。

解决方法是把全部绘制逻辑放进BoardPanel.paintComponent,并且第一行调用super.paintComponent(g)。之后每次模型变化就调boardPanel.repaint(),让 Swing 决定什么时候重绘,不要手动去碰getGraphics和paint方法。

5.3 现象:悔棋白悔,或者越悔棋盘越空

人机对战模式下,玩家点悔棋后电脑马上又下一子,看起来就像没悔过。原因是悔棋只弹掉了玩家的最后一步,AI 那步还在栈里、还在棋盘上。如果连续悔棋,还可能把栈弹空,导致currentPlayer变成null或者下一次落子时空指针。

解决的思路是「一次悔棋 = 撤销一整个回合」。人机模式弹两步,双人模式弹一步;弹完之后把当前玩家强制设回玩家颜色,再repaint。写undo()时一定要先判断栈是否为空,不要在弹空之后继续访问栈顶。

5.4 现象:存档读档后棋子都在,但轮到谁走全乱了

只把Board里的二维数组存进文件,重启后读出来棋盘没问题,但currentPlayer还是初始化时的黑棋,悔棋栈也是空的。按悔棋直接空指针,轮到自己电脑下时又多用错颜色。这就是「只存了数据,没存状态」。

解决方法是做一个GameSnapshot,把grid、currentPlayer、moveHistory、模式、步数一起写进文件。读档时先恢复棋盘,再恢复这些流程变量。如果用了对象序列化,记得transient字段不要做存档用,否则读出来全是默认值。

5.5 现象:轮到电脑下棋时,窗口卡成白板

人机模式里,AI 想棋和界面重绘都挤在鼠标事件线程上。一旦评估函数里放了大量Thread.sleep模拟思考、或者写了递归打印日志,重绘就排队等着,窗口表现成未响应。

解决方法是把 AI 计算放到SwingWorker里,计算结束后在done()回调里更新棋盘和界面,不要在mouseClicked里同步等 AI。这样点完一个子,窗口立刻刷新,AI 算多久都不会卡住界面。课程设计如果只是 15×15 权值扫描,其实很快,但养成这个习惯后面加搜索深度时才不会被坑。

6. 给代码留条后路:状态机管住对局流程,再加一个复盘入口

很多五子棋项目越改越乱,是因为流程控制散落在各个事件监听器里。落子监听器里判断游戏是否结束,按钮监听器里又判断能不能悔棋,AI 线程里又重复判断一遍。加一个暂停功能就能改出三四个 bug。

我从这套资源里学到最值钱的做法,是给整个对局定义一个状态机。先列一张状态表,再写代码,流程就会很清楚:

状态允许的操作下一状态
WAITING开始新对局PLAYING
PLAYING落子、悔棋、存档、复盘GAME_OVER / WAITING
GAME_OVER复盘、重开、退出WAITING

代码里就是一个枚举:

public enum GameState { WAITING, PLAYING, GAME_OVER }

所有入口事件统一先判断状态,符合条件才往前走。落子、悔棋、AI 响应、按钮点击都走这一个入口,状态迁移只有两处:落子后发现赢棋或棋盘满,进入GAME_OVER;用户点「再来一局」,回到WAITING。这样不会再出现「游戏结束还能继续落子」这种玄学 bug。

复盘入口也是这个状态机带来的顺手功能。棋盘数据、悔棋栈、当前玩家都在同一个控制器里被管理,清空棋盘后只需要把moveHistory按顺序回放。如果一开始就把流程写成三五个 if 散落在不同类里,想加复盘就得把每个事件都搜一遍。

从那以后我每次写 Swing 小游戏,都强制先手写一张状态迁移表,贴在类注释里,写完状态机再碰界面代码。这套工程的源码、教程和课程设计文档都在资源包里,拿下来对照第 2 章到第 4 章的顺序重写一遍,比只看代码记得牢。五子棋这样的项目,真正的收获不在能跑,而在改得动、讲得清,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进