资讯详情

资讯详情

Python实现国际数棋:六边形棋盘、联机对战与AI搜索

1. 先把棋盘抽象成数据别急着画界面三年前翻硬盘时找出一个练手项目——Python实现国际数棋图形界面、网络版、AI 三块拆开写前后迭代了七个版本。这个玩法本身不复杂一群带数字的棋子在盘面上挪动靠加减乘除去吃掉对方的子谁先把对方阵地清空或者吃够数谁赢。可真正动手做起来麻烦全在细节棋盘是六边形的坐标换算能把人绕晕联机对局要考虑谁说了算AI 更是个无底洞随手写的 minimax 在 Python 里跑起来慢得像幻灯片。这篇文章就把这三条线完整拆一遍包括我踩过的坑和最后跑通的参数。适合谁看会写基本 Python、想找个中等规模项目练手的人或者已经做过棋类游戏、卡在六边形坐标或网络同步上的人。代码给了可运行的最小骨架规则里各地有差异的地方我都抽成了配置项你按自己熟悉的那套规则填就行。1.1 为什么先做规则内核而不是先拖控件新手最容易犯的错是先开一个窗口、把棋盘画出来然后再想点击之后会发生什么。我当时就是这么干的结果界面画完了发现规则里能不能吃子的判断依赖棋盘上某个方向的连续占位情况而我的界面数据结构只存了哪个像素点有棋子根本表达不了方向关系。最后整个界面推倒重写。正确顺序是反过来的先定义一个与界面无关的Board对象它只知道六边形坐标、棋子编号、轮到谁走能回答三个问题——有哪些合法走法、走一步之后局面变成什么样、现在谁赢了。界面只负责把Board渲染出来并把鼠标点击翻译成坐标AI 只负责读Board然后吐出一个走法。三者之间用纯数据通信谁也不依赖谁。这个设计带来的直接好处是测试极其方便。我后面给规则写了两百多条单元测试全部是纯函数调用毫秒级跑完。而界面和网络层几乎没写过测试——因为它们薄逻辑都在内核里。如果你只想记一句话棋类项目里规则内核的代码量占比应该超过一半界面永远是被动的。1.2 六边形棋盘的三套坐标千万别混着用六边形网格有三套常见坐标偏移坐标offset行列号、轴向坐标axialq/r 两个轴、立方坐标cubex/y/z 且 xyz0。偏移坐标最符合直觉适合打印和存档轴向坐标计算最省事适合做邻居和移动立方坐标算距离最干净。我一开始只用偏移坐标结果写判断两个格子隔了几格时写出了一堆 if-else 分支还全是错的。后来改成内部统一用轴向坐标只在存档和调试输出时转成偏移坐标代码立刻短了一半。轴向坐标的邻居只有六个固定方向距离公式用立方坐标转换后取最大绝对值# hex.py —— 六边形坐标的三个基础工具 SQRT3 3 ** 0.5 class Hex: __slots__ (q, r) def __init__(self, q, r): self.q, self.r q, r def __add__(self, o): return Hex(self.q o.q, self.r o.r) def __eq__(self, o): return isinstance(o, Hex) and self.q o.q and self.r o.r def __hash__(self): return hash((self.q, self.r)) def __repr__(self): return fHex({self.q},{self.r}) # 六个方向顺时针排列索引本身就可以当方向编号存进走法里 DIRS [Hex(1, 0), Hex(1, -1), Hex(0, -1), Hex(-1, 0), Hex(-1, 1), Hex(0, 1)] def to_cube(h): x, z h.q, h.r return x, -x - z, z def hex_distance(a, b): ax, ay, az to_cube(a) bx, by, bz to_cube(b) return max(abs(ax - bx), abs(ay - by), abs(az - bz))这里有个很实用的约定方向用 0~5 的整数表示不要用坐标差。走法存成(from_q, from_r, dir_index, steps)这样的元组比存终点坐标好——因为吃子判定需要知道往哪个方向走存方向就不用反推了。后面做网络传输和 AI 走法排序时整数方向比坐标对象省一半内存。1.3 规则差异做成配置表别写死在代码里国际数棋在国内流传的版本不少最典型的分歧有三处能不能连吃吃子后是否继续用新位置再吃一次、除法除不尽时算不算合法、以及0这个数字棋子能不能作为被除数。我见过几个开源实现直接把这些写死别人想改成自己那套规则就得改源码。我的做法是定义一张RuleConfig字典规则内核每次判定都读它# rules.py DEFAULT_RULES { chain_capture: True, # 吃子后是否允许用落点继续吃 division_exact: True, # 除法必须整除才合法 zero_as_dividend: False, # 0 能否被除 capture_keeps_middle: True, # 中间那枚对方棋子是否保留 win_by_clear: True, # 吃光对方全部棋子即胜 win_by_home: False, # 是否支持占满对方大本营获胜 }这张表最大的价值不是灵活而是让不同规则版本的测试用例可以共存。我本地测试时同时跑division_exactTrue和False两组每次改内核都能立刻知道有没有破坏另一套规则。真实项目里这种配置化 双跑测试的收益比写文档高得多。注意配置项一旦进入网络协议就等同于协议版本号。我在联机消息里带了一个rule_ver字段双方不一致就直接拒绝开局而不是打起来各算各的——那种情况排查起来能要命。2. 规则内核一次行棋到底要判多少东西很多人以为棋类规则就是能不能走过去实际上一次完整的行棋判定要经过四到五个检查点任何一个漏掉都会出现看着像 bug 其实是规则错的现象。这一章把流程按顺序拆开顺便讲几个数值边界上的坑。2.1 局面对象怎么存直接影响 AI 速度最直觉的存法是一个字典dict[Hex, Stone]Stone是一个带side和num属性的对象。这样写最清晰我第一版就这么干的跑规则测试完全没问题。但做 AI 的时候就崩了搜索到深度 4每秒只能跑两三万个节点而字典哈希 对象属性访问的开销占了七成。后来改成两个数组cells是一个定长列表索引是坐标经过偏移转换后的整数 ID值编码为side * 16 num0 表示空。这样一次读取就是一次列表下标访问比查字典快四五倍。坐标 ID 用查表得到pos_to_id和id_to_pos两张表在初始化时建好之后是 O(1) 的常数操作。代价是局面复制变慢了一点列表拷贝 vs 字典浅拷贝但我在搜索里用的是落子 撤销的原地修改模式压根不复制局面所以这个代价可以接受。这是一个典型的取舍为了让搜索快牺牲了 API 的优雅度。我的建议是两套都留——字典版给界面和网络用数组版给搜索用中间加一个转换函数两边各自舒服。2.2 合法性判定的四个检查点一次行棋请求进来我按这个顺序判顺序检查内容失败提示备注1起点是否有己方棋子no_stone最便宜先查2方向是否合法0~5bad_dir顺便防越界3落点是否在盘内且为空或可吃out_of_board/blocked涉及距离计算4若是吃子运算是否成立no_math最贵最后查这个顺序不是随便排的是按判断成本从低到高。第 4 步要枚举四则运算的所有结果涉及整除判断和除零判断比前面三步加起来还慢。AI 搜索里每秒要调用几十万次把最贵的放最后能省下大量无效计算——实测整体提速约 35%。还有个容易漏的点判断落点是否在盘内不能用横纵坐标范围来判六边形边缘是斜的。我是用预计算的距离表——开局时算一遍所有格子到棋盘中心的最大距离把超过半径的格子标记成None之后查表即可一次比较就完事。2.3 吃子运算负数、整除和浮点数是三个雷吃子的核心逻辑是己方棋子 A 沿方向 d中间隔着对方棋子 B落在 B 后面那一格 C 上判断 A 的数字与 B 的数字做运算能否得到 C 的数字。听起来简单写起来全是边界。第一版我直接用了浮点除法结果出现6 / 3 2.0000000000000004这种鬼东西导致本该合法的吃子被判死。解决方式是除法一律转成交叉相乘判断全程只用整数# rules.py —— 返回 {结果值: 运算符标记}全程整数运算 def calc_results(a, b, rule): out {a b: , a - b: -, b - a: -r, a * b: *} if b ! 0 and a % b 0: out[a // b] / if a ! 0 and b % a 0: out[b // a] /r return out第二个雷是被减和被除。很多人只写了a - b和a // b忘了b - a和b // a也是合法的运算方向。这会导致玩家明明觉得能吃、程序却说不能而且只在特定数字组合下才暴露测试时极难复现。我的做法是给每个结果单独打标记这样回放棋谱的时候能显示用 6 除以 3 得到 2玩家一眼看懂发生了什么。第三个雷是0。0做除数会抛异常但0做被除数在数学上是有意义的结果为 0。我的配置项zero_as_dividend默认关掉因为大部分玩家规则里不允许用 0 参与除法。这类规则上模糊、实现上致命的地方最好在配置里显式写出来而不是靠if硬编码。举个具体例子验证一下A 的数字是 6B 的数字是 3C 的数字是 2。6 / 3 2命中整除分支吃子成立。再换个组合A2B6C82 6 8成立。而 A2B6C4则6 - 2 4命中被减分支——如果漏写这条这个局面就吃不掉。2.4 终局判定不只判谁赢还要判和棋胜负条件我实现了两条吃掉对方全部棋子或者把己方棋子全部推进到对方大本营。第二条在规则配置里默认关闭因为不同版本差异太大。真正麻烦的是和棋判定。两个水平相当的 AI 对下时很容易陷入来回重复的局面谁也不肯先动关键子。我最初只靠总步数超过 200 步判和结果在深度搜索里 AI 会算出走完 199 步我就赢了这种荒谬结论。改成记录最近 60 步的局面哈希出现三次重复就判和三次重复规则。同时在 AI 的估值函数里给重复局面一个接近 0 的分数避免它主动往循环里钻。这两个改动加起来不到四十行代码但让 AI 的对局质量提升了一个档次——它不再靠熬步数赢棋了。3. 图形界面用 Canvas 手搓一个能干活的棋盘选图形库时我纠结过 Tkinter、PyQt 和 Pygame。最后选了 Tkinter理由很朴素零依赖、跨平台、打包体积小。PyQt 画出来确实更精致但打包出来的可执行文件要大出几十兆Pygame 适合做动画但控件、菜单、对话框全得自己造。棋类游戏是静态渲染为主Tkinter 的 Canvas 完全够用。如果你的项目需要复杂的动画和高分屏支持那就换 PyQt5但核心架构坐标换算、事件分发、线程边界完全一样替换成本主要是重写渲染函数。3.1 六边形坐标到像素坐标的换算这是界面部分最容易出错的环节。我用的是平顶六边形布局换算公式是# view.py SIZE 34 # 六边形外接圆半径像素 ORIGIN_X, ORIGIN_Y 430, 360 def to_pixel(h): x ORIGIN_X SIZE * 1.5 * h.q y ORIGIN_Y SIZE * SQRT3 * (h.r h.q / 2) return x, y def from_pixel(px, py): # 先转到六边形局部坐标再四舍五入到最近的格 q (px - ORIGIN_X) * 2 / (3 * SIZE) r (py - ORIGIN_Y) / (SQRT3 * SIZE) - q / 2 return round_to_hex(q, r)参数是怎么来的1.5 * SIZE是相邻两列的水平间距SQRT3 * SIZE是相邻两行的垂直间距。这里有个必须算清楚的约束棋子圆的半径不能超过间距的一半否则会互相压盖。SIZE34 时列间距是 51px行间距约 58.9px我把棋子半径定在 24px直径 48px留 3px 缝隙。如果界面缩放SIZE 和棋子半径必须同步按比例变不能只改一个。from_pixel里的四舍五入也有讲究。轴向坐标转像素是精确的但反过来做浮点取整会在格的边界上出现点了半天没反应。我的处理是先算出浮点 q/r再用立方坐标做一次取整修正把 x、y、z 三个分量分别取整然后调整偏差最大的那个这样边缘点击的准确率接近 100%。这个技巧在很多六边形网格教程里能查到值得抄进自己的工具库。3.2 事件分发让点击能找到对应的格子Canvas 的点击事件只给你一个像素坐标你要自己把它变成棋盘坐标再判断这个位置有没有棋子、是不是该你走。我的分发逻辑很薄def on_click(self, event): h from_pixel(event.x, event.y) if h not in self.board.legal_cells: return if self.selected is None: if self.board.stone_at(h) and self.board.side_at(h) self.my_side: self.selected h self.redraw() else: moves self.board.moves_from(self.selected) hit [m for m in moves if m.dest h] if hit: self.send_move(hit[0]) # 只发意图等服务器确认 else: self.selected None # 点错地方就取消选中 self.redraw()注意最后那个else分支——很多人写成忽略点击结果玩家点错位置后必须再点一次才能换子体验很差。取消选中是成本最低的容错。渲染上我用了分层底层画棋盘线中层画格子高亮顶层画棋子。每次状态变化不是清空重画全部而是只重画变化的那一层。整盘全清重画在 61 个格子的规模下其实也能跑但会闪分层之后完全无感。如果格子更多比如 127 格的大棋盘就得考虑增量绘制了。3.3 界面和内核之间必须有一道线程边界AI 思考要几秒钟如果跑在 Tkinter 的主线程里整个窗口会假死——鼠标点不动、关闭按钮没反应、系统甚至弹程序未响应。我第一次遇到时以为是死循环查了半天才发现是线程问题。正确做法是AI 搜索跑在独立线程结果通过队列回传主线程。Tkinter 不是线程安全的子线程绝对不能直接改控件只能往queue.Queue里塞结果主线程用after(50, poll)定时取。def start_ai_thinking(self): self.result_q queue.Queue() def worker(): move search(self.board.copy(), time_limit1.5) self.result_q.put(move) threading.Thread(targetworker, daemonTrue).start() self.after(50, self.poll_ai) def poll_ai(self): try: move self.result_q.get_nowait() except queue.Empty: self.after(50, self.poll_ai) return self.apply_remote_move(move)daemonTrue很关键否则玩家在 AI 思考期间关窗口进程会挂在后台不退出。这个坑我在 Windows 上踩过任务管理器里堆了十几个残留进程。提示搜索线程里要支持中途取消。用一个threading.Event做取消信号迭代加深每层开始时检查一次玩家认输或关闭窗口时置位线程就能在一百毫秒内退出。打包这块顺带说一句用 PyInstaller 打单文件时Tkinter 的资源一般能自动带进去但如果用了自定义字体或图标要在--add-data里显式声明。我吃过一次亏本地跑得好好的发给别人打开是白板排查半天是图标路径没打进去。4. 网络对战从能连上到敢上线联机部分是整个项目里最容易低估工作量的。写一个能收发消息的 demo 只要一下午但要让它在真实网络抖动下不出现双方棋盘不一致得再花两周。核心原则只有一条服务端权威。4.1 服务端说了算客户端只发意图我最初的版本是双方各自本地计算走法然后互发我走了这一步。这个设计在本地测试完美无缺一放到真实网络就出问题两边规则实现有微小差异、丢包导致顺序错乱、其中一方作弊。最典型的症状是同一个局面A 电脑上能吃的子 B 电脑上吃不了然后两边各走各的彻底崩。改成服务端权威后逻辑干净多了客户端点一下发出去的是一个意图起点、方向、目标服务端拿到后在自己的Board上做完整合法性校验通过才落子然后把权威结果和状态哈希广播给所有玩家。客户端收到后直接覆盖本地状态不做任何本地猜测。协议用 JSON 行格式一行一条消息用\n分帧消息类型方向关键字段作用joinC→Sroom, name加入房间startS→Cseats, rule_ver, seed开局带规则版本moveC→Sseq, src, dir, dest行棋意图appliedS→Cseq, board, state_hash权威落子结果rejectS→Cseq, reason意图被拒ping/pong双向ts心跳保活state_hash是我加的一道保险客户端每次收到applied后本地重算一遍哈希跟服务端给的对不上就立刻请求全量同步。上线初期它帮我抓到了两个隐蔽的 bug一个是随机种子没统一导致开局的先手顺序不一致另一个是客户端把吃掉的棋子没从渲染层删掉。4.2 粘包和半包别假设一次 recv 就是一条消息TCP 是字节流不是消息流。你在客户端sendall一次对面可能recv到半条也可能一次收到三条粘在一起。新手最容易写data sock.recv(1024); msg json.loads(data)本地测试永远不出问题因为数据小、延迟低一旦跨网络就随机报JSONDecodeError。正确写法是维护一个缓冲区按分隔符切分# net.py class LineConn: def __init__(self, sock): self.sock sock self.buf b def recv_msg(self): while b\n not in self.buf: chunk self.sock.recv(4096) if not chunk: return None # 对端关闭 self.buf chunk line, self.buf self.buf.split(b\n, 1) if not line: return self.recv_msg() # 跳过空行 return json.loads(line.decode(utf-8)) def send_msg(self, obj): raw json.dumps(obj, ensure_asciiFalse) \n self.sock.sendall(raw.encode(utf-8))两个细节值得说。一是ensure_asciiFalse不加的话中文昵称会变成\uXXXX转义虽然能解析但日志里完全没法看。二是发送必须用sendall而不是sendsend返回值是实际发出的字节数可能小于你给的缓冲区长度剩下的部分就丢了。4.3 断线重连靠增量补发别做全量重传房间里的对局一旦开始玩家掉线是常态。我的处理是服务端为每个房间保留最近 200 条applied记录客户端重连时带上自己的last_seq服务端从那条记录之后开始补发。超过 200 条大约一两百步棋就退回全量同步。为什么要限制 200因为房间列表在内存里一个房间存全部历史会慢慢吃光内存。200 条记录按每条约 300 字节算也就 60KB 一个房间够撑几十个并发房间。还有一个体验细节断线时不要让对手也卡住。服务端检测到某个连接超过 15 秒没收到心跳就标记该玩家为离线同时给对手一个对方掉线60 秒后判负的提示。这 60 秒里如果人回来了就接着下。这个设计比一掉线就结束对局舒服太多尤其是移动网络环境下。4.4 延迟补偿可以预表现但不能预判结果点击之后要等服务端返回才更新画面在 50ms 延迟下会有明显卡顿感。我的折中方案是只做视觉预表现点击后立刻把棋子画到目标位置并做一个半透明的待确认样式等服务端回包再实化或者回滚。逻辑状态始终以服务端为准绝不提前改。这样既要流畅度又不至于出现我这边吃掉了对面说没吃掉的经典事故。实现上就是给渲染层加一个pending图层回包来了清掉。5. 对局 AI写个 minimax 只是起点我见过不少人以为棋类 AI minimax alpha-beta写完就完事了。实际上这两个东西加起来大概占 AI 代码量的三成剩下的七成全是工程优化和估值调参。而且 Python 的性能天花板很低同样的算法在 C 里能搜到深度 12Python 里到 7 就已经很吃力了。5.1 搜索框架迭代加深比固定深度实用固定深度的问题在于不同局面下分支数差异巨大开局空旷分支因子能到 60残局棋子少了可能只有 10。固定深度 5开局算 8 秒残局算 0.1 秒体验像抽风。迭代加深的做法是从深度 1 开始搜逐步加深直到时间用完。看起来做了大量重复工作但因为浅层搜索极快实际总耗时只比直接搜最深一层多 20%~30%。更重要的是它随时能给出当前最佳走法——时间一到就返回上一层的结果不会白算。配合 alpha-beta 剪枝节点数从b^d降到理论上的b^(d/2)量级。算一下分支因子 b≈45深度 4无剪枝是 45⁴ ≈ 410 万节点理想剪枝后约 45² ≈ 2000 节点。实际因为走法排序不完美大概落在 1~3 万节点区间。Python 一个节点大约 15~25 微秒也就是每秒 4~6 万节点深度 4 大约 0.3 秒能跑完。深度 6 理想情况 45³ ≈ 9 万实际约 30 万节点需要 5~7 秒——这就是为什么必须限时迭代加深。5.2 走法排序的收益比想象中大alpha-beta 的效率极度依赖走法顺序。先搜最好的走法剪枝率能接近理论值顺序随便可能退化成全搜索。我按这个优先级排吃子走法按吃掉的目标数字价值降序上一轮迭代中在这个位置产生过剪枝的走法杀手走法历史启发全局统计每个位置造成的剪枝次数次数多往前排其余按静止走法。加上排序之后同样时间限制下的搜索深度从 4 提到了 6棋力提升非常直观。这部分代码不到五十行是我认为性价比最高的一处优化。5.3 置换表同一个局面别算两遍六边形棋盘上不同走法顺序很容易到达同一局面尤其是来回移动的走法。置换表就是把局面哈希 → 搜索结果缓存起来再次遇到直接查表。哈希用 Zobrist 方案为每个(格子ID, 阵营, 数字)组合预生成一个 64 位随机数局面的哈希就是所有非空格子的随机数异或。落子时只需hash ^ 旧值的随机数 ^ 新值的随机数O(1) 更新比每次重算整盘快得多。表项里除了分数和深度还要存一个边界标志——因为这个分数可能是精确值也可能只是至少这么大或至多这么这么大。判断错边界会导致搜索出错误的走法这个 bug 极其隐蔽因为大部分时候结果是对的。EXACT, LOWER, UPPER 0, 1, 2 def negamax(state, depth, alpha, beta): if depth 0 or state.is_over(): return evaluate(state), None key state.zobrist() hit TT.get(key) if hit and hit[depth] depth: if hit[flag] EXACT: return hit[value], hit[move] if hit[flag] LOWER: alpha max(alpha, hit[value]) elif hit[flag] UPPER: beta min(beta, hit[value]) if alpha beta: return hit[value], hit[move] best, best_move -INF, None for mv in order_moves(state): child state.apply(mv) val, _ negamax(child, depth - 1, -beta, -alpha) val -val state.undo(mv) if val best: best, best_move val, mv alpha max(alpha, best) if alpha beta: break # 剪枝 flag EXACT if best alpha_orig: flag UPPER elif best beta: flag LOWER TT[key] {depth: depth, value: best, flag: flag, move: best_move} return best, best_move置换表要限容。我是用普通 dict 加一个 50 万条的上限超了就整表清空——听起来粗暴但实测比复杂的 LRU 替换更省事而且清空的瞬间正好发生在搜索后期影响很小。5.4 估值函数从拍脑袋到可回归估值函数就是这个局面我领先多少。我第一版写了五条规则拍脑袋给权重AI 下起来像个傻子——疯狂用大数字换小数字因为子力权重是常数它觉得换子不亏。改了两版才想明白这游戏里数字本身就是战力6 和 2 完全不等价。所以子力分改成100 2 * num一枚 9 的分值比一枚 1 高出 16 分AI 立刻学会了保护大数字。现在的估值表大概是这个样子评估项权重说明子力数量100 / 枚基础分被吃就掉数字价值2 × 数字大数字更值钱前进距离6 / 格距对方大本营每近一格机动性3 / 步双方合法走法数之差直接威胁15 / 个下一步就能吃子的目标数被威胁-18 / 个己方子处于对方可吃位置中心控制4 / 格中心格机动性更好参数是我用自我对弈跑回归调出来的让两套权重对打 200 局胜率高的留下然后微调。这个方法土但确实有效——同样的搜索深度下调参后的 AI 赢调参前大约七成。还有一个容易被忽略的项防守估值。如果你的棋子挡住了对方回家的路哪怕它一动不动也有价值。我加了一项拦截分计算己方棋子覆盖了对方关键推进路线上的格数这个改动让 AI 在残局明显更难被翻盘。6. 踩过的坑和一份能直接用的排查表这部分是我这几年维护下来最值钱的东西全都是文档里不会写、但一旦遇上能耗掉一整天的问题。6.1 常见问题速查现象大概率原因定位方法处理方式点击格子没反应像素转坐标取整偏移打印点击的原始像素和换算结果用立方坐标取整修正棋子重叠压盖半径大于间距一半量相邻格中心距半径 ≤ 0.45 × 最短间距吃子时灵时不灵漏了被减/被除方向用固定组合写单测全跑一遍四个运算方向全部枚举除不尽被判合法用了浮点除法打印中间结果全整数用取余判整除联机两边棋盘不一致客户端做了本地预判比对state_hash改为服务端权威偶发JSONDecodeError粘包/半包打日志看原始字节按换行分帧缓冲AI 思考时界面卡死搜索在主线程看窗口是否响应子线程 队列回传关闭窗口后进程残留线程非 daemon任务管理器看进程设daemonTrue内存缓慢增长置换表/历史表无上限定时打印len(TT)设上限超限清表中文昵称变乱码缺ensure_asciiFalse看日志里的 JSON发送端统一设置打包后白屏资源文件没打进去看打包日志的add-data显式声明资源路径长局后期变卡每步全量重绘用计时器统计重绘耗时分层增量绘制6.2 几条没写在文档里的经验第一坐标系混用是 bug 的最大来源。我的项目里曾经同时存在三套坐标转换函数分别写在界面、规则、AI 三个文件里各自都能用但微妙地不一致导致 AI 在某些边缘格子上算出的距离和规则判定的距离差一格。后来强制规定只有hex.py可以定义坐标转换其他模块一律调用它问题立刻消失。模块化这件事在几何计算上不是洁癖是刚需。第二别在搜索里用异常做控制流。我早期写吃子判定时用了try/except ZeroDivisionError单线程测试完全正常但搜索里的调用次数是百万级的异常抛出和捕获的开销让速度掉了四成。改成显式判断除数是否为零立刻恢复。Python 的异常在低频场景很优雅在高频循环里是毒药。第三测试用例要覆盖错得最离谱的组合。我给规则写了 200 多条用例最有价值的是几组极端数字0参与的所有运算、1作为除数的除法、两个相同数字相乘、以及最大数字 9 与 9 的组合。这些用例抓出来的 bug 比随机测试多得多。第四AI 的强度不等于深度。我曾经花了两天把搜索深度从 6 优化到 8结果和原来的 AI 对下胜率反而下降了。原因是估值函数的缺陷在深搜索下被放大了——它能算到八步之后但评估局面的标准是错的。后来我把时间花在估值调参上深度退回 6胜率反而高了。先修评估再加深度这个顺序别搞反。第五留一个回放/调试入口能省下大量时间。我在界面里加了一个隐藏的调试面板显示当前局面的 Zobrist 哈希、双方走法数、AI 最近一次的搜索深度和节点数。没有它的时候排查AI 为什么走了这步只能靠猜有了它很多问题看一眼就明白。如果你打算自己动手我的建议是先只做规则内核加命令行对战能完整下一局了再上界面界面跑顺了再上网络最后做 AI。四个阶段各自独立可测任何一步卡住都不会把前面的工作浪费掉。这个项目最大的坑从来不是算法难而是想一口气写完然后在某个联调不通过的下午全部推倒重来——我干过两次。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →