☰
Java五子棋游戏开发详解:从棋盘建模到AI算法与Swing界面
2026/10/21 2:41:13 网站建设 项目流程

简介:面向Java初学者的五子棋游戏开发教程资源,系统讲解棋盘类、棋子类、玩家类的面向对象设计,深入分析合法落子判断、五连珠检测、基于Swing/JavaFX的图形界面构建,以及Minimax和Alpha-Beta剪枝的AI算法实现,适合课程设计、毕业设计或自学实战。压缩包共65个文件,以h/cpp源文件、obj目标文件为主,含wav/bmp素材、doc文档、ini配置文件与可执行程序等,整体约2.73MB,结构清晰便于查看。资源已有205人学习下载,内附完整工程及论文正文文档,可直接运行并进行人机对弈,同时支持人人对战、悔棋、存档等扩展功能,可帮助读者系统掌握Java游戏开发流程,并为撰写实验报告提供参照。

1. Java五子棋游戏:一份能跑通完整对局的代码包从哪下手

第一次打开这份Java五子棋游戏资源的人,多半是刚把Java SE学完,手头需要一个能写进课程设计、也能在面试里讲清楚的项目。坦白说,五子棋看起来简单,棋盘十五路十五列,规则一句话就能说完,可真要把落子、胜负判定、简单AI、悔棋这些串成一个能稳定运行的程序时,很多人会在看似不起眼的地方卡住——数组越界只是入门,真正的坑在判赢算法的效率和界面线程的阻塞上。这份资源我拆过一遍,里面代码量不大,但把对局循环、判定逻辑和两个难度的AI都完整实现了,适合拿来当骨架,替换成自己的界面风格或扩展成网络对战。下面按我自己的复现顺序,把每个模块怎么用、参数怎么调、哪些地方容易翻车写清楚。

2. 棋盘数据模型与落子逻辑:先定好二维数组的边界再写界面

五子棋程序不管写成什么样,核心数据模型就一个棋盘。多数人会习惯性用二维数组存棋盘,但没有想清楚下标到底代表行还是列,以及数组长度和棋盘坐标之间的关系。这类问题看着小,后面写判定算法时会把坐标全算乱。

2.1 棋盘二维数组的设计:为什么推荐int[][]而不是布尔数组

棋盘用int[][] board = new int[ROW][COL],每个格子存三个值就够了:0表示空,1表示黑子,2表示白子。有人问为什么不用boolean,因为后面AI评分、胜负判定都要频繁读取棋子颜色,一个非黑即白的boolean存不下“空、黑、白”三种状态。

public class Board { public static final int ROW = 15; public static final int COL = 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[ROW][COL]; public boolean inBounds(int x, int y) { return x >= 0 && x < ROW && y >= 0 && y < COL; } public boolean place(int x, int y, int player) { if (!inBounds(x, y) || grid[x][y] != EMPTY) { return false; } grid[x][y] = player; return true; } }

这个类里最关键的是inBounds方法。所有外部传入的坐标,在写入数组前必须先过这一关。我见过不止一个初学项目在鼠标点击棋盘边缘时直接抛出ArrayIndexOutOfBoundsException,就是因为把鼠标像素坐标直接当成了数组下标。place方法的返回值设计成boolean也有讲究:调用方不需要自己先查一遍格子是否为空,落子失败时界面弹提示即可,逻辑集中一处,避免多处重复校验。

这里把棋盘做成15路是默认选择,但实际开发中应该把ROW和COL抽成常量。如果你后面想改成19路围棋棋盘,只需要改这两个常量,判定算法和AI逻辑会自动适配,这个习惯在你将来扩展功能时能省下大量改坐标的零碎工作。

2.2 悔棋与状态重置:栈结构比直接回退更可靠

很多初版实现会在棋盘上留一个“上一次落子的坐标”,悔棋时把那个位置置空。这看起来够用,但连续悔棋两次就会出错,因为你只记住了最后一次,前一次的位置已经丢了。正确的做法是维护一个落子记录栈。

import java.util.ArrayDeque; import java.util.Deque; public class MoveHistory { private final Deque<int[]> stack = new ArrayDeque<>(); public void push(int x, int y, int player) { stack.push(new int[]{x, y, player}); } public int[] pop() { return stack.pollFirst(); } public void clear() { stack.clear(); } }

