☰
Java围棋代码:棋盘规则、GTP与MCTS工程实现
2026/10/26 2:17:50 网站建设 项目流程

简介:这是一份基于Java实现的围棋游戏项目源码,面向正在学习Java图形界面、数据结构与棋类AI的初学者及课程设计者,可用于理解棋盘建模、落子交互、提子规则与胜负判定的完整流程。压缩包为rar格式,共21个文件、约29KB,包含14个class、4个java源文件以及Eclipse工程配置和偏好设置,整体体量小巧,便于直接导入查看类结构、调试运行逻辑与复用基础代码。已有659人学习下载,说明其作为小型参考项目具有一定借鉴价值。代码覆盖Swing/JavaFX界面搭建、二维数组棋盘存储、落子合法性检查、提子与回合切换,也涉及鼠标事件监听、AI搜索与多线程思考等环节,适合读者对照梳理GUI事件处理、规则实现及Minimax/Alpha-Beta剪枝等知识,对于希望打通棋类程序从界面到规则落地完整链路的读者,也能提供可操作的参照,进而完成自己的围棋小游戏或课程作业。

1. 围棋代码 java:一块能落子、能提子、能数子的 19 路盘

搜「围棋代码 java」的人,多数不是想学围棋规则,而是手里有任务需要完成:课程设计要交一个能落子、能提子、能判胜负的棋盘,产品里想嵌一块 19 路盘做小游戏,或者自己训练了个模型,缺一个能对话的前端。这份 Java 围棋代码要解决的就是这件事:棋盘表示、落子合法性、提子、打劫、终局数子、SGF 复盘、GTP 引擎接口,全部用纯 Java 拆成互不依赖的几层,规则层不碰 Swing,AI 不碰棋盘内部数组。它适合三类人:赶作业但不想抄一个跑不起来的 demo 的人,想把棋盘塞进已有后端服务的人,以及手里有算法、需要一套干净规则层来验证的人。下面按我拆包的顺序走,先讲最容易写错、也最影响后面一切的那一层。

2. 棋盘与气:byte[]存储、预计算邻居和一次 BFS 判定

这一层决定了后面所有代码的样子。棋盘结构选错,打劫、数子、MCTS 会一路别扭下去,改到最后只能推倒重写。我见过不少能跑的 demo,卡在这一步:不是逻辑错,是结构选得让逻辑没法写对。

2.1 棋盘为什么用byte[]而不是int[][]

int[][]写起来直观,board[r][c]一眼看懂,问题出在三个地方。第一个是拷贝成本,MCTS 每模拟一步都要一份棋盘快照,int[][]的clone()是浅拷贝,得老老实实 new 19 个数组、循环复制;byte[]一次clone()就是 361 字节,快到可以忽略。第二个是缓存局部性,一维数组连续排布,遍历 361 个点时 CPU 缓存命中率高,数子和气统计这类全盘扫描的收益很明显。第三个是哈希,一维下标是现成的,做 Zobrist 打劫指纹时不用来回换算。

索引约定是idx = row * size + col,行优先,左上角是 0。这个约定必须全包统一,Board、数子、SGF 解析、GTP 坐标转换全部照这一个公式来。反过来写成col * size + row也能跑,但只要你漏改一处,表现出来就是棋盘被转置了——棋子下在镜像位置,而且只在非对称局面下才看得出来,通常要等到复盘才发现。

邻居关系提前算好存起来。19 路盘 361 个点,每个点最多 4 个邻居,一共不到 1500 个整数,内存可以忽略,换掉的是每次落子都要做的四次边界判断。

