Soma Zero Tutorials
🔍 搜索功能尚未开启,敬请期待。

1.9 Capstone:井字棋对弈 App 与基准

第一章的算法已经集齐:Minimax、α-β、评估/排序、迭代加深、MCTS、迷你 AlphaZero。这一节是收口——把它们装进一个能用鼠标玩的 pygame 程序,让它们互相对战排个名次,最后把贯穿全课的“开放信息接口”正式定稿。这套接口契约,第二、三章会原样复用,所以这一节虽然是井字棋的终点,却也是后两章的起点。

一、pygame 人机对弈:让它真的能玩起来

前面我们都在命令行里下棋,现在给它一张脸。我们用 pygamepip install pygame)——它是 Python 里最轻便的 2D 图形库,画个棋盘、收个鼠标点击,几十行就够。

这里有个设计上的关键,请你留心:图形界面只通过“开放信息接口”和引擎打交道——它问引擎 legal_moves()、调 make_move()、查 is_terminal()、让 AI 走时调 best_move()。界面完全不关心背后那颗大脑是 Minimax 还是 AlphaZero。正因如此,换引擎就像换电池一样简单,一行都不用改界面:

import pygame

def play_gui(engine, cell=120):
    """人点鼠标落子,AI 通过 best_move() 应子。engine 可以是任意一种引擎。"""
    pygame.init()
    screen = pygame.display.set_mode((cell * 3, cell * 3))
    running = True
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.MOUSEBUTTONDOWN and not engine.is_terminal():
                x, y = event.pos
                pos = (y // cell) * 3 + (x // cell)        # 像素坐标 → 格子编号 0~8
                if pos in engine.legal_moves():
                    engine.make_move(pos)                  # ① 人走一步
                    if not engine.is_terminal():
                        engine.make_move(engine.best_move())  # ② AI 立刻应一步
        draw_board(screen, engine, cell)                  # 把棋盘和棋子画出来
        pygame.display.flip()
    pygame.quit()

那个 draw_board 负责把网格线和 X/O 画到屏幕上,细节不难、这里不展开。重点是体会上面这层“界面 ↔ 接口 ↔ 引擎”的解耦:它就是我们从 1.2 起一路坚持“统一接口”换来的回报。到第二章,这套界面骨架几乎原样拿去画五子棋的 15×15 棋盘。

二、多引擎基准对战:到底谁更强

有了统一接口,让两个引擎自动对打很多盘也就水到渠成。我们写一个“擂台”,喂进两个引擎,让它们轮流先手下上一百盘,统计战绩,顺便记录每步的耗时和搜索节点数:

def play_match(engine_a, engine_b, games=100):
    """两个引擎对战 games 盘(轮流先手),返回 A 的 胜/和/负。"""
    score = {"win": 0, "draw": 0, "loss": 0}
    for g in range(games):
        # ……让 a、b 交替执先,走到终局,按 winner() 记一笔……
        pass
    return score

把第一章四个引擎两两摆上擂台,结果大致会是下面这样(数字示意,重点看趋势):

引擎对“随机乱走”胜率引擎间互相对战平均每步耗时是否需要训练
随机走子逢人就输~0
Minimax(搜到底)≈100% 不败彼此全和毫秒级
α-β(搜到底)≈100% 不败彼此全和更快(剪枝)
迷你 AlphaZero≈100% 不败彼此全和看迭代次数

你会注意到一个“无聊”的结果:几个像样的引擎互相之间全是和棋。这一点都不奇怪——井字棋本就是必和的(1.1 讲过),大家都下到完美,自然谁也赢不了谁;它们碾压的只是“随机乱走”。所以在井字棋上,这张擂台的真正价值不在分出胜负,而在这套“自动对战 + 统计”的工具本身。到第二章五子棋,棋有了胜负悬念,我们就用同一套擂台给各引擎评 Elo 等级分、排一个真正的天梯。

三、开放信息接口定稿:给后两章立下模板

最后做一件影响深远的事:把我们一路零敲碎打的接口,固化成一份正式契约。我们用一个抽象基类把它写死,规定“凡是本课的下棋引擎,都必须提供这几样”:

from abc import ABC, abstractmethod

class GameEngine(ABC):
    """三个棋种共同遵守的'开放信息接口'契约。"""

    @abstractmethod
    def legal_moves(self): ...      # 此刻能走哪些地方
    @abstractmethod
    def make_move(self, move): ...  # 落一子并轮换
    @abstractmethod
    def is_terminal(self): ...      # 是否终局
    @abstractmethod
    def winner(self): ...           # 谁赢了(0 表示尚无/和棋)
    @abstractmethod
    def evaluate(self): ...         # 给当前局面估个分
    @abstractmethod
    def best_move(self): ...        # 引擎推荐的最佳一步
    @abstractmethod
    def info(self): ...             # 把以上信息打包成结构化数据

其中 info() 是这套接口对外最重要的“窗口”,它把引擎肚子里的判断一次性开放出来。我们定稿它的字段,并预留两个到五子棋才会真正填上的字段(威胁分析、必胜线),让契约从一开始就为后面留好位置:

info() 字段含义井字棋五子棋 / 国象
board / to_move当前局面、轮到谁
legal_moves / is_terminal / winner能走哪、是否终局、谁赢
evaluation局面评分(谁占优、多少)
best_move / pv最佳一步 / 主变着法
threats威胁分析(活三、冲四…)—(无此概念)✅ 五子棋填上
forced_win是否存在 N 步必杀线✅ 五子棋(VCF/VCT)填上

为什么大费周章统一这套契约?因为它有三个直接受益者:上面的 pygame 界面、刚才的对战擂台,以及——最终的——ANIMA 认知框架。将来 ANIMA 想把下棋当成一项“技能”来调用时,它面对的就是这套统一的问答口:问一句 info(),就能知道“当前谁占优、最佳着法是什么、有没有几步必杀”。三个棋种、一套接口,这正是“开放信息接口”这条暗线的全部用意。

四、本章小结,与通往第二章

恭喜你走完第一章!我们做了一件很值的事:在井字棋这个小到能看清一切的沙盘里,把下棋 AI 的每一块基石都亲手搭了一遍。回头看这条由“局限驱动”的升级链——

  • 贪心会输 → Minimax 向前看;树太大 → α-β 剪枝;搜不到底 → 评估函数;剪枝靠顺序 → 走子排序
  • 不会写评估 → MCTS 用随机模拟;模拟太笨 → AlphaZero 用神经网络补上;
  • 最后用 pygame 让它能玩、用擂台让它们对战、用统一接口把它们规范起来。

这些算法在井字棋上多半是“杀鸡用牛刀”,威力还没真正显出来。而下一章,棋盘从 9 个格子暴涨到 225 个交叉点,一切都将不同——朴素搜索瞬间失效,评估函数和威胁搜索成为生死攸关的主力,AlphaZero 也要在你的 5070 Ti 上真刀真枪训练好几天。我们去 2.1 从 3×3 到 15×15,看看“变大”到底带来了什么。