简介:这是一份五子棋游戏开发学习资源包,围绕棋盘、棋子、玩家与AI等模块讲解完整设计思路,解决从零搭建项目时在数据结构、界面交互与AI决策上的难点,适合Java学习者用于课程设计或博弈编程入门。项目从棋盘类、棋子类、玩家类入手,介绍15x15棋盘的二维数组表示、落子合法性校验、胜负判定,以及基于Swing/JavaFX的事件驱动界面,并重点讲解Minimax与Alpha-Beta剪枝在AI对手决策中的应用。压缩包为rar格式,共65个文件,大小约2.73MB,内含工程源码、exe可执行程序、bmp/wav/ico界面素材、项目配置文件及doc/txt说明文档,便于直接运行观察效果并对照代码阅读。当前已有205人浏览学习。通过该资源可进一步理解棋盘状态维护、边界条件处理等细节,还能尝试悔棋、存档、难度调整、人机与人人对战等扩展功能,适合Java综合训练和游戏开发入门。
1. 五子棋游戏教程:一个把Java GUI知识串起来的完整Swing实战
学完Java基础语法和面向对象,最大的困惑往往是:知识都会,但不知道怎么写一个能打开、能点击、能交互的窗口程序。控制台里跑一百道练习题,不如亲手做一个能玩儿的五子棋。这个教程项目的价值就在这儿——它用一个15×15棋盘,把二维数组、事件监听、图形绘制、规则判定这些Java核心知识点全部串了起来。你跟着源码走一遍,能看到鼠标点击如何换算成棋盘坐标、落子如何写入数据模型、五个棋子连成一线如何被算法检测出来。适合刚学完Java想找练手项目的人,也适合需要能演示、能答辩的课程设计场景。下面直接对着源码,从结构到运行再到二次开发,完整拆一遍。
2. 项目定位与技术选型:为什么五子棋是Java GUI练手的最佳载体
2.1 教程覆盖的功能范围:从双人对战到胜负判定的完整闭环
先把这个教程实现的功能边界说清楚,避免你拿下来之后发现和预期不一样。这个项目实现的是双人在同一台电脑上轮流落子的标准五子棋,核心功能有四块。
第一是棋盘,经典15×15规格,带五个星位标记,整体用Swing的JPanel绘制,不依赖任何图片素材,棋盘网格、星位、棋子全部用Graphics2D纯代码画出来。第二是落子交互,鼠标点击棋盘网格交叉点,系统自动把最近的交叉点换算成落子位置,黑子先行,双方交替。第三是胜负判定,任意一方在横、竖、两条斜线四个方向上率先连成五颗棋子即获胜,程序弹出结果并终止对局。第四是重开机制,一局结束后可以一键清空棋盘重新开始。
这个功能范围是经过设计的。相比那些加了倒计时、联网对战、棋子动画的版本,这个教程刻意保持了功能收敛——每个模块都在讲授一个明确的Java知识点,没有引入任何第三方依赖。你看到的每一个棋子、每一条网格线,背后的绘制逻辑都是透明的,不存在「调了个库就完事」的黑匣子。
为什么说五子棋适合做Java GUI的第一课?因为它刚好卡在简单和完整的平衡点上。数据模型只需要一个二维数组,判断逻辑只需要四个方向的扫描,界面只需要一个面板加一个监听器。但就是这么点代码,已经能覆盖Swing开发的大部分核心路径:组件继承、绘图覆写、事件绑定、模型与视图分离。你做完这个,再去做贪吃蛇、扫雷,会发现套路完全一致。
2.2 技术栈与设计取舍:Swing、二维数组与最朴素的事件驱动模型
技术选型上,这个项目用的是Java标准库自带的Swing组件,没有引入Maven、Gradle这类构建工具,也没有任何外部依赖包。用最朴素的javac编译、java命令运行就能起项目。我一般在教学场景里也推荐这种零依赖方案——它让初学者的注意力完全集中在Java本身,而不是构建工具和环境问题上。等你把这个项目吃透,再去学Maven管理依赖,心智负担会小很多。
棋盘的核心数据结构是int[15][15]二维数组,0表示空位,1表示黑子,2表示白子。为什么不用ArrayList?因为五子棋的棋盘天生就是固定大小的二维网格,数组的随机访问是O(1),遍历绘制效率也足够,完全没有必要引入集合框架的额外开销。这个选择不是妥协,而是最贴合问题域的做法。
在架构上,项目做了一个很轻量的MVC划分。Board类充当模型,持有棋盘状态和落子逻辑;BoardPanel类充当视图,负责把模型数据绘制到屏幕上;鼠标事件监听器充当控制器,负责把鼠标动作翻译成模型操作。这个划分虽然粗糙,但它让数据和显示解耦了——棋盘数据的变化不直接画到屏幕上,而是通过repaint()通知视图重新绘制。后面做悔棋、做AI,都是基于这个结构往模型层加逻辑,视图层基本不用动。
Swing本身是单线程模型,所有UI操作都必须在事件分发线程(EDT)上执行。这个项目里没有用到多线程,所以不存在线程安全问题。但你要知道:如果有人改了一个版本,在落子逻辑里加了Thread.sleep做动画,界面就会卡死——因为动画阻塞了EDT。后面二次开发章节我会讲到怎么规避这个问题。
3. 核心模块拆解:棋盘数据模型、绘制流程与胜负判定算法
3.1 项目文件结构与类职责:五个类干了什么
源码包的结构非常直白,一共五个Java文件,全部放在gobang包下:
gobang/ ├── Main.java // 程序入口,创建窗口并启动 ├── Board.java // 棋盘数据模型,持有二维数组与落子逻辑 ├── BoardPanel.java // 棋盘绘制面板,继承JPanel并覆写paintComponent ├── GameFrame.java // 主窗口,负责组装面板和状态栏 └── Rule.java // 规则类,静态方法封装胜负判定和落子合法性检查Main类是入口,main方法里创建GameFrame实例,设置窗口标题、默认关闭操作、尺寸和居中位置。GameFrame持有Board、BoardPanel、Rule这些核心对象的引用,并把面板加进窗口的内容面板。BoardPanel是整个项目视觉核心,它既要负责画出棋盘和棋子,又要在构造函数里把鼠标监听器绑定到自己身上。Board作为纯数据类不依赖任何Swing组件,这是理解整个项目结构的关键——你把Board替换成控制台版本的五子棋逻辑,完全不用改BoardPanel以外的东西。
Rule类设计成静态方法集合,没有实例字段。这个设计我比较认同,因为规则判定本身是无状态的:给定棋盘和落子坐标,返回是否胜利或是否合法。把规则和棋盘数据分开,后面做AI评估函数的时候可以直接复用Rule里的方向扫描逻辑,这是当初划分模块时留下的伏笔。
3.2 棋盘数据模型:二维数组如何管理落子状态
看Board类的实现。这里用了int数组而不是boolean数组,是因为棋盘需要表达三种状态:空、黑棋、白棋,boolean表达不了三方状态。用int也是Java里最朴素的枚举替代方案,三个常量定义让代码的可读性比散落一地的魔法数字好太多。
public class Board { 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 final int[][] grid; private int currentPlayer = BLACK; public Board() { grid = new int[SIZE][SIZE]; } public int getStone(int row, int col) { return grid[row][col]; } public boolean placeStone(int row, int col) { if (row < 0 || row >= SIZE || col < 0 || col >= SIZE) { return false; // 越界直接拒绝 } if (grid[row][col] != EMPTY) { return false; // 已有棋子,拒绝重复落子 } grid[row][col] = currentPlayer; return true; } public void switchPlayer() { currentPlayer = (currentPlayer == BLACK) ? WHITE : BLACK; } public int getCurrentPlayer() { return currentPlayer; } public void reset() { for (int r = 0; r < SIZE; r++) { Arrays.fill(grid[r], EMPTY); } currentPlayer = BLACK; } }placeStone方法返回boolean而不是void,这是有意为之。调用方(鼠标监听器)需要根据返回值决定要不要刷新界面、要不要切换玩家。如果落子失败——比如点到已经有棋子的位置——监听器不需要做任何后续操作,直接忽略这次点击就行。把合法性检查放在模型层,由模型来判断能不能落子,比在监听器里做一堆if判断要干净得多。
currentPlayer字段记录当前轮到谁。switchPlayer在每次成功落子后调用,黑子白子交替。reset方法用于新一局,用Arrays.fill把每一行清零,比双重for循环手动赋值更简洁,而且性能也好一点。
3.3 棋盘绘制:paintComponent里如何用Graphics2D画出一盘完整的棋
BoardPanel继承JPanel,核心在覆写paintComponent。这个方法是Swing绘图机制的回调入口——窗口系统决定需要重绘的时候(窗口拖拽、遮挡解除、代码调用repaint),都会触发它。你永远不应该手动直接调用paintComponent,而是通过repaint()去安排重绘。
public class BoardPanel extends JPanel { private final Board board; private final int margin = 30; // 棋盘边缘留白(像素) private final int cellSize = 36; // 每个格子的宽度(像素) public BoardPanel(Board board) { this.board = board; setPreferredSize(new Dimension( margin * 2 + cellSize * (Board.SIZE - 1), margin * 2 + cellSize * (Board.SIZE - 1))); setBackground(new Color(0xEEC085)); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); drawGrid(g2); drawStones(g2); } private void drawGrid(Graphics2D g2) { g2.setColor(new Color(0x5A3A1A)); for (int i = 0; i < Board.SIZE; i++) { int x = margin + i * cellSize; g2.drawLine(x, margin, x, margin + (Board.SIZE - 1) * cellSize); g2.drawLine(margin, x, margin + (Board.SIZE - 1) * cellSize); } // 画五个星位 int[][] stars = {{3, 3}, {3, 11}, {7, 7}, {11, 3}, {11, 11}}; for (int[] s : stars) { int x = margin + s[0] * cellSize; int y = margin + s[1] * cellSize; g2.fillOval(x - 4, y - 4, 8, 8); } } private void drawStones(Graphics2D g2) { for (int r = 0; r < Board.SIZE; r++) { for (int c = 0; c < Board.SIZE; c++) { int stone = board.getStone(r, c); if (stone == Board.EMPTY) { continue; } int cx = margin + c * cellSize; int cy = margin + r * cellSize; int radius = cellSize * 4 / 10; if (stone == Board.BLACK) { g2.setColor(Color.BLACK); } else { g2.setColor(Color.WHITE); } g2.fillOval(cx - radius, cy - radius, radius * 2, radius * 2); if (stone == Board.WHITE) { g2.setColor(Color.LIGHT_GRAY); g2.drawOval(cx - radius, cy - radius, radius * 2, radius * 2); } } } } // 把鼠标像素坐标换算成棋盘行列号 public int colAt(int x) { return Math.round((x - margin) / (float) cellSize); } public int rowAt(int y) { return Math.round((y - margin) / (float) cellSize); } }这里有个关键点要注意:setPreferredSize里计算的是margin * 2 + cellSize * (SIZE - 1),而不是SIZE * cellSize。因为15条网格线之间只有14个间隔,整个棋盘内容正好撑满面板,不会多出一行空白。这个细节新手经常忽略,直接写SIZE * cellSize,结果棋盘四周留白不对称,看起来就别扭。
像素坐标换算成棋盘坐标,用的是Math.round而不是直接整除。因为如果鼠标点在两个格子的边缘附近,直接整除会取到偏小的那个下标,视觉上棋子会卡在网格上偏移半个格。用round做四舍五入能把点击坐标映射到最近的交叉点上,手感会好很多。
绘制棋子时,radius取cellSize的4/10而不是1/2。如果棋子半径恰好等于半个格子,相邻两枚棋子会正好相切,视觉上显得太挤。留出20%空隙是五子棋常见的绘制方案。
3.4 胜负判定:四方向扫描如何检测五子连珠
胜负判定是整个项目算法含量最高的部分。标准做法是:在每次落子之后,只从当前落子点出发,向四个方向扫描连续同色棋子的数量。如果某个方向连续数达到5,则当前玩家获胜。这个策略比全盘扫描高效得多——全盘扫描要检查225个点乘以8个方向,而单点扫描最多4个方向乘以几个位置。
public class Rule { // 方向向量:右、下、右下斜、右上斜 private static final int[][] DIRECTIONS = { {1, 0}, // 水平 {0, 1}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; public static boolean checkWin(Board board, int row, int col) { int player = board.getStone(row, col); if (player == Board.EMPTY) { return false; } for (int[] dir : DIRECTIONS) { int count = 1; count += countAlong(board, row, col, dir[0], dir[1], player); count += countAlong(board, row, col, -dir[0], -dir[1], player); if (count >= 5) { return true; } } return false; } private static int countAlong(Board board, int row, int col, int dr, int dc, int player) { int r = row + dr; int c = col + dc; int count = 0; while (r >= 0 && r < Board.SIZE && c >= 0 && c < Board.SIZE && board.getStone(r, c) == player) { count++; r += dr; c += dc; } return count; } }注意DIRECTIONS数组的设计,四个方向只用四个向量表示。对于水平方向{1,0},负方向就是{-1,0};对角方向{1,-1}的负方向是{-1,1}。这样一组方向向量配一个取反操作,就能覆盖8个需要检测的方位。
checkWin里count初始化为1,因为落子点本身算一颗。countAlong方法沿着指定方向一个个往后看,碰到越界、空位或对方棋子就停下。把正方向和反方向的连续数加起来,就是某个方向上的总连子数。四个方向只要有一个方向达到5,就判定胜利。这个逻辑足够简洁,没有任何多余的分支判断。
3.5 事件链路:鼠标点击如何变成棋盘上的棋子
前面几节把模型、视图、规则都拆开了,这一节能把它们串起来——鼠标点击到棋子落下的完整链路。代码在GameFrame的构造逻辑里:
boardPanel.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { int row = boardPanel.rowAt(e.getY()); int col = boardPanel.colAt(e.getX()); if (row < 0 || row >= Board.SIZE || col < 0 || col >= Board.SIZE) { return; // 点在棋盘外框之外 } Board board = boardPanel.getBoard(); if (board.placeStone(row, col)) { if (Rule.checkWin(board, row, col)) { String winner = (board.getStone(row, col) == Board.BLACK) ? "黑子" : "白子"; JOptionPane.showMessageDialog(frame, winner + "获胜!"); board.reset(); } else { board.switchPlayer(); } boardPanel.repaint(); } } });整个链路的顺序是:鼠标点击触发mouseClicked事件,事件对象携带屏幕坐标;rowAt和colAt把像素坐标换算成棋盘行列;placeStone检查落子合法性并把棋子写入二维数组;checkWin从落子点做四方向扫描判定胜负;如果该落子合法且没有胜负,switchPlayer切换当前玩家;最后调用repaint触发重绘,界面上显示新棋子。
这里有个容易踩坑的细节:placeStone失败时(比如点到已有棋子的位置),代码不执行任何后续操作,这没问题。但如果点在棋盘外,rowAt和colAt可能返回负值或大于14的值,必须在调用placeStone之前就做边界检查——否则数组下标越界异常会直接崩掉界面线程。这里的判断顺序是先校验坐标范围,再调用placeStone。
4. 运行与调试:从源码到可玩的五子棋,命令行与避坑指南
4.1 编译与启动:命令行和IDE两种跑法
先给最直接的命令行方式。项目不依赖外部库,javac一条命令就能把源码编译成class文件:
# 在源码根目录下执行(gobang目录的上一级) javac -encoding UTF-8 gobang/*.java -d out # 运行主类 java -cp out gobang.Mainjavac的-encoding参数必须带上。如果源码里写了中文注释(这个项目里到处都是),而系统默认编码是GBK,不带这个参数编译会报「编码GBK的不可映射字符」错误。这是Windows环境下的老熟脸问题。
如果你用IDE跑,更简单:新建一个Java项目,把gobang整个包目录拖进src目录,然后右键Main类选Run即可。IDE会自动处理编译和编码。窗口弹出后能看到的交互有三个:点击棋盘任意交叉点落子、状态栏显示当前轮到谁、有人五子连珠时弹窗提示获胜者。
注意棋盘大小和窗口尺寸是写死的——15×15网格、每个格子36像素、边距30像素。不要直接拉大窗口底部,否则面板会被强行拉伸导致棋盘错位。这种情况在避坑章节会细说。
4.2 避坑指南:重绘闪烁、重复监听、编码问题与窗口拉伸
我把这个项目从代码到跑起来最容易翻车的几个点列一下,都是实际编译运行过的血泪经验。
现象1:窗口弹出后,点了很多次,棋盘上始终没有任何棋子,控制台也不报错。
原因:鼠标监听器没有绑定到棋盘面板上,或者绑定了但落子成功后没调用repaint()。最常见的是忘了repaint——数据模型里的数组已经写了棋子,但界面不知道要重新绘制。
解决:检查两处。第一,确认boardPanel.addMouseListener(...)这段代码真正执行了;第二,确认placeStone返回true的分支里,最后一行是boardPanel.repaint()。Swing不会自动感知数据变化,一切界面刷新都要显式触发。
现象2:棋子落在网格线的交叉点上总是偏半格,越靠近棋盘边缘偏差越明显。
原因:像素坐标到棋盘坐标的换算用了整数除法,比如(x - margin) / cellSize,鼠标点在某个格子范围内时,小数部分被直接截断,四舍五入丢失了。
解决:换成Math.round((x - margin) / (float) cellSize)。关键点是先转成float再除,否则两边都是整数,结果还是整数,round就失去意义了。这个坑在五子棋代码里几乎必现。
现象3:窗口显示正常,中文状态栏和弹窗全是乱码。
原因:运行时的文本编码和源码编译编码不一致。Windows控制台默认GBK,源码如果以UTF-8保存,java运行时读取字符串字面量按平台默认编码解析,就会出现乱码。
解决:IDE里统一设置文件编码为UTF-8;命令行运行时加JVM参数-Dfile.encoding=UTF-8。如果是纯命令行跑,可以把源码转成GBK编码再编译,但最佳方案还是全程UTF-8。
现象4:鼠标点一次,棋盘上出现两颗棋子(一颗黑一颗白交替错乱)。
原因:鼠标按下和释放各触发一次mouseClicked事件,或者MouseListener同时绑定了多个组件,导致同一个逻辑执行了两次。
解决:这个项目用的是mouseClicked,它只在完整的一次点击完成后触发一次,理论上不会双触发。如果你改成mousePressed,就可能遇到。调试方法是在事件方法第一行打印日志,观察触发次数。另一个隐藏原因:GameFrame构造方法和某个初始化方法各自绑定了一次监听器,两个监听器同时触发。
现象5:把窗口拉大后,棋盘变得歪歪扭扭,棋盘边缘和棋子错位。
原因:JPanel的默认布局会把组件拉伸到容器大小,而绘制逻辑里的margin和cellSize是写死的常量,面板尺寸被拉大后,棋盘依然是按原尺寸画的像素,但背景被拉伸,看起来就像错位。
解决:给窗口调用setResizable(false)固定尺寸,这样最省事。也可以在paintComponent里动态计算cellSize = (getWidth() - 2 * margin) / (SIZE - 1),每次重绘都自适应窗口大小,但那样需要处理坐标换算里的动态cellSize,代码量上去了。教学项目选前者就好。
5. 二次开发:悔棋功能与简单AI评分算法的具体落地
5.1 悔棋功能:用Deque撤销栈记录每一步落子
这个项目改造成支持悔棋并不难,核心思路是把每一步落子位置记录下来,悔棋时从数组里移除最后一步。我一般用Deque(双端队列)来当撤销栈,因为它比ArrayList更适合频繁的尾部插入和删除。这个数据结构选型很关键:用错了容器,悔棋功能可能会在你下到第50手的时候出现性能或者逻辑上的怪问题。
模型层加一个栈字段:
private final Deque<int[]> history = new ArrayDeque<>();每次成功落子后把坐标推进栈:
public boolean placeStone(int row, int col) { if (row < 0 || row >= SIZE || col < 0 || col >= SIZE) { return false; } if (grid[row][col] != EMPTY) { return false; } grid[row][col] = currentPlayer; history.push(new int[]{row, col}); return true; }悔棋逻辑:
public boolean undo() { if (history.isEmpty()) { return false; } int[] last = history.pop(); grid[last[0]][last[1]] = EMPTY; // 悔棋相当于撤销当前玩家的一步,状态回到上一位玩家 switchPlayer(); return true; }改完记得在GameFrame里加一个「悔棋」按钮,点击后调用board.undo()然后boardPanel.repaint()。这里有一个容易漏的细节:一局结束reset()的时候,应该把history也清空,否则新一局的悔棋会撤销到上一局的棋子,逻辑直接错乱。
5.2 一个能赢的简单AI:基于分值评分的贪心落子
如果你想把这个项目从双人对战变成人机对战,最常见的入门级做法是评分表法。思路不复杂:遍历棋盘每一个空位,先假设AI在这里落子,计算这一手对AI自己的进攻价值,再算一下如果对手落在同一个位置会有多大的威胁价值,两个值加起来就是该位置的最终得分,取最高分的位置落子。
public int[] findBestMove(Board board, int aiPlayer) { int bestScore = -1; int[] bestMove = {7, 7}; // 默认天元 for (int r = 0; r < Board.SIZE; r++) { for (int c = 0; c < Board.SIZE; c++) { if (board.getStone(r, c) != Board.EMPTY) { continue; } int score = evaluatePoint(board, r, c, aiPlayer); if (score > bestScore) { bestScore = score; bestMove = new int[]{r, c}; } } } return bestMove; } private int evaluatePoint(Board board, int row, int col, int aiPlayer) { // 分别评估AI自己落子和对手落子的价值,取两者的和 return scorePosition(board, row, col, aiPlayer) + scorePosition(board, row, col, opposite(aiPlayer)) * 9 / 10; }思路是:一个位置的分值由「自己落在这里能形成的进攻价值」加上「对手如果落在这里能造成的威胁价值」决定。后者乘以0.9是因为防守的权重略低于进攻,这是调参时反复试出来的——系数给得太高,AI变得只会堵不会攻;给得太低,AI经常看不见对手的冲四。这两个改造点加起来,代码量增加不到100行,但项目体验会从双人共享键盘变成一个人和电脑对战。
我最后说一个自己的习惯:每次改完AI评分系数,我都会先手动摆一个对手即将连五的棋局,让AI先手防守,验证它能不能堵住。这一步比跑十遍随机对局都有用——攻击力不足的AI很难在随机对局里暴露问题,但防守漏洞一摆就能看出来。从那以后我每次调完评估函数,都强制先跑一遍防守用例再开完整对局。希望帮到你。
本文还有配套的精品资源,点击获取