1.2 把井字棋写成代码
上一节我们说,一个下棋程序由三个零件组成:规则引擎、决策大脑、信息接口。这一节就动手造第一个零件——规则引擎,并顺手搭起信息接口的雏形。井字棋小,所以这两件事加起来也就几十行 Python,正好让我们把“一个棋种该怎么写成代码”这件事看个通透。决策大脑(怎么挑最好的一步)留到下一节。
动手前先约定环境:我们用 Python,棋盘借助 NumPy(pip install numpy)。井字棋其实用普通列表也够,但从一开始就用 NumPy,是为了让你习惯它——到了五子棋、国际象棋,向量化的棋盘运算会帮我们省下大量时间。
一、把棋盘装进代码:状态怎么表示
1.1 用一个长度为 9 的数组表示局面
井字棋是 3×3 的九宫格。最直接的想法是用一个 3×3 的二维数组,但其实拉平成一个长度为 9 的一维数组更省事——格子从左上到右下编号 0 到 8,落子时报一个数字就行,不必每次写一对 (行, 列)。
每个格子只有三种状态:空、先手的子、后手的子。这里有个看似随意、实则讲究的选择:我们用 0 表示空、+1 表示先手、−1 表示后手。为什么偏偏用 +1 和 −1,而不是 1 和 2?因为它带来两个后面会反复占便宜的好处:其一,轮换走子只要把符号一翻(乘以 −1)即可;其二,它天然契合上一节说的“零和”——先手的“好”记为正、后手的“好”记为负,到第三节做决策大脑时,正负号会让代码优雅得出乎你意料。
import numpy as np
class TicTacToe:
"""井字棋的规则引擎:只负责'懂规则',不负责'会思考'。"""
def __init__(self):
self.board = np.zeros(9, dtype=int) # 9 个格子,0=空,+1=先手,-1=后手
self.current_player = 1 # 约定 +1 先走
def render(self):
"""把棋盘画成人看得懂的样子,方便调试。"""
symbols = {0: '.', 1: 'X', -1: 'O'}
for r in range(3):
print(' '.join(symbols[self.board[3 * r + c]] for c in range(3)))
注意我们顺手写了一个 render,把数字翻译成 X / O / . 打印出来。别小看它——写棋类程序时,第一件要做的就是让自己能一眼看见棋盘,否则后面调试会很痛苦。
1.2 落子与轮替
有了棋盘,落子就是“在某个空格填上当前玩家的标记,然后把行棋权交给对手”。那个“交给对手”,正是前面埋的伏笔——一行 self.current_player *= -1 就搞定,因为 +1 和 −1 互为相反数。
def make_move(self, pos):
"""在 pos(0~8)落一子,然后轮到对手。"""
assert self.board[pos] == 0, "这个格子已经有子了"
self.board[pos] = self.current_player
self.current_player *= -1 # 轮换:+1 ↔ -1,一个乘法就够
那句 assert 是给我们自己的安全带:万一哪天大脑算错、想往已经有子的格子里落,程序会立刻喊停,而不是默默地下出一步非法棋。规则引擎的本分,就是不让任何非法的事情发生。
二、两个核心问题:能走哪儿、谁赢了
规则引擎要回答的核心问题就两个:此刻我能往哪些地方走,以及这局棋是不是已经分出胜负。把这两个答好,决策大脑才有得算。
2.1 合法走子:哪些格子还空着
井字棋的规则简单到极点:任何空格都能走。所以“合法走子列表”就是所有还是 0 的格子编号。
def legal_moves(self):
"""返回所有还空着的格子编号,就是能走的地方。"""
return [i for i in range(9) if self.board[i] == 0]
记住这个方法名 legal_moves,它会原封不动地出现在五子棋和国际象棋里——只是那边的“合法”会复杂得多(国际象棋还得排除“走完会被将军”的着法)。但“问引擎此刻能走哪儿”这个动作,是所有棋种共通的。
2.2 判定胜负:一个漂亮的求和小技巧
井字棋的赢法一共八种:三横、三竖、两条对角线。我们把这八条线的格子编号列成一张表:
LINES = [
(0, 1, 2), (3, 4, 5), (6, 7, 8), # 三横
(0, 3, 6), (1, 4, 7), (2, 5, 8), # 三竖
(0, 4, 8), (2, 4, 6), # 两条对角线
]
接下来怎么判断有没有人三连?这里就轮到 +1 / −1 编码大显身手了。把一条线上三个格子的值加起来:如果先手占满,三个都是 +1,和就是 +3;如果后手占满,和就是 −3。没占满或被混着占,和绝不会到 ±3。于是一次求和就判完一条线,连“这三个是不是同一个人的”都不用单独比:
def winner(self):
"""返回 +1 / -1 表示对应玩家获胜;0 表示还没有人连成线。"""
for a, b, c in LINES:
s = self.board[a] + self.board[b] + self.board[c]
if s == 3:
return 1 # 先手三连
if s == -3:
return -1 # 后手三连
return 0 # 暂时无人获胜
这种“用编码本身的算术性质来省判断”的小聪明,在棋类程序里随处可见。你现在体会到的是它最朴素的样子;到五子棋数“活四、冲四”时,类似的思路会被放大成一套完整的棋型识别。
2.3 终局与和棋
一局棋结束,无非两种情形:有人赢了,或者棋盘填满了还没人赢(和棋)。把这两种情形合起来,就是“是否终局”:
def is_terminal(self):
"""棋局是否结束:有人获胜,或者无处可走(和棋)。"""
return self.winner() != 0 or len(self.legal_moves()) == 0
有了 winner 和 is_terminal,规则引擎就齐活了:它能告诉你能走哪儿、能落子、能判断谁赢、能判断是否该收场。这就是一个完整的“懂规则”的零件。
三、把它包装成“开放信息接口”
3.1 为什么要刻意定一套统一的“问法”
到这里我们其实可以直接进入下一节去写大脑了。但请先停一下,做一件对整门课影响深远的事:把规则引擎对外的“问法”固定下来,定成一套统一接口。
为什么值得这么做?因为我们将来要写三个棋种的引擎,还要让它们都能被上层的 Anima 认知框架当作“工具”来调用。如果井字棋问“能走哪儿”叫 legal_moves、五子棋却叫 get_moves、国际象棋又叫别的,那上层每对接一个棋种就得重写一遍。反过来,只要三个引擎都遵守同一份“问答契约”,上层代码就能一视同仁地对待它们——这正是“开放信息接口”的用意:把引擎肚子里的信息,用一套固定的、谁都能问的方式开放出来。
这一节我们先立下契约的核心几项,后面随棋种变复杂再逐步扩充:
| 接口方法 | 回答的问题 | 本节是否实现 |
|---|---|---|
legal_moves() | 此刻能走哪些地方? | ✅ 已实现 |
is_terminal() | 这局棋结束了吗? | ✅ 已实现 |
winner() | 结束的话,谁赢了? | ✅ 已实现 |
info() | 把当前局面打包成结构化数据 | ✅ 本节补上 |
best_move() | 你觉得最好的一步是哪? | ⏳ 留给“决策大脑”(1.3 起) |
3.2 给引擎加一个 info():把局面讲给外面听
前面的方法都是“一问一答”,我们再加一个 info(),一次性把当前局面打包成一份结构化的数据(一个字典)。这正是开放信息接口最典型的形态——上层不必懂引擎内部怎么存棋盘,问一句 info() 就拿到它需要知道的一切:
def info(self):
"""把当前局面打包成结构化信息,供上层(如 ANIMA)查询。"""
return {
"board": self.board.reshape(3, 3).tolist(), # 还原成 3x3,方便阅读
"to_move": int(self.current_player), # 轮到谁走
"legal_moves": self.legal_moves(), # 能走哪儿
"is_terminal": self.is_terminal(), # 是否终局
"winner": int(self.winner()), # 谁赢了(0=尚无)
}
注意 best_move() 我们故意还没写。因为“挑最好的一步”是决策大脑的活,而大脑正是下一节的主题。这里先把“接口上该有这个口”记下来——等我们有了 Minimax,把它接上即可,接口形态完全不用改。
3.3 跑一局随机棋,验证它真的能用
代码写完不能就走,得让它真的跑起来看看。我们还没有大脑,那就先用“随机乱走”来代替——双方每步都从合法走子里随便挑一个,一直走到终局。这既能验证规则引擎没 bug,也能让你直观看到接口是怎么被使用的:
import random
game = TicTacToe()
while not game.is_terminal(): # 没结束就继续
move = random.choice(game.legal_moves()) # 从合法走子里随便挑一个
game.make_move(move)
game.render()
result = game.winner()
print("赢家:", {1: "X 先手", -1: "O 后手", 0: "和棋"}[result])
多跑几次你会发现:结果有时 X 赢、有时 O 赢、有时和棋,全凭运气——这很正常,因为双方都在瞎走。这恰恰点出了我们接下来要解决的问题:怎么让它别再瞎走,而是每一步都挑“最有利于自己”的那一步?那就需要给它装上真正的大脑了。
四、小结与下一节
这一节我们用几十行代码造出了井字棋的规则引擎,并立下了开放信息接口的契约。回看一眼核心:
- 状态表示:长度 9 的数组,0 / +1 / −1 三态;正负号编码让“轮换”和后面的“评估”都变简单。
- 三个规则方法:
legal_moves(能走哪儿)、winner(谁赢了,用求和到 ±3 的小技巧)、is_terminal(是否收场)。 - 接口契约:
info()把局面结构化开放出来;best_move()先留个口,等大脑来填——这套契约将原样复用到五子棋和国际象棋。
现在引擎会“懂规则”了,但还只会瞎走。下一节 1.3 Minimax:把下棋变成搜索,我们就给它装上第一颗大脑:让它学会在脑子里把未来推演一遍,从而每一步都挑通向最好结局的那一着。