简介:一套基于Java的五子棋对战系统课程设计源码包,面向Java初学者、课设学生及游戏开发爱好者,提供完整可运行的项目与清晰的模块划分,解决从零搭建对战逻辑和界面交互的难题。包内共290个文件,大小18.26MB,以265个GIF图像为主,覆盖棋盘、棋子、按钮及动画效果;17个Java源文件构成游戏核心逻辑,包括初始化、落子处理、胜负判断等环节;3个XML配置文件用于界面布局与参数设置,另有readme.txt项目说明、jpg图片、wav音效及gitignore、iml等工程文件,方便直接导入IntelliJ IDEA运行和二次开发。目前已有281人学习下载,可作为课程设计或个人练手的完整案例。通过阅读源码与readme,读者能掌握Java GUI程序设计、事件监听、游戏状态机等关键知识点,并理解一个交互式应用的完整开发流程,值得系统研读与扩展实践。
1. 基于 Java 五子棋对战系统源码:先定位三个核心文件
拿到“基于Java五子棋对战系统的设计实现源码”这个标题,我通常不会先看棋盘画得漂不漂亮,而是先找三个文件:模型层、连接层、AI 层。五子棋是一个典型的两方零和博弈,它的“对战系统”难点不在规则本身,而在于三个玩家看不到但必须成立的约定:黑白双方交替落子的时序由谁保证,一方的落子如何同步给另一方,以及人机模式下 AI 是在什么样的评分空间里选点。如果这三个文件互相耦合——比如在鼠标监听里直接写网络发送,在 socket 线程里改棋盘数组——那这源码在课程设计或简历上展示时,第一个被问倒的问题必然是“断线重连后棋盘状态怎么办”。这篇文章不讨论某一个具体下载包,而是从工程实现角度把这三块拆开,给出可以直接平移的代码骨架和参数边界,适合正在做 Java 课设、Gomoku 练手项目或准备面试源码讲解的人。
2. 五子棋核心模型设计:坐标、状态与胜负判定
2.1 为什么先把棋盘模型从界面里独立出来
很多 Java 五子棋源码把棋盘画在JPanel上,鼠标点击坐标直接换算成棋盘坐标然后写进二维数组。单人玩没问题,一旦进入对战系统,界面线程要处理点击、网络线程要处理对方落子、AI 线程要计算下一点,三个线程同时碰一个int[][] board,不锁就等着读到半写状态。所以第一步不是画界面,而是设计一个纯 Java 的核心模型类。
模型类至少要回答四个问题:当前该谁下、某个坐标能不能落子、落子后是否出了胜负、棋盘当前快照是什么。下面是我常用的一份最小可运行骨架:
public enum MoveResult { OK, OUT_OF_BOUNDS, OCCUPIED, WIN, DRAW, GAME_OVER } public class GomokuModel { public static final int BOARD_SIZE = 15; public static final int EMPTY = 0; public static final int BLACK = 1; public static final int WHITE = 2; private final int[][] board = new int[BOARD_SIZE][BOARD_SIZE]; private int currentPlayer = BLACK; private int moveCount = 0; private boolean gameFinished = false; public synchronized MoveResult place(int x, int y) { if (gameFinished) { return MoveResult.GAME_OVER; } if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) { return MoveResult.OUT_OF_BOUNDS; } if (board[x][y] != EMPTY) { return MoveResult.OCCUPIED; } board[x][y] = currentPlayer; moveCount++; if (hasFive(x, y)) { gameFinished = true; return MoveResult.WIN; } if (moveCount == BOARD_SIZE * BOARD_SIZE) { gameFinished = true; return MoveResult.DRAW; } currentPlayer = 3 - currentPlayer; return MoveResult.OK; } public synchronized int[][] snapshot() { int[][] copy = new int[BOARD_SIZE][BOARD_SIZE]; for (int i = 0; i < BOARD_SIZE; i++) { copy[i] = board[i].clone(); } return copy; } public int currentPlayer() { return currentPlayer; } }这段代码里有两个值得注意的设计:currentPlayer = 3 - currentPlayer利用了黑棋为 1、白棋为 2 的数字关系,把状态切换写成一行;snapshot()返回克隆二维数组而不是直接返回内部引用,后续在网络层把快照交给 UI 线程刷新时,对方无法通过拿到引用直接篡改棋盘。synchronized放在place方法上,锁的是模型实例本身,保证同一时刻只有一个线程能落子。
2.2 胜负判定为什么只扫四个方向而不是全盘扫
新手常见写法是每次落子后嵌套两层循环,遍历 225 个格子分别判断横竖斜三条线是否连成五子。这种做法也能跑得动,但每次落子做 15 次乘法和维度转换,而且代码里到处都是row、col的边界判断,很容易漏掉某个方向的越界。更合理的判断是:既然只有当前落子可能导致胜负,那就以当前点为起点,向四个方向的正反向分别扫描连续同色棋子。
private static final int[][] DIRECTIONS = { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; private boolean hasFive(int x, int y) { int player = board[x][y]; for (int[] d : DIRECTIONS) { int count = 1 + scan(x + d[0], y + d[1], d[0], d[1], player) + scan(x - d[0], y - d[1], -d[0], -d[1], player); if (count >= 5) { return true; } } return false; } private int scan(int x, int y, int dx, int dy, int player) { int count = 0; while (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE && board[x][y] == player) { count++; x += dx; y += dy; } return count; }DIRECTIONS中的四个二元组分别代表:横向坐标递增、纵向坐标递增、右下斜向、右上斜向。{1, -1}作为反对角线方向,扫描时需要反向计算x - d[0], y - d[1],不能写错符号。scan方法在撞到边界或遇到非己方棋子时自然停止,不需要额外判断“长度为多少”,因为调用方只需要累加结果是否大于等于 5。
这套逻辑还有一个隐含好处:它天然支持“悔棋后重新判定胜负”。如果实现悔棋,只需要把(lastX, lastY)对应位置置回EMPTY,并将gameFinished复位为false,下一次落子仍然从当前位置重新扫描,不需要任何全局重算。
2.3 模型层应该把“禁手”放在哪里
五子棋在很多平台上有禁手规则,黑棋禁止下出双三、双四和长连。做课程设计或内部对战系统时,有没有禁手不影响主流程,但源码结构上必须留下接口。常见做法是在place方法里加一个RuleValidator接口:
public interface RuleValidator { MoveResult validate(GomokuModel model, int x, int y, int player); }无禁手实现直接返回OK,检测到禁手则返回ILLEGAL_MOVE。这比在place方法里写一堆if (x == 10 && y == 10)的硬编码干净得多。真正对战时往往默认不开启禁手,因为双方需要先约定规则,但在设计实现层面保留这个扩展点,后续如果要接入裁判功能,直接替换RuleValidator实现即可。
3. 对战系统消息协议与 Java 线程模型实现
3.1 给对战系统设计一个按行分隔的明文协议
对战系统里最基础的通信是“把一方坐标告诉另一方”。传输层可以用 Java 自带Socket加BufferedReader,但协议格式必须先定死。我见过不少源码直接用ObjectOutputStream把整个Point对象序列化后发过去,局域网偶尔能跑通,一旦换电脑或跨语言客户端就崩,因为Serializable的类结构版本号对不上。更稳的方式是用一行一段的明文消息。
| 消息方向 | 报文示例 | 语义 |
|---|---|---|
| 客户端到服务端 | move:7,7 | 客户端请求在坐标 (7,7) 落子 |
| 服务端到客户端 | drop:7,7 | 服务端广播落子结果给对端 |
| 客户端到服务端 | ready:1 | 客户端确认准备完毕 |
| 服务端到客户端 | turn:1 | 告诉客户端当前轮到哪一方 |
| 任意方向 | leave:bye | 宣布对局结束或玩家退出 |
其中字段分隔符是冒号,坐标分隔符是逗号,行结束符必须是\n。这个设计的好处是BufferedReader.readLine()天然按行切分,不会出现半包问题。很多初学者在这里踩坑:用socket.getInputStream().read()读单字节,然后手工拼字符串,结果在\r\n混用时解析出错。
服务端收到move:7,7后,不能直接转发给对端,必须先更新自己持有的GomokuModel。只有模型返回OK或WIN,服务端才广播drop:7,7。客户端拿着自己的模型也判断一次,但最终以服务端广播消息为准。这样设计是为了防止客户端跳过规则空转。
3.2 用 BlockingQueue 把 socket 线程和模型线程解耦
一段典型的服务端接收循环长这样:
public class GameConnection implements Runnable { private final Socket socket; private final BlockingQueue<String> eventQueue; private final GomokuModel model; public GameConnection(Socket socket, BlockingQueue<String> queue, GomokuModel model) { this.socket = socket; this.eventQueue = queue; this.model = model; } @Override public void run() { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = in.readLine()) != null) { eventQueue.put(line); } } catch (IOException e) { // 连接异常,通常在这里通知 RoomManager 移除玩家 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { try { eventQueue.put("leave:bye"); } catch (InterruptedException ignored) { } } } public void dispatch(String line) { if (line.startsWith("move:")) { String[] parts = line.split(":")[1].split(","); int x = Integer.parseInt(parts[0]); int y = Integer.parseInt(parts[1]); MoveResult result = model.place(x, y); if (result == MoveResult.OK || result == MoveResult.WIN) { // 模型已更新,把事实广播出去 broadcast("drop:" + x + "," + y); } } else if (line.equals("ready:1")) { // 走就绪状态机,把 turn 消息发给当前玩家 } } }这个设计的关键点在于eventQueue.put(line)。socket 读取线程不直接改棋盘,而是把消息丢进LinkedBlockingQueue,模型消费线程从队列里取消息并处理。BlockingQueue内部用ReentrantLock和Condition保证线程安全,因此不需要自己在 socket 线程里加synchronized。消费端取到消息后调用model.place,模型内部已加锁,逻辑上形成“单生产者、单消费者”的流水线,时间顺序不会乱。
如果不用队列,直接在 socket 线程里改模型,然后调用JPanel.repaint(),界面很可能出现撕裂——Swing 的绘制线程和网络回调线程在抢事件队列。正确做法是把 UI 刷新也做成一个任务,交给SwingUtilities.invokeLater。
3.3 客户端不要自行落子,专注表现状态
在对战系统源码里最常见的逻辑错误是客户端拿到鼠标点击后,先在自己的棋盘上画一个子,再把坐标发给服务端。这样做一旦出现服务端判定该点非法、后悔或对方悔棋,客户端和服务器状态就不一致了。合理流程是:客户端点击后先把坐标发送出去,等收到drop消息后再更新画面。这段伪代码说明了过程:
public void onMouseClick(int x, int y) { // 不直接修改棋盘 sendToServer("move:" + x + "," + y); } public void onServerDrop(String payload) { String[] coord = payload.split(","); int x = Integer.parseInt(coord[0]); int y = Integer.parseInt(coord[1]); SwingUtilities.invokeLater(() -> { gomokuPanel.setStone(x, y, model.currentPlayer()); gomokuPanel.repaint(); }); model.place(x, y); }这里model.place又被调用一次,看似冗余,但作用不同:服务端的model.place用于判定合法性,客户端的model.place只用于记录“当前局面在这个客户端视图下是什么样”。如果服务端已经广播drop,就一定合法,所以客户端调用place只需要取回它产生的胜负结果来弹窗。这样做还有一个好处:游戏回放功能可以通过重新播放所有drop消息来恢复历史画面,数据源头唯一。
4. 五子棋 AI 评估算法与搜索深度参数调整
4.1 四方向扫描统计棋形并给出分数
人机对战系统里,AI 不需要访问网络,它只需要在GomokuModel上执行评估。最常见的实现不是 MCTS,而是一层启发式评分:对每个空格子计算“如果我落在这个点,我能形成多好的棋形,同时我能阻挡对手什么棋形”。棋形用“连子数”加“开放端数”两个特征表示。
连子数指沿某个方向连续同色棋子的个数,开放端数指这条线段两端是否有空位。例如黑棋在横向连续三个棋子,且两侧都是空格,就是一个“活三”。对应分数表如下:
| 连子数 | 开放端数 | 进攻分数 |
|---|---|---|
| 5 及以上 | 任意 | 100000 |
| 4 | 2 | 10000 |
| 4 | 1 | 1200 |
| 3 | 2 | 1200 |
| 3 | 1 | 200 |
| 2 | 2 | 120 |
| 2 | 1 | 20 |
有了这个评分表,判断函数写起来很直接:
private int evaluateDirection(int x, int y, int dx, int dy, int player) { int count = 1; int openEnds = 0; int nx = x + dx; int ny = y + dy; while (inBoard(nx, ny) && board[nx][ny] == player) { count++; nx += dx; ny += dy; } if (inBoard(nx, ny) && board[nx][ny] == EMPTY) { openEnds++; } nx = x - dx; ny = y - dy; while (inBoard(nx, ny) && board[nx][ny] == player) { count++; nx -= dx; ny -= dy; } if (inBoard(nx, ny) && board[nx][ny] == EMPTY) { openEnds++; } if (count >= 5) return 100000; if (count == 4) return openEnds == 2 ? 10000 : 1200; if (count == 3) return openEnds == 2 ? 1200 : 200; if (count == 2) return openEnds == 2 ? 120 : 20; return openEnds == 2 ? 5 : 0; }inBoard是坐标合法性检查,避免出现x + dx为负时对数组取下标。这四个方向各算一次,累加后就是该点对某一方的进攻分数。用同一个函数评估对方玩家,就能得到防守分数。
4.2 进攻分和防守分的加权组合
很多 AI 写得过于激进:永远选自己得分最高的点,结果是对手已经形成冲四还不去堵。优化方式是让最终分等于进攻分加防守分乘以一个系数。示例:
int attack = evaluatePoint(x, y, aiColor); int defense = evaluatePoint(x, y, humanColor); int total = attack + (int) (defense * 1.2);1.2表示防守倾斜度。设置成1.0时 AI 偏攻击,1.3以上则偏保守。实际对战中,如果对手形成“冲四但只有一个空位”的棋形,防守分通常会因为count == 4而拿到一千多分,这已经足够让 AI 放弃自己活三的落点。但如果防守分系数过高,AI 会不断补防御导致进攻节奏全无,所以建议在1.1到1.3之间调参。
4.3 候选点剪枝和一层搜索的实践边界
十五路棋盘有 225 个点,如果 AI 每下一子都遍历全部空格并计算四向评分,计算量并不大,一层搜索平均只需要几十毫秒。问题是胜负往往不在单点决定,比如自己落下一手后,对手下次真正可能下在哪。因此实际对战系统里常做“一层搜索加邻域限定”:
private List<Point> candidateSpots(int radius) { Set<Point> spots = new HashSet<>(); int minX, maxX, minY, maxY; for (int i = 0; i < 15; i++) { for (int j = 0; j < 15; j++) { if (board[i][j] != EMPTY) { for (int dx = -radius; dx <= radius; dx++) { for (int dy = -radius; dy <= radius; dy++) { int nx = i + dx; int ny = j + dy; if (inBoard(nx, ny) && board[nx][ny] == EMPTY) { spots.add(new Point(nx, ny)); } } } } } } return new ArrayList<>(spots); }radius通常取 1 或 2。取 1 表示只评估已有棋子紧邻的候选点,这能跳过大量空旷区域;对五子棋这种局部对抗游戏是有效的。若代码支持两层搜索,就需要在这个候选集合里递归模拟落子,每层只取前 8 到 12 个评分最高的分支,否则两层全展开会慢到不可用。
如果需要一个快速能跑的 AI,我的建议是:保持一层评估,使用radius = 2,防守系数1.2。这样实现出来的 AI 不至于被新手轻松打穿,代码也不会膨胀到 MCTS 那么复杂。人机对战标题下,这是一个性价比很高的落点策略。
5. 源码交付前的回放验证与边界收口
源码从“能在自己电脑上跑”到“能作为对战系统源码展示”,中间还差一步:能不能用自动化方式验证胜负判定和消息协议。手工点鼠标很难覆盖到白棋对角线五连、黑棋长连边界、平局这些情况。常见可行方案是把所有落子记录成文本日志,然后用一个回放器逐行喂给模型。
java -cp out edu.gomoku.tool.ReplayPlayer --file game.log --step-ms 120回放器的代码思路是读取每一行move:x,y,调用model.place(x, y),每步输出snapshot()的哈希或棋盘文本。如果模型判定结果与日志里记录的win/draw不一致,立刻打印失败位置。这里有个容易被忽略的细节:model.place内部已经做了gameFinished检查,回放器在收到win结果后要继续读取后续行,验证这些行都应该返回GAME_OVER。这条逻辑足以一次性覆盖“对局结束还能落子”的严重 bug。
进攻方块也不能只测合法路径。客户端可能因为网络问题发出move:a,7或move:15,15。模型层返回OUT_OF_BOUNDS或填NumberFormatException必须被上层捕获,不能直接抛到事件循环里把线程打断。处理方式是在dispatch方法的if (line.startsWith("move:"))分支里包一层 try-catch:
try { int x = Integer.parseInt(parts[0]); int y = Integer.parseInt(parts[1]); model.place(x, y); } catch (NumberFormatException e) { // 记录非法消息,但不断开连接,也不崩溃 logger.warn("illegal move message: {}", line); }这比把整块代码放到最外层 catch 里更合适,因为如果是服务器内部故障,直接吞掉异常会掩盖问题;而这里吞掉的是“对端协议不合法”,属于可预期的业务异常。
最后一个验收技巧:加密回放日志与实际对战日志的落子序列做 md5 对比。如果从“鼠标点击”驱动的界面对战和从“日志文件”驱动的服务端处理结果不一致,说明某个入口绕过了模型层,直接改了棋盘数据。这一步能在几分钟内帮你找出“界面能赢但网络对战判负”这类的隐性矛盾。把这些边界收口之后,“设计实现源码”就不再只是能运行,而是能够被正常阅读和复现了。
本文还有配套的精品资源,点击获取