简介:这是一份面向Java初学者的五子棋游戏开发教程资源包,系统讲解棋盘类、棋子类、玩家类与计算机AI的设计思路,完整覆盖从棋盘初始化、落子合法性校验到五子连珠胜负判断的整个流程,适合希望用Java实践图形界面、事件驱动和搜索算法的开发者。压缩包为rar格式,共65个文件,约2.73MB,涵盖工程源码、头文件、目标文件、可执行程序、调试信息以及棋盘图片、图标和音效等素材,可按模块对照阅读、编译运行与断点调试。目前已有205人浏览学习。既有实现讲解,又有工程文件,适合课程设计或业余练手。内容除人机对战与人人对战模式外,还介绍了Minimax和Alpha-Beta剪枝算法在落子决策中的应用,并结合Swing/JavaFX构建完整游戏循环;同时补充悔棋、保存加载进度、难度分级等功能设计,帮读者从零搭建出可运行、可扩展的五子棋项目,并借此加深对面向对象建模和基础AI原理的理解。
1. 一个 Java 五子棋游戏,为什么值得重新亲手写一遍
把 java 五子棋游戏从零到一完整实现一遍,是我给刚学完 Java 基础的同学最常推荐的练手项目。它足够小,一个类就能启动窗口;又足够大,坐标换算、状态管理、胜负判定、窗口重绘都是实际开发绕不开的问题。当棋盘被画出来、黑白双方轮流落子、第五颗连珠时弹出提示,你会发现这个小游戏里塞满了值得反复琢磨的细节。适合两类人:学完语法想找第一个完整项目的新手,以及需要快速交付桌面 Demo 的开发者。成品只用纯 JDK,零第三方依赖,跑起来就是完整的双人对弈。
2. 总体设计:Swing + 二维数组棋盘,功能边界怎么划分
2.1 为什么选 Swing 而不是 JavaFX 或纯控制台
写桌面五子棋,技术选型其实不多。常见做法是 Swing 或者 JavaFX,也有不少人先用纯控制台把规则跑通。控制台版最大的问题是「看不到形状」,你在内存里判断出黑棋连成五很容易,但让用户理解为什么赢,棋盘的可视化是不可替代的。JavaFX 虽然界面更现代,但要额外处理模块配置、启动类路径,对入门项目来说很容易还没开始游戏就卡在环境上。
Swing 是 JDK 自带的 GUI 组件库,javac 直接编译就能跑,能在最小环境成本下把棋盘画出来。事件监听模型也成熟,鼠标点击、窗口重绘、对话框这些 UI 基础能力都有现成的类可用。另一个关键点是资料最多,遇到「棋盘闪」「点击没反应」这类问题,搜索一下基本都能找到对应解法。所以最终我的选择是:Java SE 8 + Swing + AWT 的 Graphics2D 绘制,数据层用一个二维数组表达棋盘,不引入数据库,不引入网络框架。
2.2 核心类与方法职责
整个项目拆成五个类,职责边界尽量单一,方便后面加 AI 或联机功能时不至于推翻重来。入口类 GameMain 负责创建窗口、设置标题尺寸和退出逻辑;BoardPanel 继承 JPanel,承担两件事:在 paintComponent 里绘制棋盘与棋子,以及注册 MouseListener 处理落子;GameState 保存 15×15 的棋盘数组、当前轮到谁、已经下了多少手;WinChecker 是纯函数式的判定器,输入棋盘与落子坐标,输出胜方,不持有任何状态。最后还留一个常量类 Config,把棋盘大小、格子宽度、边距这些魔力数字集中管理。
GameState 我通常这样写:
public class GameState { public static final int EMPTY = 0; public static final int BLACK = 1; public static final int WHITE = 2; private final int[][] board = new int[15][15]; private int currentPlayer = BLACK; private int moveCount = 0; public boolean place(int row, int col) { if (row < 0 || row >= 15 || col < 0 || col >= 15) { return false; } if (board[row][col] != EMPTY) { return false; } board[row][col] = currentPlayer; moveCount++; currentPlayer = (currentPlayer == BLACK) ? WHITE : BLACK; return true; } public boolean isBoardFull() { return moveCount >= 15 * 15; } }place 方法设计为「要么完整落子,要么什么都不做」的原子操作:先做越界检查,再做占用检查,全部通过才写棋盘和切换执棋方。这样上层调用方只需要一行if (!state.place(row, col))就能统一处理非法点击,不需要自己先判断再落子,避免中间状态漏掉。
参数语义说一下:row 和 col 传的是棋盘索引而不是像素坐标,取值为 0 到 14 的整数;currentPlayer 用 1(黑)、2(白)表示,用 int 而不是 String,是为了绘制棋子时可以直接和颜色映射。moveCount 等价于棋盘上的棋子总数,用它判断平局比每次遍历全盘省事得多。
2.3 项目目录结构
源码按标准包结构排放,虽然没用 Maven,但目录层级保持清晰,方便后续迁移:
gobang/ ├── src/ │ └── gobang/ │ └── demo/ │ ├── GameMain.java // 窗口入口,配置 JFrame │ ├── BoardPanel.java // 棋盘绘制与鼠标事件 │ ├── GameState.java // 棋盘状态与落子逻辑 │ ├── WinChecker.java // 胜负判定 │ └── Config.java // 棋盘常量 └── out/ // 编译输出目录,由 IDE 或 javac 生成这里没有引入 Maven 或 Gradle,不是不能,而是没必要。五子棋的依赖只有 JDK 自带包,引入构建工具反而让新手在「跑起来」之前先学工程化,劝退概率极高。等你想加 JUnit 或打包成可执行 jar 时,再迁移到 Maven 也不迟。实际交付时,我会在关键位置用 main 方法里的 System.out 输出状态日志,不需要日志框架。
坐标模型是整个项目最关键的基础设定。设定格子宽度 GRID_SIZE 为 32 像素,棋盘边距 MARGIN 为 30 像素,棋子直径等于格子宽度减 4 像素,落子点中心坐标是 MARGIN + col × GRID_SIZE、MARGIN + row × GRID_SIZE。鼠标监听里拿到的是相对于 JPanel 的像素坐标,所以换算公式是:
row = (clickY - MARGIN) / GRID_SIZE,col = (clickX - MARGIN) / GRID_SIZE
这个公式看起来简单,但实际写的时候有两个坑:一是边框像素也会落入 MARGIN 范围内,不先减掉会影响计算;二是整数除法向下取整,点击格子的右半部分可能落到下一格。这个问题留到第 3 章鼠标事件里细讲,它会直接决定落子的手感。
3. 核心模块拆解:从画棋盘到五子连珠判定的完整链路
3.1 棋盘绘制:Graphics2D 栅格与窗体重绘
绘制用 JPanel 的 paintComponent,这个方法由 Swing 的事件调度线程自动回调,我们不能手动调用它,而是通过repaint()通知系统刷新。第一次写这项目的人最容易犯的错,是把绘制代码写进普通方法里、自己在落子时调用,结果就是棋盘要拖一下窗口才肯刷新。正确姿势是重写 paintComponent,把所有绘制步骤都放进去,每次落子后调用 repaint。
先看核心绘制代码:
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 木质底色,避免白底在强光下刺眼 g2.setColor(new Color(210, 180, 140)); g2.fillRect(0, 0, getWidth(), getHeight()); // 横竖网格线,15 条线之间只有 14 段间距 g2.setColor(Color.BLACK); for (int i = 0; i < Config.BOARD_SIZE; i++) { int pos = Config.MARGIN + i * Config.GRID_SIZE; g2.drawLine(pos, Config.MARGIN, pos, Config.MARGIN + (Config.BOARD_SIZE - 1) * Config.GRID_SIZE); g2.drawLine(Config.MARGIN, pos, Config.MARGIN + (Config.BOARD_SIZE - 1) * Config.GRID_SIZE, pos); } // 五个星位:天元加四角,标准 15 路棋盘视觉锚点 int[][] stars = {{3, 3}, {3, 11}, {7, 7}, {11, 3}, {11, 11}}; g2.setColor(Color.BLACK); for (int[] s : stars) { int cx = Config.MARGIN + s[0] * Config.GRID_SIZE; int cy = Config.MARGIN + s[1] * Config.GRID_SIZE; g2.fillOval(cx - 4, cy - 4, 8, 8); } // 回放已经落下的棋子 for (int row = 0; row < Config.BOARD_SIZE; row++) { for (int col = 0; col < Config.BOARD_SIZE; col++) { int stone = state.getStone(row, col); if (stone == GameState.EMPTY) { continue; } int cx = Config.MARGIN + col * Config.GRID_SIZE; int cy = Config.MARGIN + row * Config.GRID_SIZE; g2.setColor(stone == GameState.BLACK ? Color.BLACK : Color.WHITE); g2.fillOval(cx - Config.GRID_SIZE / 2 + 2, cy - Config.GRID_SIZE / 2 + 2, Config.GRID_SIZE - 4, Config.GRID_SIZE - 4); g2.setColor(Color.DARK_GRAY); g2.drawOval(cx - Config.GRID_SIZE / 2 + 2, cy - Config.GRID_SIZE / 2 + 2, Config.GRID_SIZE - 4, Config.GRID_SIZE - 4); } } }第一行的super.paintComponent(g)是清空上一次绘制的内容,这个不能省,否则拖动窗口时会出现大量残影。抗锯齿那两行给棋子边缘做平滑,不设置的话棋子斜向边缘会有明显锯齿。绘制网格时,最后一根线的终点是 MARGIN + 14 × GRID_SIZE,因为 15 条线只有 14 段间距,很多新手会写成 15 × GRID_SIZE,导致右侧和下边的线画到窗口外面。
回放棋子时注意:绘制阶段不修改棋盘状态,只读 state 里的数组。外层的双重循环遍历 225 个格子,对非空格子画圆并描边,黑白用 BLACK/WHITE 常量决定。圆的直径是 GRID_SIZE - 4,留出的 2 像素间隙让棋子之间不黏连,视觉上更接近真实棋盘。
3.2 鼠标事件:像素坐标到棋盘坐标的换算
落子入口用 mousePressed,不用 mouseClicked。实测经验是 mouseClicked 在某些窗口管理器下会丢失快速双击,而且 mousePressed 响应更及时,手感更直接。换算的核心逻辑如下:
this.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { int col = (e.getX() - Config.MARGIN + Config.GRID_SIZE / 2) / Config.GRID_SIZE; int row = (e.getY() - Config.MARGIN + Config.GRID_SIZE / 2) / Config.GRID_SIZE; if (row < 0 || row >= Config.BOARD_SIZE || col < 0 || col >= Config.BOARD_SIZE) { return; // 点在棋盘外,直接忽略 } if (!state.place(row, col)) { statusLabel.setText("这里有棋子了,换个位置"); return; } repaint(); int winner = WinChecker.checkWin(state, row, col); if (winner != GameState.EMPTY) { String name = winner == GameState.BLACK ? "黑方" : "白方"; JOptionPane.showMessageDialog(BoardPanel.this, name + "获胜!是否再来一局?"); resetGame(); } else if (state.isBoardFull()) { JOptionPane.showMessageDialog(BoardPanel.this, "棋盘已满,平局"); resetGame(); } } });坐标换算有一个关键细节:减掉 MARGIN 后加 GRID_SIZE / 2 再整除。如果不加这半格偏移,点击格子左边缘时整除结果会让人以为点到了上一格,落子位置明显偏左上。加上偏移就变成「四舍五入」式的最近格判定,鼠标点到格子内哪个位置,最终都落在用户视觉预期的那一格。越界检查放在换算之后、落子之前,即使 e.getX() 等于窗口宽度,也不会算出 15 这样的非法下标。
落子之后的执行顺序是:先 state.place 修改数据,再 repaint 触发重绘,最后判断胜负。顺序不能颠倒,repaint 只负责刷新界面,真正的状态变更必须在它之前完成,否则窗口先闪一下旧画面。statusLabel 是个 JLabel 引用,用来在窗口底部提示当前状态,非法落子时改文本,比弹窗更克制。
3.3 落子逻辑与黑白交替
落子的动作核心都在 GameState.place 里,鼠标事件那边只负责把坐标交进去。黑白交替用三元表达式实现:当前是黑就变白、是白就变黑。我见过有人用currentPlayer = 3 - currentPlayer的写法,也能跑,但可读性差一些,不推荐。
落子时记录 moveCount 有两个作用:一是 isBoardFull 判断平局;二是后续做悔棋功能时,天然就知道栈里有多少步可回退。悔棋的扩展点其实已经埋好了——只要把每一步的行列坐标压进一个 Deque,撤销时 pop 出来把对应格子弹回 EMPTY,再回退 currentPlayer 和 moveCount。现在 place 是幂等校验后再写入,将来扩展 undo 不会破坏现有契约。
这里要特别提一个容易翻车的点:胜负判定发生在落子之后,此时 place 已经切换了 currentPlayer。所以千万不要用state.getCurrentPlayer()去判断刚才是谁赢,正确做法是从棋盘上读回 row、col 位置的棋子类型,也就是 checkWin 方法里通过state.getStone(row, col)拿到的那一个值。我最初实现时就在这里出过问题,提示的赢家永远是白方,排查了半天才发现是状态切换顺序的事。数据变更和规则判定之间,务必想清楚「判断时状态处于哪个阶段」。
3.4 胜负判定:从当前落子点向四个方向扫描
判定的核心思路是:一局中最后落子的那个点,必然在五连棋型上,所以不需要扫描整张棋盘,只从 (row, col) 出发,沿横、竖、两个斜方向分别向正反两个方向累计同色棋子数量,总数达到 5 就判胜。这个策略比全盘扫描每行每列高效得多,而且天然规避了「棋子被空位隔断」的误判。
public static int checkWin(GameState state, int row, int col) { int player = state.getStone(row, col); if (player == GameState.EMPTY) { return GameState.EMPTY; } // 四个方向:水平、垂直、主对角线、副对角线 int[][] directions = { {0, 1}, // 横向:列递增 {1, 0}, // 纵向:行递增 {1, 1}, // 主对角线:行和列同时递增 {1, -1} // 副对角线:行增列减 }; for (int[] d : directions) { int count = 1 + countLine(state, row, col, d[0], d[1], player) + countLine(state, row, col, -d[0], -d[1], player); if (count >= 5) { return player; } } return GameState.EMPTY; } private static int countLine(GameState state, int row, int col, int dr, int dc, int player) { int count = 0; int r = row + dr; int c = col + dc; while (r >= 0 && r < Config.BOARD_SIZE && c >= 0 && c < Config.BOARD_SIZE && state.getStone(r, c) == player) { count++; r += dr; c += dc; } return count; }count 的初始值是 1,把当前落子点本身算进去。很多实现会把它初始化为 0,然后正反方向累计,如果落子点两侧各有 2 颗同色棋子,0 + 2 + 2 = 4 判不了胜,其实已经是五连了。反过来,如果两个方向的循环都沿同一个方向走,就会漏判另一侧。directions 数组每个元素代表一格的步进量,正负组合正好覆盖两个相反方向。这段扫描模板在后面做 AI 评分时也会复用,统计空点周围的棋型用的就是同一套遍历思路。
checkWin 设计成静态方法是为了便于验证:不启动窗口,直接 new 一个 GameState,往数组里摆五颗黑子,调用 checkWin 断言返回 BLACK。这个写测试的习惯在改造阶段特别实用——改 AI 逻辑时不用一遍遍开窗口下棋,跑一个 main 方法就能确认胜负规则没被碰坏。
4. 常见问题与避坑记录:像素偏移、越界、重绘与乱码
把项目完整走一遍之后,我整理了新手最容易踩的六个坑。它们的共同点是「代码能编译、程序能启动,但行为不对」,且大部分出在坐标和生命周期问题上,不深入看根本发现不了。
4.1 点击棋盘边缘,棋子落到完全错误的位置
现象:鼠标点在边框附近时,棋子出现在意料之外的格子里,有时甚至感觉点在棋盘外也落了子。
原因:坐标换算没有先减 MARGIN,或者减了之后直接用整数除法向下取整。边框像素没被排除,被算进第 0 格或越界成 -1 格;点击格子左半部分时,又会因为向下取整被归到上一格。
解决:换算统一用(e.getX() - MARGIN + GRID_SIZE / 2) / GRID_SIZE模板,并在换算后加一层越界检查,越界点击直接 return。这层检查即使你自己觉得「肯定在范围内」,也建议留着,因为窗口边缘的像素事件比你想象的多。
4.2 拖动窗口后棋盘出现大量残影和黑块
现象:窗口最小化再恢复,棋盘变花,棋子影子叠在一起,像透明的旧画面没被擦掉。
原因:paintComponent 没有先调用 super.paintComponent(g),上一次绘制的图形没有清理,Swing 重绘时新旧内容叠加。很多人以为残影是双缓冲没开,真正源头是第一行清屏缺失。
解决:每个继承 JPanel 的绘制类,第一行就写super.paintComponent(g);如果还闪,可以在构造器里setDoubleBuffered(true)。先确认清屏,再去折腾双缓冲。
4.3 明明五颗连成一排,程序却说没有赢
现象:横着连了五颗黑子,但只有部分方向能判赢;或者五连必须落在特定位置才能触发。
原因:checkWin 只扫描了横和竖,斜向漏判;或 countLine 的边界条件写成了r > 0而不是r >= 0,边界上的棋子永远数不到。另一个常见版本是 count 初始为 0,实际五连数出来只有 4。
解决:四个方向都要覆盖,count 初始化为 1,扫描循环用r >= 0 && r < BOARD_SIZE的闭区间判断。改完写一个极端用例:把五颗子放到最后一行最后一列,验证边界不越界、能正常判胜。
4.4 同一格连续点击,棋子互相覆盖
现象:同一个位置可以连续落子,黑子刚落下又被白子盖住,棋盘状态越走越乱。
原因:落子前没有检查 board[row][col] 是否为空,或检查用的是绘制状态而不是数据状态。某些版本的代码会在绘制后才更新数组,导致一次点击同时画了两次。
解决:所有落子路径统一走 GameState.place,由它做占用判断;UI 层不允许自己直接写 board 数组。我习惯让 place 在非法时返回 false,由调用方统一提示,这样不会漏掉任何入口。
4.5 javac 编译通过,但窗口标题和按钮中文全是问号
现象:编译不报错,运行后中文全变成乱码,窗口标题、按钮文字、对话框全是「??」。
原因:javac 在 Windows 命令行下默认用 GBK 编码读源码,而源码文件用 UTF-8 保存,字符串常量被错误解码。
解决:编译时加-encoding UTF-8;如果运行环境也乱码,执行时加-Dfile.encoding=UTF-8。IDE 里则在设置中把项目编码统一为 UTF-8。这个问题不涉及 GUI 逻辑,但不处理会直接劝退一批新手。
4.6 Windows 下运行无窗口直接退出
现象:java 启动后进程秒退,窗口一闪而过,没有任何报错日志。
原因:main 里 JFrame 创建后没有调用 setVisible(true),或者主线程执行完后 JVM 直接退出。Swing 程序的事件分发线程虽然还活着,但不会自动保持进程存活,如果 main 方法末尾写了 System.exit,更是会强制关闭一切。
解决:main 方法里用SwingUtilities.invokeLater()包裹窗口创建逻辑,确保窗口在事件线程中构建;不要把 System.exit 放在创建流程里。处理这一步,窗口就能稳定驻留。
5. 把项目跑起来:编译、运行与断点调试技巧
5.1 开发环境与源码文件清单
环境要求只有三条:JDK 8 及以上、一个终端、一个顺手的 IDE。项目不依赖 Maven、不依赖第三方 jar,整个源码由五个 Java 文件组成,总行数在四百行上下。核心文件职责如下:
| 文件 | 职责 | 关键接口 |
|---|---|---|
| GameMain.java | 创建窗口,配置标题与大小 | main() |
| BoardPanel.java | 棋盘绘制与鼠标事件 | paintComponent / addMouseListener |
| GameState.java | 棋盘状态与落子逻辑 | place / getStone / isBoardFull |
| WinChecker.java | 胜负判定 | checkWin / countLine |
| Config.java | 常量集中管理 | BOARD_SIZE / GRID_SIZE / MARGIN |
Config 里我会定义BOARD_SIZE = 15、GRID_SIZE = 32、MARGIN = 30,以及窗口尺寸的推荐值。这样做的好处是调界面时只改一个文件,不至于在绘制代码里到处找魔法数字。窗口本身可以固定大小,用setResizable(false)避免拖拽后棋盘和窗口比例不一致。
5.2 命令行编译与运行
先别急着开 IDE,命令行跑通是最直接的验证方式。假设源码放在 gobang 目录下,编译运行命令如下:
cd gobang mkdir -p out javac -encoding UTF-8 -d out src/gobang/demo/*.java java -cp out gobang.demo.GameMain第一行进入项目根目录;mkdir 建编译输出目录;javac 的-d out把所有 .class 按包路径输出到 out,-encoding UTF-8是为了避免 4.5 的乱码问题;最后java -cp out指定类路径,后面跟「包名.类名」全限定名。
如果提示找不到主类,先检查源码是不是放在gobang/demo这个包路径下,class 文件的目录层级必须和 package 声明的层级严格一致。命令跑完后应看到一个 15×15 棋盘窗口,标题是「五子棋 - 双人对弈」。这时可以两人轮流点击棋盘,连成五子后弹出胜利提示。GameMain 里可以放一行打印落子坐标的日志,用于核对像素换算是否准确。
注意:如果窗口一闪而过,确认 main 方法里没有 System.exit,并且使用了 SwingUtilities.invokeLater 创建窗口。
5.3 常见启动失败排查
运行时最常见的几类报错,按症状归类:
java.lang.NoClassDefFoundError 说明类路径写错了,或者包目录结构不匹配,检查 -cp 后的路径和全限定类名是否一致。ExceptionInInitializerError 多数是 Config 里的常量计算有问题,比如把窗口宽度算成负数,检查常量表达式。窗口能开但点击没反应,多半是事件监听器没有实际 add 到组件上,或者监听器加到了外层 JFrame 而不是 BoardPanel 上。
这些错误有个共同特点:堆栈信息都不会直接指向「坐标换算」或「时序」,需要你按组件边界逐层排查。我的习惯是先把绘制、事件、规则三层拆开验证,哪一层出问题一目了然。
5.4 断点调试与验证技巧
在 IDE 里导入项目后,在 mousePressed 的 row/col 计算行打一个断点,程序会在鼠标点击时暂停,这时查看 row 和 col 是否符合预期,是排查坐标问题最快的方式,比靠肉眼猜准得多。
验证胜负判定,我建议别只靠手点。写一个临时 main 方法,直接构造 GameState,手动把第 3、4、5、6、7 行的黑子置位,然后调 WinChecker.checkWin 断言返回 BLACK。这样能把规则层和 UI 层彻底切分开,后面把规则包换成 AI 版本时心里有底。项目本身不包含测试框架,用 main 方法临时验证是零依赖的做法。
6. 二次开发三条路:人机对战、悔棋与网络联机
6.1 从双人对战到简单 AI
最值得先做的是给项目加一个「电脑对手」。最简单的实现是权值评分法:遍历每个空点,按四个方向统计以该点为中心能形成的活三、冲四、活二等棋型,给每种棋型一个估分,取分数最高的点落子。常见估分表如下:
| 棋型 | 估分 |
|---|---|
| 五连 | 100000 |
| 活四 | 50000 |
| 冲四 | 10000 |
| 活三 | 5000 |
| 眠三 | 1000 |
| 活二 | 500 |
这个表不用追求完美,先跑起来,你会发现电脑已经会堵你的活三了。加上 AI 后要注意,AI 落子路径也要统一走 GameState.place,且计算如果耗时较长,要用 SwingUtilities.invokeLater 切回 UI 线程更新棋盘。
6.2 悔棋、联网与资源扩展
悔棋改动很小:GameState 里加一个 Deque 栈,place 成功后压入 row、col;undo 方法 pop 出来置 EMPTY,回退 currentPlayer 和 moveCount。UI 加一个「悔棋」按钮监听即可。网络对战则把 GameState 的 place 调用封装成消息,用 Socket 传输坐标,对端收到后落子并 repaint。核心经验是别在事件线程里阻塞读写 socket,单独开一个线程,收包后再切回 UI 线程。
改完任何功能,先写固定棋形的用例验证规则,再开窗口手点一轮。从那以后,我每次碰棋盘类逻辑,都会先跑一遍 main 里的测试用例再动 UI。希望帮到你。
本文还有配套的精品资源,点击获取