ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Java五子棋对战系统源码:核心模型、网络通信与AI评估

2026/9/23 23:23:23 拓冰建站 浏览量
基于Java五子棋对战系统源码:核心模型、网络通信与AI评估 简介一套基于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 BlockingQueueString eventQueue; private final GomokuModel model; public GameConnection(Socket socket, BlockingQueueString 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 及以上任意100000421000041120032120031200221202120有了这个评分表判断函数写起来很直接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 ListPoint candidateSpots(int radius) { SetPoint 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-catchtry { 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 对比。如果从“鼠标点击”驱动的界面对战和从“日志文件”驱动的服务端处理结果不一致说明某个入口绕过了模型层直接改了棋盘数据。这一步能在几分钟内帮你找出“界面能赢但网络对战判负”这类的隐性矛盾。把这些边界收口之后“设计实现源码”就不再只是能运行而是能够被正常阅读和复现了。本文还有配套的精品资源点击获取