首页 / 资讯中心 / 文章详情

Java围棋代码:棋盘规则、GTP与MCTS工程实现

Java围棋代码:棋盘规则、GTP与MCTS工程实现 ★ FEATURED ARTICLE
简介这是一份基于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 拆成互不依赖的几层规则层不碰 SwingAI 不碰棋盘内部数组。它适合三类人赶作业但不想抄一个跑不起来的 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()最后一个分支是否直接返回 falsescoringAREA / 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、scoreAI 只能拿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()方法里拼字符串并立刻flushSystem.setErr()单独接日志quit之后用标志位跳出主循环再System.exit(0)。排查时把stderr重定向到文件单独看能立刻区分是「没算出来」还是「算出来了没送出去」。5.4 导入 SGF 后整盘棋向右偏了一列现象用第三方棋谱软件打开同一份 SGF布局正常导进自己的程序所有棋子统一偏移一列开局几手还看不出来边上的棋一眼就歪。原因把 SGF 坐标当成 GTP 坐标处理了。GTP 的列字母表跳过ISGF 不跳a到s连用 19 个字母。两套坐标混着用i之后的列全部错位。解决两套转换函数分开写、分开测别复用。SGF 就是col c - aGTP 是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做成回归集里的参数维度同一份棋谱在不同规则下跑一遍结果差异应该符合规则本身的定义。如果两种规则下算出完全一样的胜负先怀疑参数没真正生效而不是庆幸「实现得真兼容」。从那以后我每次改规则层都强制先跑一遍回归集再动手改下一处绝不靠临时摆棋形验证——手摆的那种摆的永远是自己已经想到的那几种情况真正的坑都在没想到的地方。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站