☰
Python国际数棋:规则内核、pygame与Alpha-Beta AI
2026/10/29 7:36:06 网站建设 项目流程

这个题目我第一次看到的时候,第一反应是“这不就是个课后作业”。规则简单,8×8 棋盘,双方各十枚数字棋子,两个人轮流动手;写个棋盘、接个鼠标点击、加个胜负判断,收工。真动手写了两个多星期之后我才发现,真正耗时间的从来不是规则本身,而是三种完全不搭界的东西被硬塞进同一个项目里:图形界面关心的是坐标映射和重绘节奏,网络对战关心的是两端状态能不能对齐,AI 关心的是每秒能生成并评估多少个节点。这三块的技术栈几乎不重叠,却又必须共享同一套规则内核——规则一旦有两份实现,早晚会在某个角落对不上。

所以这篇东西我想聊的不是“国际数棋怎么玩”,那部分网上一搜一大把。我想聊的是一个能落地的 Python 工程应该怎么切分:规则内核怎么设计成既能让 GUI 调用、又能让 AI 快速搜索、还能让网络层做校验的形态;pygame 那套事件循环在棋盘游戏里到底该怎么用才不别扭;网络对战为什么我最后放弃了状态同步;以及 AI 从随机走子一路爬到 alpha-beta 的过程中,哪些优化是真有效、哪些纯粹是自我感动。适合已经会写 Python、想做点完整项目练手的人,也适合想给自己的小游戏加个像样的对手的人。

1. 规则拆解:把“四则运算走子”翻译成机器能判的东西

国际数棋的规则在不同地区、不同教材版本里存在细节差异,这一点必须先说清楚。我手上这套是自洽的:8×8 棋盘,双方各持 0 到 9 十枚数字棋子,红方初始摆在下方两行、蓝方摆在上方两行;轮到谁走,就挑一枚自己的棋子作为行棋棋子,再挑盘面上任意一枚其他棋子作为参照棋子(不分敌我),用两枚棋子上的数字做加、减、乘、除中的一种,得到一个正整数作为步数,然后让行棋棋子沿八个方向之一直线走这么多格,路径必须畅通,落点若有对方棋子则吃掉。棋子走到对方底线区域即视为“送达”,移出棋盘并计一分,先送完十枚的一方获胜。

这里有个设计上的关键判断:参照棋子只贡献数字,不贡献位置。这句话听起来像废话,但它直接决定了后面所有的工程结构。因为参照棋子是谁、在哪、属于哪一方,全都不影响结果,唯一有影响的是“盘面上当前存在哪些数字”。这个洞察让我把走法生成从“枚枚棋子两两配对”的 O(n²) 结构,压缩成了“维护一个数字计数表 + 每枚棋子查表”的 O(n×10) 结构。实测下来,中局阶段单方走法生成时间从 1.8 毫秒降到 0.2 毫秒,AI 搜索深度直接多跑了一层。

还有一个必须提前定死的边界:除法怎么算。如果允许 7÷2 取整得 3,那规则就出现了歧义空间,玩家会觉得“凭什么不是 4”。我的处理是只允许整除,a % b == 0才产生合法步数。减法同理,我允许两个方向相减,只要结果为正——也就是本质上取绝对差。这样做走法宽松一些,棋局不至于因为数字搭配不好而卡死。乘法容易炸出大数,但步数一旦超过 7 就没有意义了(棋盘对角线最长 7 格),直接过滤掉,这个过滤同时也顺手解决了“步数越界”的问题。

数字 0 是最容易埋雷的地方。0 参与加法等于对方数字本身,参与乘法得 0(非法),作为除数直接崩,作为被除数永远是 0(非法)。我在生成表的时候就把它全部处理掉了,生成阶段过滤比在搜索里判断便宜得多。

DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] def build_steps(a: int, b: int) -> frozenset: """返回数字 a 与 b 组合后所有合法的行棋步数(1~7)""" out = {a + b, abs(a - b), a * b} if b and a % b == 0: out.add(a // b) if a and b % a == 0: out.add(b // a) return frozenset(s for s in out if 1 <= s <= 7) # 数字只有 0~9,全组合也就 100 种,模块加载时一次算完 STEP_TABLE = tuple( tuple(build_steps(a, b) for b in range(10)) for a in range(10) )

这张 100 项的静态表是整个项目里性价比最高的优化,写起来十行,省下来的是后面 AI 每秒几千次调用的开销。很多人在这一步图省事,直接在走法生成里现算,等到跑搜索的时候才发现瓶颈根本不在搜索,而在这些鸡毛蒜皮的运算上。

1.1 棋盘表示:为什么我用 int8 而不是元组

棋盘状态会被三拨人同时访问:GUI 要按格子取棋子画图,网络层要序列化传输,AI 要疯狂复制和比较。这三方对数据结构的要求是冲突的——GUI 想要二维索引方便,网络想要紧凑可压缩,AI 想要哈希快。我的取舍是用一维的bytearray(长度 64),索引r * 8 + c。

棋子编码方式很关键。红方数字 d 编码为d + 1(范围 1 到 10),蓝方数字 d 编码为-(d + 1)(范围 -1 到 -10),空格就是 0。这样 0 天然留给空位,正负号天然区分阵营,取数字只需要abs(p) - 1,取阵营只需要比较符号。比(side, digit)元组省了对象分配,比位棋盘方案又好懂得多——位棋盘在这类棋子上带数字的游戏里并不划算,因为棋子的“身份”是数字本身,不是位置。

def digit_of(p: int) -> int: return abs(p) - 1 def side_of(p: int) -> int: return 1 if p > 0 else -1 def apply_move(board: bytearray, mv) -> bytearray: """mv = (from_idx, to_idx),返回新盘面,不修改原盘面""" nb = board[:] # bytearray 切片复制,比 list 快 3 倍左右 p = nb[mv[0]] nb[mv[0]] = 0 nb[mv[1]] = p # 落点原有棋子被直接覆盖,等价于吃子 return nb

不可变盘面这个决定,后面在置换表和网络重放里救了我好几次。GUI 想实现悔棋、AI 想做搜索回溯、网络想存整局棋谱,全都只需要留一个盘面引用,不用担心谁偷偷改了它。代价是每步复制 64 字节,这个量级在现代机器上基本可以忽略,实测每秒能复制上百万次。

2. 走法生成与合法性判定:性能的分水岭在这里

走法生成是整个引擎里调用最频繁的函数,一次搜索可能要跑几十万次。它的结构决定了 AI 的上限,也决定了 GUI 里“合法落点高亮”这个交互能不能做得流畅——如果每次鼠标悬停都要算 50 毫秒,那高亮就变成了幻灯片。

我的写法分三步。第一步扫全盘收集当前存在的数字集合(用一个长度 10 的小列表做计数),这一步一次遍历就能完成,而且可以用来判断“某种数字是否还在场上”。第二步对每枚己方棋子,把数字计数表里出现过的数字逐个查STEP_TABLE,取并集得到候选步数集合。第三步对每个候选步数、八个方向做射线检查,判断路径是否畅通、落点是否越界。

第三步是真正的开销所在。朴素写法是每个步数都从头扫一遍路径,但因为步数是集合,我可以先算好“从当前位置沿某方向最多能走多远(被边界或棋子挡住)”,再拿这个上限去筛步数集合,一次方向扫描就能处理所有合法步数。

def gen_moves(board: bytearray, side: int): digits = [0] * 10 for p in board: if p: digits[digit_of(p)] += 1 moves = [] for idx in range(64): p = board[idx] if not p or side_of(p) != side: continue a = digit_of(p) steps = set() for b in range(10): if digits[b]: steps |= STEP_TABLE[a][b] if not steps: continue r0, c0 = divmod(idx, 8) for dr, dc in DIRS: for k in range(1, 8): # 八个方向最多走 7 步 r, c = r0 + dr * k, c0 + dc * k if not (0 <= r < 8 and 0 <= c < 8): break t = board[r * 8 + c] if t and side_of(t) == side: break # 被己方棋子挡住,后面全不通 if k in steps: moves.append((idx, r * 8 + c)) if t: break # 撞到对方棋子,本格可吃,再往后不通 return moves

这段代码有两个细节值得单独拎出来说。第一,for k in range(1, 8)里的break位置。碰到己方棋子直接断,因为后面全被挡住;碰到对方棋子时,当前格要判断能不能作为落点(也就是吃子),判断完再断,因为对方棋子后面也过不去。这两处的顺序如果写反,会出现「明明挡着却能穿过去吃子」的诡异 bug,而且这种 bug 在随机对局里很难复现,得专门构造局面才测得出来。

第二,steps用set而不是列表。看似无所谓,但候选步数里重复值不少(比如 3+3 和 3×2 都是 6),去重能减少后面的射线重复判断。我实测过把 set 换成列表,走法生成慢了大概 8%,算不上致命但白给的钱不捡很难受。

2.1 一个容易被忽略的正确性问题:路径阻挡与吃子顺序

上面那段代码里其实藏着一个规则理解上的坑,我踩了整整一天。最初的版本我是这样写的:先判断落点是否越界,再判断落点是不是自己人,最后判断路径上有没有阻挡。这样写出来的效果是,玩家可以让棋子“飞过”对方棋子去吃后面那枚。

问题出在“路径”和“落点”的边界定义上。正确语义应该是:从起点到落点之间的所有中间格必须是空的,落点本身可以是空的或者对方的。我一开始把落点也算进了路径检查,导致落点有对方棋子时被误判为阻挡;后来改成不检查落点,又漏掉了“中间隔着两枚棋子但只检查了最近那个”的情况。最后我用的是逐格推进法,走到哪判到哪,碰到东西就处理并终止本方向,这才彻底干净。

排查这类问题的有效手段不是盯着代码看,而是写局面回归测试。我攒了一组大概三十个固定盘面的文本表示,每条配一个“合法走法数量”的期望值。改完代码跑一遍,数量对不上就立刻知道出事了。这套用例后来在加网络层和 AI 的时候又救了我好几次——AI 层的 bug 经常表现为“下出非法棋”,而根本原因往往在走法生成,不在搜索。

# 盘面用 8 行字符串表示,大写红方、小写蓝方、'.' 空位 CASES = [ (""" ........ ........ ........ ..A..... ........ ....b... ........ ........ """, 4), ]

这里用字母代替数字只是为了肉眼可读,实际代码里还是走int8编码。测试用例的维护成本极低,但收益极高,尤其是当你的规则里还有“送达即移出”这种会改变盘面结构的动作时,没有回归用例基本等于裸奔。

3. 图形界面:pygame 在棋盘游戏里真正该扮演的角色

选 GUI 库这件事我纠结了大概两天。tkinter 自带、零依赖、打包方便,但画棋盘和做鼠标命中测试写起来相当啰嗦;pygame 绘制顺手、事件模型清晰、帧循环直观,缺点是打包体积大一点。最后我选了 pygame,理由是棋盘游戏的交互本质就是“坐标命中 + 状态重绘”,这两件事 pygame 都做到了最短路径。

但真正上手之后我发现,pygame 最大的坑不是绘制,而是交互状态机怎么设计。数棋的一次走子需要四个信息:行棋棋子、参照棋子、运算符、方向。如果做成四步点击,玩家每走一步要点四下,体验极差。我前后改了三版交互。

第一版是纯点击四步式,能跑通,但玩起来像在填表。第二版试图做拖拽,从起点拖到终点,结果因为「方向和步数」这两个信息无法从拖拽轨迹里稳定推断(玩家手一抖就偏了一格),中途放弃。第三版用的是现在这套:点一下自己的棋子选中它,此时把所有合法落点一次性高亮出来;如果某个落点存在多种走法(不同参照数字、不同运算),就在旁边弹出一个小面板列出所有可能,让玩家点选具体的运算组合。玩家实际操作是“点棋、点落点、偶尔补一次点选”,绝大多数情况下两步搞定。

这个改动的关键在于:我干脆不再让玩家“显式选择参照棋子”。因为前面已经说过,参照棋子只贡献数字,那不如直接把可用的数字和运算符列成面板,玩家看到的是一行小圆钮,比如+3 -1 ×2 ÷4,点哪个走哪个。这个设计让规则理解成本骤降,测试时找几个没玩过的朋友,几乎没人问“我该选哪个参照棋子”。

3.1 绘制层的几个实战细节

棋盘绘制的核心是一个函数:给定格子坐标(r, c),返回屏幕矩形。所有鼠标事件反过来映射。这两件事必须严格互逆,否则会出现“点得到但画不对”的错位,而且这种错位在高分屏上会被放大。我的做法是把棋盘左上角、格子边长、边框宽度三个量抽成常量,grid_to_rect和point_to_grid都只依赖它们,两边共用。

CELL = 72 MARGIN = 40 ORIGIN = (MARGIN, MARGIN) def grid_to_rect(r, c): x = ORIGIN[0] + c * CELL y = ORIGIN[1] + r * CELL return pygame.Rect(x, y, CELL, CELL) def point_to_grid(pos): x, y = pos[0] - ORIGIN[0], pos[1] - ORIGIN[1] if x < 0 or y < 0: return None c, r = x // CELL, y // CELL if r > 7 or c > 7: return None return r, c

字体的处理是另一个躲不开的坑。pygame.font.SysFont("simhei", 28)在部分系统上会静默回退到默认字体,中文直接变成方块。更麻烦的是这个失败是无声的,程序不报错,只是画面难看。我的做法是启动时按优先级尝试一组候选字体名(覆盖几个常见中文字体),全部失败再退回到pygame.font.Font(None, size),同时在控制台打一行警告,提醒用户手动指定字体文件路径。

还有个小优化值得一提:棋子上的数字只有 0 到 9 十种,颜色只有两种,所以总共二十种组合。我在初始化阶段就把这二十张渲染好的Surface缓存起来,绘制时直接blit,而不是每帧调render。在主循环里,这一步能省掉每帧几十次文字光栅化,帧率从 45 稳到 60。棋盘游戏的画面复杂度不高,性能问题往往就藏在这些“每帧都做一次但其实可以只做一次”的地方。

3.2 主循环与状态机的耦合方式

我见过的很多小游戏项目,主循环里会写一个巨大的if-elif链来处理点击,随着功能增加不断膨胀,最后没人敢改。我的写法是把“当前处于什么交互阶段”抽成一个显式的状态变量,点击处理函数只做state -> handler的分发。

状态大致是:IDLE(等选己方棋子)、PICK_MOVE(已选中棋子,等选落点或选运算组合)、ANIMATING(走子动画播放中,屏蔽输入)、GAME_OVER。动画期间屏蔽输入这一点很重要,否则玩家手快连点两下会导致状态机错乱,出现“AI 还没走完玩家又走了一步”的并发问题。我最初的版本就是因为没做输入锁,测试时出现了双方交替下两步的诡异局面,排查了很久才发现是动画没跑完就接受了新点击。

动画本身我用的是最简单的线性插值:记录起点和终点,用elapsed / duration算进度,blit到插值位置。一百多毫秒的移动加上一点缓动曲线,视觉上就很舒服了,没必要上专门的动画库。棋盘游戏对帧率的要求其实很低,我把clock.tick设成 30,CPU 占用从 15% 掉到 3%,风扇终于不叫了。

4. 网络对战:状态同步是个陷阱,指令同步才是正解

做网络版之前,我在两种设计之间犹豫了很久。一种是状态同步:服务器定期广播完整盘面,客户端照着画。另一种是指令同步:服务器只转发走法,客户端各自用同一套规则算出盘面。前者实现简单但带宽浪费严重,后者省带宽但要求规则绝对一致——只要有一丁点浮点或者顺序差异,两端就会慢慢飘开。

数棋这类回合制、完全信息、无随机性的棋类,答案其实很明确:指令同步。因为游戏是确定性的,同样的初始盘面加上同样的走法序列,必然得到同样的终局盘面。这条性质省掉了绝大部分同步逻辑。客户端不需要相信服务器发来的盘面,只需要相信走法序列,自己算就行。

协议我用的是最土的方案:TCP 之上跑 JSON,一行一条消息,用\n分隔。选它不是因为优雅,是因为可调试性。出问题的时候我可以直接telnet上去手打一条 JSON,立刻知道是服务器的问题还是客户端的问题。二进制协议省的那点带宽在这个场景里毫无意义,一局棋撑死几百条消息。

class LineCodec: def __init__(self, sock): self.sock = sock self.buf = b"" def recv(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) return json.loads(line.decode("utf-8")) def send(self, obj): data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n" self.sock.sendall(data)

消息类型定得很克制:hello(握手、报昵称)、start(分配阵营、发初始盘面)、move(走法)、sync(请求全量棋谱,用于重连)、resign、ping。走法的序列化我用了[from_idx, to_idx, op_code]三元组,op_code只是为了回放时能显示“用了什么运算”,实际重算盘面只需要前两个。

这里必须提一个所有 TCP 新手都会踩的坑:粘包。sock.recv(4096)一次读回来的可能是半条消息,也可能是三条半。上面这个LineCodec的buf就是专门处理这件事的,一直攒到出现换行符才切分。我第一版没做缓冲,本地测试一切正常(因为本地回环几乎总是整包到达),一换成两台机器联机就炸,症状是 JSON 解析随机失败。

4.1 线程模型与 UI 卡顿

网络客户端的经典难题是:recv是阻塞的,但 GUI 主循环不能被阻塞。两种解法,一种是把recv设成非阻塞配合select,另一种是开一个接收线程往queue.Queue里塞消息,主线程每帧非阻塞地取。我选后者,因为 pygame 的主循环本身就是个固定节奏的轮询,配合队列非常自然。

def recv_loop(codec, q, stop_event): while not stop_event.is_set(): try: msg = codec.recv() except OSError: break if msg is None: break q.put(msg) q.put({"type": "disconnected"})

主循环里q.get_nowait(),Empty异常就跳过。这样网络再慢也不影响画面刷新,掉线也只是收到一条disconnected消息后切到提示界面,不会整个程序卡死。

还有个容易被忽视的细节:TCP_NODELAY。默认情况下 Nagle 算法会攒小包再发,走一条棋的消息可能只有几十字节,被攒上几十毫秒才出去,主观感受就是“点了半天对方才知道”。走子这种低频小包场景,关掉 Nagle 是标准操作。

sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

重连机制我做得比较土:服务端保存整局的走法列表,客户端重连时发sync,服务端把完整列表推回去,客户端从头重放到当前局面。因为重放只涉及走法应用,一千步也就几毫秒,不需要做任何增量状态管理。这种“用重放换简单”的思路在很多地方都成立,值得记住。

5. AI 引擎:从随机走子爬到 alpha-beta 的四个阶段

AI 这块是我花时间最多、也是收益最明显的部分。我按四个阶段迭代,每个阶段都保留成了一个可切换的难度档位,这样既方便调试,也让新手玩家有个渐进的过程。

第一阶段是随机走子。看上去毫无技术含量,但它的价值在于验证走法生成的正确性——如果随机 AI 走出了非法棋,那一定是走法生成的问题,跟搜索无关。我强烈建议所有人都从这个版本开始,先把规则跑通再谈别的。

第二阶段是贪心 + 评估函数。评估函数的设计是这个游戏的核心难点。很多棋类的评估靠“子力价值”,但数棋没有吃子得分这回事(吃掉对方棋子只是减少对方的数字选择,不直接得分),所以核心指标是推进度。我定义的推进度是:每枚棋子距离对方底线的行距,越近越好。

GOAL_ROW = {1: 7, -1: 0} # 红方目标是第 7 行,蓝方目标是第 0 行 def evaluate(board, side): score = 0 for idx, p in enumerate(board): if not p: continue r = idx >> 3 s = side_of(p) d = abs(r - GOAL_ROW[s]) val = (7 - d) * 12 + 20 # 越靠近目标分越高 score += val if s == side else -val return score

这个评估函数非常粗糙,但它已经能打败随机 AI 了,胜率大概八成。有意思的是,加上一个“被威胁惩罚”项之后胜率还能再涨一截:如果某枚己方棋子的落点能被对方一步吃掉,就扣分。这一项本质上是让 AI 学会了“别把棋子送到人家嘴边”。

第三阶段是minimax + alpha-beta 剪枝。这一步的改动量不大,但需要几个配套优化才能跑得动。分支因子在这个游戏里大概两百到四百,比国际象棋高不少,因为一次行棋可以选任意参照数字、任意运算、任意方向。裸的 alpha-beta 深度 3 就要跑好几秒,完全不可用。

我做的第一个优化是走法排序。alpha-beta 的效率极度依赖先搜到好走法,我把走法按“落点离目标行的距离”升序排,先搜推进最多的。这一个改动让深度 4 的耗时从 6 秒降到 1.2 秒,收益巨大。

def move_order_key(mv, side): to_r = mv[1] >> 3 return abs(to_r - GOAL_ROW[side]) # 越小越优先

第二个优化是置换表。用 Zobrist 哈希给每个盘面算一个 64 位指纹,缓存“这个盘面在深度 d 下的搜索结果”。数棋的走法可逆性不强,但不同走法序列到达同一盘面的情况还是不少,尤其是中局阶段。加了置换表之后,深度 5 的耗时大概又降了三成。

第四阶段是迭代加深 + 时间控制。这个改动看起来是锦上添花,实际上对体验影响最大。固定深度搜索的问题是耗时波动剧烈,某些局面深度 4 只要 0.3 秒,某些要 3 秒,玩家会觉得 AI 时快时慢很出戏。改成“给 800 毫秒预算,从深度 1 开始逐层加深,超时就停,用上一层的结果”之后,AI 的反应时间稳定在 0.8 秒左右,而且顺带拿到了“上一层的最佳走法”用来排序,搜索效率还更高了。

5.1 把 AI 放进 GUI:线程、中断与假死

搜索放在主线程里跑是绝对不能接受的,超过两百毫秒的卡顿玩家就能明显感觉到,一秒以上会以为程序挂了。我的方案是搜索跑在独立线程,主线程继续渲染一个“思考中”的提示,同时通过一个共享的threading.Event做中断。

class Searcher: def __init__(self): self.abort = threading.Event() def search(self, board, side, time_budget): self.abort.clear() deadline = time.perf_counter() + time_budget best = None for depth in range(1, 8): mv = self._root_search(board, side, depth, deadline) if mv is None: break best = mv if time.perf_counter() > deadline: break return best

_root_search内部在节点计数每满 1024 次的时候检查一下time.perf_counter()是否超过 deadline,超了就抛一个自定义异常往上冒,外层捕获后返回当前最好的结果。这个“定期检查 + 抛异常中断”的模式比设标志位再层层返回要干净得多,代码里不会到处是if aborted: return。

这里有个 Python 特有的坑要提醒:GIL。搜索线程是纯 CPU 密集的,它会和主线程抢 GIL,理论上主线程的渲染会被拖慢。实际测试下来,因为主线程大部分时间阻塞在clock.tick和事件轮询上,影响不明显,帧率从 60 掉到 55 左右,可以接受。如果真要做到毫无影响,就得把搜索扔进multiprocessing,但那样每次搜索都要序列化盘面、传回来,通信成本反而可能超过收益。对这个规模的项目,线程方案是性价比最高的。

另一个实操经验:AI 开局别用搜索。开局阶段所有走法评估值几乎一样,搜索出来的结果和随机差不多,白白浪费玩家一秒钟。我的做法是开局前两步直接从预置的走法池里随机挑一个,既快又能保证每局开局不重样。这个“开局库”只有二十来条,但效果比想象中好。

6. 跑起来才会遇到的六个问题

代码写到能跑和写到能玩,中间隔着的就是这一节。下面这些全是我在实际调试里撞上的,按踩坑的时间顺序排。

第一个,除零和负数步数。这个前面提过,但值得再说一次它的隐蔽性:abs(a - b)在 a 等于 b 时结果是 0,而 0 步在逻辑上是“原地不动”,会被误判成一种合法走法。我的做法是把 0 从候选集合里彻底移除,而不是在移动阶段判断。

第二个,送达后的棋子去哪了。我最初的设计是棋子到达底线后留在原地,结果发现它会一直堵着后面的棋子,而且它自己还能被对方吃掉,规则变得很怪。后来改成到达即移出棋盘并计分,逻辑立刻清晰了。但这个改动带来一个副作用:盘面上的数字变少了,后期可用的运算组合变少,某些局面会出现“无双方可走”的僵局。我的处理是加一条规则:一方无合法走法时判负。这条规则同时也解决了循环走法导致的死局。

第三个,pygame 中文字体。前面提过,补充一个细节:pygame.font.match_font返回的可能是None,直接传给Font会抛异常,必须判空。我在这里吃过一次TypeError,报错信息完全看不出是字体问题。

第四个,高分屏的模糊。Windows 上如果程序没声明 DPI 感知,系统会把窗口按 150% 缩放,pygame 渲染出来的画面就是糊的。解决办法是在pygame.init()之前调一下系统的 DPI 设置,或者直接在创建窗口时用pygame.SCALED标志让 pygame 自己处理。我用的后者,一行代码解决。

第五个,bytearray的切片陷阱。board[:]得到的是bytearray,但如果写成了board[0:64],行为一样,可读性差。更要命的是如果误写成board = board[:]之后再修改,很容易忘记新变量和旧变量已经脱钩。我后来给所有涉及盘面的函数统一加了类型标注,至少能在 IDE 里看出端倪。

第六个,打包。pyinstaller默认不会把字体文件、图片资源打进去,运行时才会报“文件不存在”。我的做法是在代码里用sys._MEIPASS判断是否处于打包环境,资源路径统一走一个resource_path()函数。这个函数是每个用 pyinstaller 的人都要写一遍的样板代码,抄下来备着就行。

def resource_path(rel): base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, rel)

顺便说个测试相关的心得。这个项目里我写了三类测试:规则层的盘面回归用例、网络层的消息编解码单测、AI 层的“搜索在限定时间内必定返回合法走法”的断言。第二类最省事,第三类最有价值——AI 出非法棋这种 bug 极难靠人眼发现,但一个断言就能抓住。规则层的用例虽然写起来枯燥,但它是前面所有东西的地基,地基一歪,上面全塌。

整个项目从零到能玩,大概写了三千多行 Python。真正花时间的不是哪一块特别难,而是三块之间的耦合边界需要反复调整:一开始我把规则判定散落在 GUI 和 AI 里各写了一份,结果两边对“能不能吃子”的理解出现了分歧,联机时直接暴露成“客户端认为合法、服务端认为非法”。后来强制所有走法必须经过同一个gen_moves,分歧才彻底消失。如果你也打算做类似的东西,我的建议是先把规则内核单独写成一个不依赖 pygame、不依赖 socket 的纯模块,把它测透,再往上盖房子。这个顺序一旦反了,后面每改一处规则都要在三个地方同步,迟早会漏。

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

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

立即咨询