代码块的逻辑不复杂:每次落子成功,把坐标和玩家编号压入栈顶;悔棋时从栈顶取出,将棋盘该位置置空,并恢复该玩家的回合。Deque接口比Stack类更推荐,因为Stack继承了Vector,线程安全带来的性能开销在游戏这种单线程场景里没有必要,而且ArrayDeque底层是循环数组,栈操作没有任何扩容拷贝的额外成本。

悔棋功能还牵涉到谁可以悔棋的问题。如果是人人对战,悔棋要经过对方同意,这个逻辑应该放在界面层拦截;如果是人机对战,悔棋后电脑的上一手也要一起退掉,否则AI在上一步算出的评估值还残留在下一次决策里。这个细节经常被忽略,实际表现为AI会莫名走出一步很怪的棋,因为棋盘状态和AI内部缓存的局面不一致。

2.3 棋盘坐标与界面坐标的换算:像素到格子的映射方法

写Swing界面时,棋盘画在JPanel上,鼠标点击拿到的是像素质,而棋子要落在格子交叉点上。这里最常见的做法是设定一个起始偏移量MARGIN和格子边长CELL_SIZE,然后按照公式换算。

public class BoardPanel extends JPanel { private static final int MARGIN = 30; private static final int CELL_SIZE = 40; private static final int BOARD_PIXELS = MARGIN * 2 + CELL_SIZE * 14; private final Board board; private int[] toGrid(int px, int py) { int x = Math.round((px - MARGIN) / (float) CELL_SIZE); int y = Math.round((py - MARGIN) / (float) CELL_SIZE); return new int[]{x, y}; } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); for (int i = 0; i < Board.ROW; i++) { g.drawLine(MARGIN, MARGIN + i * CELL_SIZE, MARGIN + CELL_SIZE * 14, MARGIN + i * CELL_SIZE); g.drawLine(MARGIN + i * CELL_SIZE, MARGIN, MARGIN + i * CELL_SIZE, MARGIN + CELL_SIZE * 14); } drawPieces(g); } }

换算公式里用Math.round而不是直接整数除法,是因为鼠标点落在格子线附近时,直接除会把边界上的坐标截断到错误的格子。比如像素坐标px - MARGIN = 39,CELL_SIZE = 40,整数除法结果是0,但肉眼看起来棋子放到了第1格更自然,Math.round(0.975)得到1,规避了这个偏差。

上面还有一处BOARD_PIXELS的计算值得留意:15路的棋盘,线之间只有14个间隔,所以整个棋盘像素宽度是2 * MARGIN + 14 * CELL_SIZE,而不是15 * CELL_SIZE。这个公式写错,棋盘会多出一条超出面板的线或者右侧被截断,属于那种一眼看去不明显、但截图交作业时会被发现的低级错误。

3. 胜负判定与AI落子策略:核心算法怎么实现才不拖慢对局

棋盘模型搭好了,接下来是让程序真正“会玩”五子棋。很多人以为赢棋判定不过是个四方向循环,但真到代码里,方向处理、边界合并、和AI评估共用同一套扫描逻辑时,写出简洁又正确的版本是需要一点技巧的。

3.1 四方向扫描法:一次遍历判断五子连珠的写法

判定五子连珠,常规思路是以刚落下的棋子为中心,向上下、左右、两条对角线共四个方向数连续同色棋子数。实现时要注意的是,每个方向要拆成正反两段计数,不能只扫一个方向。