public final class Board { public static final byte EMPTY = 0, BLACK = 1, WHITE = 2; private final int size; private final byte[] cells; // 一维存储,idx = row * size + col private final int[][] neighbors; // 预计算邻居,省掉边界判断 private final int[] neighborCount; public Board(int size) { this.size = size; this.cells = new byte[size * size]; this.neighbors = new int[size * size][4]; this.neighborCount = new int[size * size]; for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { int idx = r * size + c; int n = 0; if (r > 0) neighbors[idx][n++] = idx - size; // 上 if (r < size - 1) neighbors[idx][n++] = idx + size; // 下 if (c > 0) neighbors[idx][n++] = idx - 1; // 左 if (c < size - 1) neighbors[idx][n++] = idx + 1; // 右 neighborCount[idx] = n; // 边角实际只有 2~3 个 } } } public Board copy() { // MCTS 快照用,一次数组复制 Board b = new Board(this.size); System.arraycopy(this.cells, 0, b.cells, 0, this.cells.length); return b; } public byte get(int idx) { return cells[idx]; } public int size() { return size; } public int pointCount() { return cells.length; } }

参数说明:size支持 9、13、19,传别的值也不会崩,只是贴目和让子参数要自己调。颜色值取 0/1/2 而不是枚举,是为了3 - color直接拿到对手色,省一次 switch,热点路径上这点差别在每秒几十万次模拟里是能测出来的。neighborCount单独存一份,避免在循环里判断neighbors[idx][k] != 0——0 是合法下标,拿 0 当哨兵是经典翻车点。

2.2 气的统计:时间戳去重的一次 BFS

数气最直观的写法是递归:拿到一个点,看四个邻居,同色就递归进去。这个写法在 19 路盘上一般不会栈溢出,但会重复访问同一块棋的同一个点,一块大龙反复展开,复杂度从 O(块大小) 变成指数级,实测一盘棋跑到中盘就能卡到秒级。

正确的做法是一次广度优先,把整块棋收集起来,同时统计气。去重不能每次Arrays.fill(visited, false),361 个点的数组填一遍看起来不多,但数气是每手棋、每次模拟都要调的高频函数,累加起来很可观。用时间戳:维护一个自增的stampTick,访问过的点记上当前 tick,判断时只看stamp[nb] == stampTick,省掉清空动作。tick 溢出到负数时重新填 0 再继续,这个分支一万年碰不到一次,但不写就是隐患。

private final int[] stamp = new int[pointCount]; private int stampTick = 0; private final int[] queue = new int[pointCount]; // BFS 队列,复用不 new private int lastGroupSize = 0; // 上一次收集到的块大小 /** * 从 start 出发收集整块棋并返回气数。 * @param start 起点,必须是非空点 * @param out 输出缓冲,长度 >= pointCount,前 lastGroupSize 个是整块棋的下标 * @return 这块棋的气数,0 表示无气可提 */ public int collectGroup(int start, int[] out) { if (++stampTick <= 0) { // 溢出兜底,正常跑不到 java.util.Arrays.fill(stamp, 0); stampTick = 1; } byte color = cells[start]; int head = 0, tail = 0, libs = 0; queue[tail++] = start; stamp[start] = stampTick; while (head < tail) { int cur = queue[head++]; for (int k = 0; k < neighborCount[cur]; k++) { int nb = neighbors[cur][k]; if (stamp[nb] == stampTick) continue; // 本轮的重复访问 stamp[nb] = stampTick; byte v = cells[nb]; if (v == EMPTY) { libs++; // 同一个空点只算一次气 } else if (v == color) { queue[tail++] = nb; // 同色入队,继续扩 } // 敌子只打标记不入队,避免同一颗敌子被重复统计 } } System.arraycopy(queue, 0, out, 0, tail); lastGroupSize = tail; return libs; }

逻辑上两个细节值得记:空点被打上 tick 之后再遇到就跳过,所以一块棋的同一口气不会被数两遍,这是libs准确的前提;敌子也打 tick,但它不进队,只是避免同一颗敌子被反复检查。queue和out都从外部传或复用一个成员数组,是为了让这个函数在每秒几十万次调用下不产生垃圾回收压力——MCTS 跑起来以后,GC 抖动是性能杀手。

2.3 落子合法性:四条判定必须按这个顺序

顺序是:点位为空 → 劫争检查 → 落子并提掉对方无气的块 → 提完之后自己仍无气则非法回滚。很多人把第三条和第四条写反,先判自杀再提子,结果「提掉对方之后自己就有气了」的棋被误判非法,表现为某些贴身缠斗的局面下,明明能提子的点下不进去。这个 bug 在空盘测试里永远复现不了,必须摆出具体的吃子形。

private final int[] groupBuf = new int[pointCount]; private final int[] capturedBuf = new int[pointCount]; private int capturedLen = 0; /** * 尝试落子。 * @param idx 目标点下标 * @param color Board.BLACK 或 Board.WHITE * @return true 表示落子成功并已生效;false 表示非法且棋盘未被修改 */ public boolean play(int idx, byte color) { if (idx < 0 || idx >= cells.length || cells[idx] != EMPTY) return false; byte opp = (byte) (3 - color); cells[idx] = color; capturedLen = 0; // 先提子:只有落子方的敌人可能被提,自己刚下的这一子不可能无气 for (int k = 0; k < neighborCount[idx]; k++) { int nb = neighbors[idx][k]; if (cells[nb] != opp) continue; if (collectGroup(nb, groupBuf) > 0) continue; for (int i = 0; i < lastGroupSize; i++) { int p = groupBuf[i]; cells[p] = EMPTY; capturedBuf[capturedLen++] = p; // 记下来,undo 时按顺序放回 } } // 提完子后仍有气,合法 if (capturedLen > 0) return true; // 一个子都没提到,此时才可能出现自杀 if (collectGroup(idx, groupBuf) == 0) { cells[idx] = EMPTY; // 只回滚自己这一手,提子列表此时必然为空 return false; } return true; }

这段的关键在于:capturedLen > 0时直接返回成功,不再判自杀。道理很朴素——你刚提掉的点已经变成空点,那就是你自己的气,不可能无气。理解这一点,回滚逻辑就能砍到只剩一行;反过来写成「先判自杀」,就必须为提子列表写完整的逆操作,还得保证恢复顺序,一不小心就把棋盘恢复成半截状态。capturedBuf依然要留,因为悔棋、SGF 复盘、UI 高亮被提子都用得上,只是不参与合法性判断。

配套的undo()注意顺序:先按capturedBuf倒序把被提的子放回去,再把cells[idx]清空,最后退历史栈。反过来做,中间态会短暂出现「提子已恢复但落子还在」的重叠局面,如果这时有别的线程在快照,就会读到脏数据。

3. 规则层:打劫指纹、禁着点顺序和终局数子的边界

棋盘能落子只是及格线。规则层要做的是把「什么情况下这手棋不成立」和「什么时候这盘棋结束了、谁赢」这两件事说清楚。这部分是最容易糊弄过去、也最容易被验收卡住的地方——作业验收通常就摆一个打劫形和一个单官局面。

3.1 打劫不是「不能马上提回来」,是局面指纹比对

新手对打劫的理解常常停在「不能立刻提回去」,于是实现成「记录上一手的落点和提子点,如果这手棋刚好提回同一个点就拒绝」。这个写法在标准劫形下勉强能用,一遇到双劫、循环劫就彻底失效。

正确的抽象是把「局面」当成一个整体:给每个局面算一个指纹,新局面的指纹如果和两手之前的局面相同,就是劫。指纹用 Zobrist 哈希:为每个(交叉点,颜色)组合预生成一个随机 long,落子就 XOR 进去,提子再 XOR 一次同一个值(XOR 自逆,天然支持撤销),增量维护,O(1)。

private final long[][] zob = new long[pointCount][3]; private long hash = 0L; private final long[] history = new long[2048]; private int historyLen = 0; /** 初始化时调用一次,固定种子保证同一盘棋每次跑出的指纹一致 */ public void initZobrist(long seed) { java.util.SplittableRandom rnd = new java.util.SplittableRandom(seed); for (int i = 0; i < pointCount; i++) for (int c = 1; c <= 2; c++) zob[i][c] = rnd.nextLong(); history[historyLen++] = hash; // history[0] 是空盘 } /** 落子或提子时调用,同一个 (点,颜色) 异或两次等于没动 */ private void xorPoint(int idx, byte color) { if (color != EMPTY) hash ^= zob[idx][color]; } /** * 落子前调用,判断是否违反劫争规则。 * @param nextHash 假设这手棋落下并完成提子后的局面指纹 */ public boolean violatesKo(long nextHash) { // 简单劫:新局面等于两手前的局面即非法 return historyLen >= 2 && history[historyLen - 2] == nextHash; }

参数说明:zob[pointCount][3]第二维是 3 而不是 2,是为了用颜色值直接当下标,省一次减一。种子固定很重要,否则同一份 SGF 复跑两次算出的指纹不同,回归测试全部失效。history长度 2048 够放一盘 19 路棋(正常对局很少超过 400 手),超出时扩容或直接开环形缓冲。

简单劫的漏洞是循环劫和三劫循环:局面在三个状态之间轮转,每一手都合法,棋盘永远走不到终局。兜底方案是加一条「同一指纹第三次出现即判和」,或者干脆切到位置超级劫——循环全部历史,任何重复局面都非法。19 路盘的搜索代价可以接受,但会让某些正常的劫争变长,取舍看你更需要「不死循环」还是「规则贴近日式实物」。

3.2 禁着点、自杀与变体规则:把开关做成参数

规则变体不要写死在if里,抽成配置,验收时改一个参数就能演示不同规则。常见参数就这么几个:

参数取值影响的代码路径
boardSize9 / 13 / 19Zobrist 表大小、GTP 坐标列字母表
koRuleSIMPLE / POSITIONALviolatesKo是比一手还是循环全部历史
allowSuicidetrue / falseplay()最后一个分支是否直接返回 false
scoringAREA / TERRITORY数子是否额外维护提子计数
komi6.5 / 7.5只影响胜负判定,不影响棋盘状态

allowSuicide = true在教学演示里有用——能直观展示「自杀等于送子」,但正式对局必须关掉,否则 AI 在劣势下会疯狂自杀把搜索树搅乱。komi用浮点是为了避免和棋,6.5 和 7.5 的选择直接决定贴目计算里那半步怎么处理,写死成整数会出现「双方都以为自己赢」的尴尬。

需要一套健壮的状态快照。悔棋、SGF 复盘、MCTS 都要能随时回到某个历史局面,所以维护一条操作日志:每一步记录(类型, 下标, 被提子列表, 落子前的 hash)。回退时按逆序执行,最后恢复hash和historyLen。日志比「每次存一整盘」省内存,也比「只存棋谱重放」快得多。

3.3 终局数子:flood fill 划地盘与两种特殊棋形

中国规则是数子法:己方棋子数 + 只被己方包围的空点数。实现就是遍历全盘,对每个没访问过的空点做一次 flood fill,一边数块大小,一边收集这块空区接触到的颜色;只接触一种颜色就整块归它,两色都接触就是单官或者双活,谁都不算。

/** * 按中国规则数子。 * @return 长度 3 的数组,下标 1 是黑方地域,下标 2 是白方地域,下标 0 不用 */ public int[] areaScore() { int[] score = new int[3]; boolean[] seen = new boolean[cells.length]; int[] stack = new int[cells.length]; for (int i = 0; i < cells.length; i++) { if (cells[i] != EMPTY) { // 盘上的子本身就算一分 score[cells[i]]++; seen[i] = true; continue; } if (seen[i]) continue; int top = 0, border = 0, regionSize = 0; stack[top++] = i; seen[i] = true; while (top > 0) { int cur = stack[--top]; regionSize++; for (int k = 0; k < neighborCount[cur]; k++) { int nb = neighbors[cur][k]; if (cells[nb] == EMPTY) { if (!seen[nb]) { seen[nb] = true; stack[top++] = nb; } } else { border |= cells[nb]; // 1|2 得 3,表示两色都碰到 } } } // border 为 1 或 2 才归属单方;为 3 是单官/双活,不计 if (border == BLACK || border == WHITE) score[border] += regionSize; } return score; }

border |= cells[nb]这一行是整个函数的灵魂:黑是 1、白是 2,或运算之后等于 3 就说明这块空区被两色同时接触,是单官或者双活,不计入任何一方。写成「记录第一个碰到的颜色,后面碰到不同颜色就标记中立」也对,但多一个状态变量、多一层判断,还容易在「先碰到黑后碰到白」和「先碰到白后碰到黑」两条路径上写出不一致的分支。

日本规则要复杂一步:数的是「空 + 提子」,所以Board里要额外维护capturedByBlack、capturedByWhite两个计数器,在play()提子时累加、undo()时扣减。另外日本规则下双活的处理和中国规则差别很大,做复盘对比时不要拿两套结果直接对拍,会以为是 bug。

4. 引擎接入:GTP 报文、三层解耦和一棵 60 行的 MCTS

规则层写对之后,接界面和接 AI 是两条独立的路。这里最容易犯的错是让 UI 直接操作cells,或者让 AI 拿到Board的引用随便改——一旦两边都能写,调试时会陷入「到底是谁改坏了棋盘」的困境,而且这种 bug 通常表现为随机崩溃,非常难查。

4.1 用接口把界面、规则、AI 隔成三层

分层原则只有一条:数据单向流动。Board是纯数据,只提供读和落子;RuleSet包装Board,对外只暴露isLegal、play、undo、score;AI 只能拿Board.copy()的快照,改自己的副本;UI 通过回调接收变更通知,永远不直接写数组。

/** UI 只实现这个接口,规则层落子成功后回调,参数是只读事件 */ public interface MoveListener { void onMove(int idx, byte color, int[] captured); void onUndo(int idx, byte color, int[] restored); } /** 引擎接口,返回值必须是自己算出来的,不能改传入的棋盘 */ public interface Engine { /** * @param snapshot 当前局面快照,引擎可以随意修改这份副本 * @param color 轮到谁走 * @param timeBudgetMs 思考时间预算,超时也要给出合法着法 * @return 落子下标,或 -1 表示停一手 */ int genmove(Board snapshot, byte color, long timeBudgetMs); }

这个约束看起来只是「设计得干净一点」,实际价值在于可测试:给定一个 SGF 局面,注入一个固定随机种子的引擎,输出必须是确定的着法序列,可以写成断言。如果 AI 能直接改真实棋盘,这个测试就没法做,因为跑两遍结果不一样。

4.2 GTP 报文:一行请求、一行响应的硬约束

GTP 是围棋引擎和前端之间最通用的文本协议,格式简单到可以用telnet手工测:请求一行,响应以=开头表示成功、?开头表示失败,然后一个空行结束。id可选,前端传了就必须原样回显。

/** 处理一行 GTP 请求,返回完整响应(含结尾的两个换行) */ public String handle(String line) { line = line.trim(); if (line.isEmpty()) return ""; int sp = line.indexOf(' '); String cmd = (sp < 0) ? line : line.substring(0, sp); String args = (sp < 0) ? "" : line.substring(sp + 1).trim(); switch (cmd) { case "protocol_version": return "= 2\n\n"; case "name": return "= JavaGo\n\n"; case "boardsize": reset(Integer.parseInt(args)); return "= \n\n"; case "clear_board": reset(size); return "= \n\n"; case "komi": komi = Double.parseDouble(args); return "= \n\n"; case "play": { String[] p = args.split("\\s+"); byte color = "b".equalsIgnoreCase(p[0]) ? BLACK : WHITE; int idx = gtpToIndex(p[1]); if (!play(idx, color)) return "? illegal move\n\n"; return "= \n\n"; } case "genmove": { byte color = "b".equalsIgnoreCase(args) ? BLACK : WHITE; int idx = engine.genmove(copy(), color, 800); play(idx < 0 ? passIndex() : idx, color); return "= " + indexToGtp(idx) + "\n\n"; } case "quit": return "= \n\n"; default: return "? unknown command\n\n"; } } private static final String GTP_COLS = "ABCDEFGHJKLMNOPQRST"; // 跳过字母 I private int gtpToIndex(String s) { int col = GTP_COLS.indexOf(Character.toUpperCase(s.charAt(0))); int row = size - Integer.parseInt(s.substring(1)); // 行号从下往上 return row * size + col; }

三个容易忽略的点:坐标列字母表必须跳过I,否则 D4 会被算成 E4,整盘偏移一列;行号在 GTP 里是从下往上数的,和内部row = 0在顶部相反,必须减一次;stdout只能输出协议响应,任何调试日志走stderr,一行多余输出就会让前端解析失败,表现为「引擎卡住不吭声」。最后一条我调过很久,引擎明明在算,界面就是不动——就是某行System.out.println干的。

手工验证协议闭环,用管道把几条指令一次喂进去最省事:

# 九路盘上走一手黑、让白方应一手,检查响应格式与坐标是否对齐 printf 'boardsize 9\nclear_board\nplay b e5\ngenmove w\nquit\n' \ | java -cp target/classes your.pkg.GtpMain 2>/dev/null

期望输出是四组=开头的响应加一个空行,genmove那行必须是一个合法坐标。如果只看到前几行,基本就是对端在读stdout时被缓冲卡住了,在启动处加System.out.flush()或者直接关掉自动刷新缓冲。

4.3 MCTS:把 UCT 公式落成能跑的结构

一个不依赖外部引擎、自己能下出像样棋的搜索,用蒙特卡洛树搜索是最省代码量的。核心就四步:选择(按 UCT 挑子节点)、扩展(加一个没试过的着法)、模拟(随机下到底)、回传(更新路径上的访问数和胜场)。

final class Node { final Node parent; final int move; // 到达本节点的着法,-1 表示停一手 final byte colorToPlay; final Node[] children; // 惰性创建,初始 null final int[] tried; // 已尝试的着法下标 int triedCount, visits, wins; }

shouldExpand之前用visits做门槛,UCT = wins/visits + 1.414 * sqrt(ln(parent.visits) / visits)。1.414是探索常数,调大到 2.0 会更多尝试冷门着法、棋更野,调到 0.5 会更快收敛到当前最优、也更容易钻牛角尖。九路盘上给 800 毫秒能到大约 2 万次模拟,棋力已经能压住纯随机;十九路盘同样的时间只够在局部纠缠,全局观需要扩到几十万次模拟,这时就得把Board.copy()做成可复用对象池,不然光是分配数组就把时间耗光了。

一个血泪教训:模拟阶段的随机走子必须用规则层的合法着法判定,不能图快自己写个「空点就下」。随机走到一半出现自杀或者打劫,整棵树的胜负统计全部失真,表现出来是 AI 越下越差、越到后盘越像在送子,而单步测试每次都过。

5. 避坑与排查:五个只在真机上冒头的问题

下面五条都是我踩过或者帮人查过的,共同点是在空盘、单步、手测的情况下完全不出现,一上完整流程就爆发。每条按「现象 → 原因 → 解决」记。

5.1 悔棋之后棋盘上多出几颗「幽灵子」

现象:连下十几手后连续悔棋,棋盘上的子数和预期对不上,多出来的子位置正好是之前被提掉的点,而且再下一次落子就可能出现同色重叠。

原因:undo()里先清了cells[idx],再恢复captured列表,中间态被MoveListener读走了,UI 缓存的棋盘和规则层的棋盘就这么劈叉。或者恢复captured时用了正序而不是逆序,遇到一手提两块的局面就还原错位。

解决:undo()里严格按「倒序恢复被提子 → 清除落子 → 退 hash → 退 historyLen → 最后才发通知」执行,通知放在状态完全一致之后。所有外部读棋盘的地方都通过回调拿,不许自己缓存一份。

5.2 打劫判定在双劫局面下彻底失效

现象:正常单劫能正确拒绝,摆出双劫或者连环劫之后,双方可以无限循环提子,程序永远不判终局。

原因:只比了「两手前的局面」,在两个独立的劫交替进行的局面下,每一步都和上上步不同,简单劫规则抓不住。

解决:把koRule切到位置超级劫,循环全部history比对指纹;同时加一条兜底——同一指纹出现第三次直接判和,并给出提示。不要指望只用简单劫,实战里循环劫不是理论问题,是必然会出现的。

5.3 引擎明明在跑,前端却一直等超时

现象:日志显示genmove正常返回,前端收到的却是空响应或者被挂死;用 telnet 手敲同样的命令又一切正常。

原因:stdout被缓冲,响应没刷出去;或者某处打了个调试日志到stdout,把协议行污染了;还有一种是quit之后没退出循环,进程挂着不放。

解决:所有协议输出统一在一个respond()方法里拼字符串并立刻flush;System.setErr()单独接日志;quit之后用标志位跳出主循环再System.exit(0)。排查时把stderr重定向到文件单独看,能立刻区分是「没算出来」还是「算出来了没送出去」。

5.4 导入 SGF 后整盘棋向右偏了一列

现象:用第三方棋谱软件打开同一份 SGF,布局正常;导进自己的程序,所有棋子统一偏移一列,开局几手还看不出来,边上的棋一眼就歪。

原因:把 SGF 坐标当成 GTP 坐标处理了。GTP 的列字母表跳过I,SGF 不跳,a到s连用 19 个字母。两套坐标混着用,i之后的列全部错位。

解决:两套转换函数分开写、分开测,别复用。SGF 就是col = c - 'a',GTP 是col = "ABCDEFGHJKLMNOPQRST".indexOf(c),各自加一个从a1、A1到下标再回转的单元测试,用i列和t列的点各测一遍。

5.5 点「思考」按钮之后界面假死几秒

现象:AI 思考时整个窗口无响应,鼠标点不动,标题栏显示「未响应」,思考结束才恢复。

原因:把genmove直接写在按钮的事件回调里了。Swing 的事件分发线程被长时间占用,重绘和输入全部排队。

解决:AI 计算丢到ExecutorService,回调里submit之后立刻返回,结果用SwingUtilities.invokeLater送回事件线程更新界面。同时在按钮上禁用状态、显示「思考中」,避免用户连点触发多份并发计算——两份计算同时改同一份棋盘,那才是真正的灾难。

6. 进阶:用 SGF 回归集把规则层钉死

规则层最怕的不是写错,是改一处崩另一处。今天修好打劫,明天发现数子错了;改完数子,提子又少算一块。压住这种反复的唯一办法是建一套自动跑的回归集,而不是每改一次就手动摆一遍棋形。

具体做法:收集或手工构造几十份小棋谱存成 SGF,九路盘为主,覆盖吃子、打劫、双活、单官、让子五类局面;测试时逐手重放,每落一手就比对一次局面指纹和双方地域。指纹用固定种子算,同一份 SGF 每次结果必须完全一致。

/** SGF 坐标转内部下标,注意和 GTP 的区别:SGF 不跳过字母 i */ static int sgfToIndex(String pt, int size) { int col = pt.charAt(0) - 'a'; int row = pt.charAt(1) - 'a'; // SGF 行号从上往下,和内部 row 方向一致 if (col < 0 || col >= size || row < 0 || row >= size) return -1; return row * size + col; }

写完这个函数先拿一份已知答案的棋谱验一遍,重点看第 9 列(i)和第 19 列(s)上的子有没有错位——这两个位置正好卡在两套坐标体系的分歧点上,最容易暴露问题。

回归集之外,加一条性能基线:固定开局,让引擎在九路盘上跑 100 手,记录总耗时和每秒模拟次数。这条基线的价值不在绝对数值,而在变化趋势——某次改动之后模拟次数突然掉一半,通常意味着某个高频路径里多了一次数组分配或者一次Arrays.fill,而这种退化靠功能测试永远发现不了。

还有个小技巧:把komi和koRule做成回归集里的参数维度,同一份棋谱在不同规则下跑一遍,结果差异应该符合规则本身的定义。如果两种规则下算出完全一样的胜负,先怀疑参数没真正生效,而不是庆幸「实现得真兼容」。

从那以后我每次改规则层,都强制先跑一遍回归集再动手改下一处,绝不靠临时摆棋形验证——手摆的那种,摆的永远是自己已经想到的那几种情况,真正的坑都在没想到的地方。希望帮到你。

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

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

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

立即咨询