public class WinChecker { private static final int[][] DIRECTIONS = { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; public static int check(Board board, int x, int y) { int player = board.get(x, y); if (player == Board.EMPTY) return -1; for (int[] dir : DIRECTIONS) { int count = 1; count += countInDir(board, x, y, dir[0], dir[1], player); count += countInDir(board, x, y, -dir[0], -dir[1], player); if (count >= 5) return player; } return -1; } private static int countInDir(Board board, int x, int y, int dx, int dy, int player) { int count = 0; int nx = x + dx, ny = y + dy; while (board.inBounds(nx, ny) && board.get(nx, ny) == player) { count++; nx += dx; ny += dy; } return count; } }

DIRECTIONS数组定义了四个方向向量,每个向量对应正反两方向的组合。以水平方向{1, 0}为例,正方向向右数,反方向向左数,两段之和加上当前棋子就是该方向的总连续数。这种写法避免了写四遍几乎相同的循环,也不会像八方向枚举那样把相反方向重复判断两次。由于引入了inBounds检查,即使落子落在棋盘边缘,代码也不会越界。

这里只检查刚落下的棋子是否导致获胜,而不是全盘扫描,这个设计对性能很关键。在人机对战中,如果每一步都遍历整个15x15棋盘做四方向判断,开销会随对局推进线性增长。只检查最新落子,判定时间恒定在个位数的循环次数内,肉眼完全感知不到卡顿。

3.2 简单AI:基于落子评分的贪婪策略与参数调整

资源包自带的AI并不复杂,属于“评分——取最高分落子”的贪婪策略。AI遍历棋盘所有空格,对每个空格计算一个分数,分数由该点在四个方向上形成的连子潜力决定,最后落到得分最高的位置。这种AI下不过高手,但足以对付普通玩家。

public class GreedyAI { private final int aiPlayer; public GreedyAI(int aiPlayer) { this.aiPlayer = aiPlayer; } public int[] move(Board board) { int bestScore = Integer.MIN_VALUE; int[] bestMove = new int[]{-1, -1}; for (int i = 0; i < Board.ROW; i++) { for (int j = 0; j < Board.COL; j++) { if (board.get(i, j) != Board.EMPTY) continue; int score = evaluate(board, i, j); if (score > bestScore) { bestScore = score; bestMove[0] = i; bestMove[1] = j; } } } return bestMove; } }

evaluate方法的评分逻辑是这份资源里最具调参空间的部分:对每个方向统计以该点为中心、往两个方向延伸的连子数和空位数。若有己方棋子成四连且一端为空,则给极高分数;若对方有三连或四连而己方未堵住,则相应给防守分数。具体权重在资源里是一个数组常量,修改这个数组就能改变AI的攻守倾向。把进攻权重调高,AI会优先自己连五,但可能漏堵对方;把防守权重调高,AI会变成“防守狂魔”,很难输但也很少主动取胜。

我一般会给这个AI加一个随机扰动:在多个分数相同的落子点中随机选一个,而不是固定取第一个。这样同一局棋每次重开,AI走出变化棋形的概率更高,演示时观感好很多。否则只要先手方走同一条路线,AI永远回复同一手,看两局就会觉得程序笨死了。

4. 界面交互与对局控制:把Model、View、Controller理顺

数据模型和算法就位后,最容易被低估的是把三者接起来的控制层。Swing开发中,初学项目最常见的通病是界面处理也放在paintComponent里,或者在事件回调里写了大量业务逻辑,导致想扩展一个“悔棋”功能要牵连改五个方法。

4.1 回合状态机:用枚举管理轮到谁下、是否结束

与其散落地用int变量记录游戏状态,不如用一个枚举把状态机显式写出来。状态机的价值在于,所有改变状态的操作只能通过定义好的迁移路径走,非法操作在编译期或运行期一眼就能查出来。

public enum GameState { BLACK_TURN, WHITE_TURN, BLACK_WIN, WHITE_WIN, DRAW; public GameState switchTurn() { if (this == BLACK_TURN) return WHITE_TURN; if (this == WHITE_TURN) return BLACK_TURN; return this; } public boolean isGameOver() { return this == BLACK_WIN || this == WHITE_WIN || this == DRAW; } }

switchTurn方法只在两方回合间切换,如果状态已经是胜负或平局,调用switchTurn返回自身原状态,这样配合界面层可以杜绝“游戏结束了还能继续落子”的bug。这个细节很多人会在联调时才意识到:棋盘已经显示黑棋获胜,鼠标再点击棋盘,却仍然能放下一颗子,这种体验在演示现场极容易暴露整体开发的粗糙度。

配合状态机的对局控制类,应该负责任何情况下都先调用state.isGameOver()判断,再决定是否接受落子和切换回合。状态判断放在同一处统一处理,而不是在鼠标监听器里散落多个if判断,是避免逻辑漏洞路径的有效做法。

4.2 鼠标事件与重绘流程:Swing线程模型下别碰后台线程

Swing的SwingUtilities是单线程的,所有界面更新必须在事件调度线程(EDT)上执行。很多初学项目为了在落子后播放一点音效或者让AI“思考一下”,会直接Thread.sleep(1000),然后界面就在等待期间冻结。

public void mousePressed(MouseEvent e) { if (state.isGameOver()) return; int[] grid = panel.toGrid(e.getX(), e.getY()); if (!board.place(grid[0], grid[1], currentPlayer)) return; history.push(grid[0], grid[1], currentPlayer); int winner = WinChecker.check(board, grid[0], grid[1]); if (winner != -1) { state = currentPlayer == Board.BLACK ? GameState.BLACK_WIN : GameState.WHITE_WIN; } else { state = state.switchTurn(); if (state == WHITE_TURN && aiEnabled) { aiMoveInBackground(); } } panel.repaint(); }

这里面的关键点有取舍。鼠标事件本身就在EDT上执行,于是落子、判赢、切换状态这些轻量操作直接做没有任何问题。但AI的落子计算如果要遍历棋盘评分,在大棋盘或深度搜索场景下可能耗时超过几百毫秒,这时候应该用SwingWorker在后台线程计算,计算完毕后回到EDT再落子刷新。如果只是本资源这种15路棋盘加简单评分AI,单次计算在微秒级,直接在事件回调里执行也不会卡顿。

aiMoveInBackground方法里我一般会用SwingWorker<int[], Void>,doInBackground里执行AI计算,done里完成最终落子并改回合状态。这样既不会冻结界面,也让重绘保持单线程安全。不少人在此踩坑:直接在子线程里调panel.repaint(),偶尔导致画面异常闪烁。

4.3 菜单与工具栏:新游戏、悔棋、AI开关的设置入口

对局控制摆平后,菜单栏是让这份资源看起来像个完整程序而不是练手零件的重要一环。标准配置包括“新游戏”“悔棋”“切换AI开/关”和“退出”。这些功能核心就几行状态重置代码,难的是别漏掉一件事——新游戏时不仅要清空棋盘,也要清空落子历史栈和重置当前玩家到黑方。

public void reset() { board.clear(); history.clear(); state = GameState.BLACK_TURN; panel.repaint(); }

另外悔棋按钮的可用性要和状态联动:游戏未结束且历史栈不为空时才能点击,否则点击后没有反应,用户会以为程序坏了。给按钮加一个setEnabled的动态更新,比在事件回调里反复做if判断更友好。这个方案只需要在每次落子和悔棋后调用一次状态刷新方法,没有增加复杂度。

5. 常见问题与避坑指南:五个翻车现场记录

资源里代码本身能跑通,但拿回去改改就会出现各种怪问题。这一节把我拆项目时碰到的实际报错和现象整理成记录,每一条都按现象、原因、解决的顺序说清,照着排查能省下大量查资料的时间。

5.1 棋盘点击落子不准:棋子总偏向鼠标右下方

现象是鼠标点在交叉点附近时,棋子画出来会偏离一格,尤其在棋盘上方和左侧边缘明显。原因是toGrid换算时用了(px - MARGIN) / CELL_SIZE的整数除法,鼠标位置距离格子线中心超过半个格子时被截断到错误格。解决方法是把除法换成浮点运算并加Math.round,同时检查BOARD_PIXELS计算公式里间隔数是否为COL - 1。这个场景下也可以用MouseEvent的getX/getY相对于棋盘面板的坐标,而不是相对于窗口的坐标,如果拿错基准,整个棋盘点击区域都会位移。

5.2 AI落子后界面不刷新,要动一下窗口才能看到

现象是电脑走完棋后棋盘上看不到新棋子,切换窗口或拖动后又出现。原因是AI移动结束回调里没有调用repaint(),或者调用发生在后台线程。Swing的重绘请求必须在EDT上发起,后台线程触发重绘不一定报错,但经常不生效。解决方法是AI计算完成后通过SwingUtilities.invokeLater包裹落子和repaint代码块。如果是用了SwingWorker,直接在done()里写落子逻辑即可,done()本身运行在EDT上,不会重蹈覆辙。

5.3 已分胜负还能继续落子

现象是黑棋连成五个以后,白棋控制方仍然能落子,有时棋盘上还能多出第六、第七颗子。原因是鼠标监听器没有统一检查游戏状态,直接调用了落子逻辑。解决方法是把state.isGameOver()检查放在mousePressed入口的第一行,后续任何功能入口(包括菜单里的悔棋)也各自检查状态。养成统一入口检查的习惯后,这类漏洞会明显减少。

5.4 悔棋时AI也跟着退一步,但人机又变回人先手

现象是悔棋后玩家走了一步新棋,结果发现AI好像“失忆”了,重新从第一步开始下。原因在于history栈弹出了玩家和AI各一手,但AI的内部状态没有重置,AI在下一步计算时使用了旧局面下评估出的历史路径信息。如果AI实现里缓存了前一步推荐的落子,悔棋时必须清掉这个缓存。解决方法是检查AI类的全局字段,把所有局面相关缓存统一在reset()里清理。如果AI是无状态算法,每次从当前棋盘实时计算,则不存在这个问题。

5.5 打包成Jar后图标或背景图加载不出来

现象是在IDE里运行一切正常,导出Jar后运行却找不到图片资源。原因是代码里用new File("images/board.jpg")这类文件路径方式读取资源,Jar包内部的文件不是文件系统路径,无法被File定位。解决方法是把所有图片资源放在源码包内,通过getClass().getResource("/images/board.jpg")获取URL再交给ImageIcon加载。这一步属于Java项目打包绕不开的基础认知,资源文件不随代码走这条路,迟早会被它坑一次。

6. 进阶验证:给五子棋加一套自动化对局测试

功能全部跑通后,如果想让这份资源在答辩或作品集里更有说服力,最值得做的是加一套自动化测试,用无界面的方式验证逻辑层的正确性。这不仅能自证代码没写错,也是在展示你对单元测试和流程控制的理解,面试时这条加分项相当直接。

6.1 用JUnit验证胜负判定与悔棋边界

先在pom.xml里引入JUnit 5依赖,然后针对WinChecker和Board写几个针对性用例。不需要覆盖所有可能局面,只挑最有代表性的:斜向五连、边缘五连、刚好不连成五、悔棋后棋盘恢复。

class BoardTest { @Test void placedPieceShouldBeVisibleOnBoard() { Board board = new Board(); assertTrue(board.place(3, 3, Board.BLACK)); assertEquals(Board.BLACK, board.get(3, 3)); } @Test void diagonalFiveShouldBeRecognized() { Board board = new Board(); for (int i = 0; i < 5; i++) { board.place(i, i, Board.WHITE); } assertEquals(Board.WHITE, WinChecker.check(board, 4, 4)); } @Test void occupiedCellCannotBePlacedAgain() { Board board = new Board(); board.place(7, 7, Board.BLACK); assertFalse(board.place(7, 7, Board.WHITE)); } }

第一个用例验证落子写入和读取对称,这是后续所有逻辑的基石。第二个用例特意用了斜向五连,因为纯横向和纵向的bug在人工测试中容易暴露,而斜线方向经常被漏查。assertFalse的用例保护的是棋盘数据的不可覆盖性,这类测试写多了,后续扩展天梯模式或积分模式时改错数据的概率会小很多。

运行逻辑层的测试完全不依赖界面,这也是把规则独立成Board、WinChecker、GameState这些纯Java类的原因。如果当初把所有逻辑都写在JPanel里,这些测试几乎没法写,因为每个测试都要先创建一个窗口。

6.2 模拟对局的自动演练脚本

单测覆盖了规则正确性,还能再往前一步:写一个无界面的自动对局演练,让两个AI互相下棋直到分出胜负,用来验证整句流程不会在中途崩溃。这种脚本在改AI参数后特别有用,分叉到测试流程能自动检查整套对局逻辑是否仍然成立。

public class Simulator { public static GameState run(boolean verbose) { Board board = new Board(); MoveHistory history = new MoveHistory(); GreedyAI aiBlack = new GreedyAI(Board.BLACK); GreedyAI aiWhite = new GreedyAI(Board.WHITE); GameState state = GameState.BLACK_TURN; while (!state.isGameOver()) { int[] move = state == GameState.BLACK_TURN ? aiBlack.move(board) : aiWhite.move(board); int player = state == GameState.BLACK_TURN ? Board.BLACK : Board.WHITE; board.place(move[0], move[1], player); history.push(move[0], move[1], player); int winner = WinChecker.check(board, move[0], move[1]); if (winner != -1) { state = winner == Board.BLACK ? GameState.BLACK_WIN : GameState.WHITE_WIN; } else { state = state.switchTurn(); } if (verbose) { System.out.printf("player=%d move=(%d,%d)%n", player, move[0], move[1]); } } return state; } }

这个脚本如果跑几十局,能顺带验证悔棋的记录栈数据是否和每一步落子严格匹配(在每次落子后断言栈顶就是刚下的坐标),也可以验证任何一局都不会因为AI走了非法坐标而崩溃。改AI参数时,跑一遍这种脚本比手动下一整盘棋快得多。

最后结合个人习惯说一个收尾细节:从那以后我每写完一个Swing小游戏,都强制走一遍“单元测试 + 模拟对弈 + 手动体验”三件套,模拟对弈能抓到的逻辑崩溃远比手动测试多,单元测试则帮我把判定规则固定成一份可回归的契约。如果你也想拿这份Java五子棋资源当自己作品集的一部分,建议先把上面这些清单跑完再演示,希望帮到你。

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

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

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

立即咨询