☰
pygame五子棋AI:α-β剪枝与评估函数实战
2026/10/26 11:05:43 网站建设 项目流程

简介:这是一份面向Python初学者与博弈算法学习者的五子棋人机对战项目源码,围绕pygame图形界面搭建棋盘与落子交互,并用极大极小值搜索配合α-β剪枝完成电脑走步决策。项目重点落在棋型评估函数与候选落点生成上,同时处理先后手切换、胜负判定、重开与退出等完整流程,适合用来理解搜索树剪枝如何提升对弈效率,也可作为课程设计或算法练习的参考实现。压缩包共3个文件,以2个Python源码和1个说明文本为主,整体约15KB,体量轻便,便于直接阅读核心逻辑与在本地运行调试。已有4707人学习下载,说明其在五子棋AI入门实践中具有一定参考价值。读者可从中获得可运行的pygame对战框架、按钮控制先后手的交互组织方式,以及评估函数与剪枝策略的落地写法,并据此继续调整搜索深度、评分权重或界面细节。

1. 五子棋人机对战:一个 pygame 窗口加 α-β 剪枝,本地就能跟 AI 掰手腕

周六晚上想下盘五子棋,网页版关掉就没了,手机小程序里的 AI 又常常只盯着眼前一步堵。后来翻到一个用 pygame 写的本地项目,15×15 棋盘、鼠标落子、人机对战,AI 内核是 α-β 剪枝,跑起来就一个窗口,回手基本在一秒内。它适合两类人:想找个能离线跑的棋类小工具练手的,以及想搞明白博弈树搜索到底怎么落到 Python 代码里的开发者。整个项目不依赖在线服务,棋盘状态、评估函数、搜索逻辑全在一个进程里,改棋盘尺寸或者调 AI 强度都不用顾忌后端接口。

2. 工程骨架:从目录结构到 pygame 主循环的第一帧

这个项目的体积不大,但模块边界划得比较清楚:棋盘只管数据和规则,AI 只管搜索和打分,渲染和事件循环放在入口文件里。这样拆的好处是,后面想换 UI 或者把 AI 换成别的搜索算法,不用动棋盘逻辑。我先把目录和启动方式讲清楚,再拆主循环,因为很多人第一次跑这类 pygame 项目,卡住的地方不是算法,而是窗口弹不出来或者事件写错。

2.1 目录结构与模块职责

源码包解压后大概是下面这几个文件,不同版本可能多一个资源目录,但核心职责不会变:

文件/目录职责
main.py入口,pygame 初始化、窗口创建、事件分发、主循环
board.py棋盘二维数组、落子合法性、胜负判定、坐标转换
ai.py评估函数、候选点生成、α-β 剪枝搜索
config.py棋盘尺寸、边距、格子像素、搜索深度等常量
assets/字体、棋子贴图、音效,缺失时一般会回退到纯色绘制

这个拆法有两个实际好处。第一,board.py不 import pygame,棋盘逻辑可以单独跑单元测试,不用开窗口;第二,ai.py只接收一个二维数组和当前执子颜色,搜索内核和渲染完全解耦。我见过不少人把所有东西塞进一个main.py,写到后面想加一个“悔棋”功能,结果发现棋盘状态、绘制状态、AI 缓存全缠在一起,改一处崩三处。

数据流是单向的:鼠标事件进来,先问board.place()能不能落,落完调board.check_winner(),没结束就切到 AI 回合,AI 返回坐标后再走一遍place()和check_winner(),最后统一进board.draw()重绘。这条链路不长,但每一步的返回值要约定好,否则很容易出现“AI 落子后棋盘没刷新”或者“人类还能在 AI 回合点棋子”的问题。

2.2 依赖安装与启动命令

依赖就 pygame 一个,numpy 是可选的,装不装都能跑,区别在评估函数里用不用数组加速。Python 版本建议 3.8 以上,pygame 用当前稳定版即可:

# 建议先建虚拟环境,避免和系统里的 pygame 版本打架 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install pygame # 可选:评估函数里用 numpy 做批量打分时再装 pip install numpy python main.py

启动后应该看到一个固定尺寸的窗口,棋盘线画在留白区域内,鼠标移上去能落黑子,随后白子由 AI 回手。如果窗口一闪就退,先看终端里的报错,最常见的是pygame.error: No available video device,这说明当前环境没有图形显示,远程服务器或者纯命令行容器里跑不了,需要在本地桌面环境执行。另一个常见问题是ModuleNotFoundError: No module named 'pygame',多半是虚拟环境没激活,pip 装到了系统 Python 里。

我一般会把config.py里的窗口宽高、棋盘边距、格子间距先对一遍。这三个值决定了后面坐标换算的正确性,改错一个,棋子就会整体偏移。常见做法是用「边距 + 格子间距 × 14」推窗口边长,15 路棋盘有 14 个间隔,边距留出棋子半径的余量,这些常量集中在一处,比散落在各个文件里好排查。

2.3 主循环与事件分发

入口文件的核心就是下面这段,事件处理、AI 回合、重绘三件事按顺序走:

# main.py import pygame import sys from board import Board from ai import AlphaBetaAI from config import WIDTH, HEIGHT, FPS def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("五子棋 · 人机对战") clock = pygame.time.Clock() board = Board(size=15) ai = AlphaBetaAI(depth=4, candidate_limit=12) running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and not board.game_over: # 只有人类回合才接受鼠标落子 if board.current_player == Board.HUMAN: x, y = board.pixel_to_grid(event.pos) if board.place(x, y): board.check_winner(x, y) board.current_player = Board.AI # AI 回合同步搜索,搜到就走 if board.current_player == Board.AI and not board.game_over: move = ai.search(board.grid, Board.AI, Board.HUMAN) if move: board.place(*move) board.check_winner(*move) board.current_player = Board.HUMAN board.draw(screen) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit() if __name__ == "__main__": main()

这段代码里几个参数值得说清楚。depth=4是搜索层数,层数越高 AI 看得越远,但耗时是指数增长的;candidate_limit=12是每一层最多展开多少个候选点,用来压分支因子;FPS控制主循环帧率,一般 30 或 60,跟 AI 搜索速度没关系,只影响窗口响应。event.pos是鼠标的像素坐标,交给board.pixel_to_grid()转成棋盘下标,转换函数必须和绘制函数用同一套边距和格子间距,否则就会出现落子偏格。

这里有个设计上的取舍:AI 搜索是同步执行的,搜索期间主循环被卡住,窗口不响应拖动和关闭。深度 4、候选 12 的情况下一般几百毫秒能返回,用户感知不明显;一旦把深度调到 6 以上,窗口就会假死几秒。后面排查章节会专门讲这个现象,常见的改法是把搜索放到子线程,或者用迭代加深加时间上限,搜到时限就返回当前最好的一手。

重绘部分放在循环末尾,每帧全量重画棋盘线、棋子和状态文字。棋盘只有 15×15,全量重绘的开销可以忽略,不需要做脏矩形优化。状态文字一般显示当前执子方和胜负结果,board.game_over为真时停止接受鼠标事件,这些判断都集中在主循环里,逻辑比较直白。

3. 棋盘与胜负判定:二维数组、坐标映射和四方向连珠扫描

棋盘模块是整个项目的地基,AI 搜索时频繁读写这个二维数组,人类落子也走同一套接口。这一章把数据结构、坐标换算、胜负判定和重开一局的流程拆开讲,都是能直接抄的代码。别看这些东西简单,我见过最多的翻车点就在坐标顺序和边界判断上,board[x][y]和board[y][x]一混,棋子画出来是斜的,胜负也判不准。

3.1 棋盘数据结构与坐标约定

棋盘用二维列表表示,grid[y][x],第一维是行,第二维是列,这样和像素坐标的 y 轴方向一致,画图的时候不用来回颠倒。空格用 0,人类用 1,AI 用 2,三个常量挂在类上,避免代码里到处写魔法数字:

# board.py class Board: EMPTY, HUMAN, AI = 0, 1, 2 def __init__(self, size=15): self.size = size self.grid = [[self.EMPTY] * size for _ in range(size)] self.current_player = self.HUMAN self.game_over = False self.history = [] # 落子历史,悔棋和复盘用 self.last_move = None # 最后一手,绘制高亮用 def in_range(self, x, y): return 0 <= x < self.size and 0 <= y < self.size def pixel_to_grid(self, pos): px, py = pos # round 而不是 int,避免点在交叉点附近时向下取整偏一格 x = round((px - MARGIN) / GRID_SIZE) y = round((py - MARGIN) / GRID_SIZE) return x, y def grid_to_pixel(self, x, y): # 绘制和鼠标换算共用同一套公式 return MARGIN + x * GRID_SIZE, MARGIN + y * GRID_SIZE

pixel_to_grid()里用round()是个细节。棋盘线画在交叉点上,鼠标点下去的像素很难正好落在交点中心,用int()向下取整会让左上方向的点击偏移半格,用round()四舍五入到最近的交点更符合直觉。MARGIN是棋盘第一条线到窗口边缘的像素距离,GRID_SIZE是相邻两条线的间距,这两个值从config.py导入,绘制函数必须用同一对常量,否则鼠标换算和画面就对不上。

place()负责落子合法性,越界、已占位都返回 False,成功则写入数组、记录历史、更新最后一手:

def place(self, x, y): if not self.in_range(x, y): return False if self.grid[y][x] != self.EMPTY: return False self.grid[y][x] = self.current_player self.history.append((x, y, self.current_player)) self.last_move = (x, y) return True

这里没有在place()里做胜负判断,是为了让 AI 搜索可以复用落子逻辑而不触发游戏结束状态。搜索过程中会反复落子和撤销,如果把game_over写进place(),搜索树还没展开完游戏就被判结束了。常见做法是落子只改数据,胜负判定单独一个函数,由主循环在人类或 AI 真正走完一步后调用。

悔棋功能也依赖history。撤销时从列表尾部弹出一手或两手,把对应格子的值改回EMPTY,同时把last_move指回上一手。人机对战一般一次撤两手,因为中间夹着 AI 的一步,只撤一手会变成 AI 连着走。

3.2 四方向连珠检测

胜负判定只看最后一手,因为五子棋不可能在无关位置突然凑出五连。以落子点为中心,沿横、竖、主对角线、副对角线四个方向向两侧延伸,统计连续同色棋子的数量,达到 5 就判赢:

def check_winner(self, x, y): color = self.grid[y][x] if color == self.EMPTY: return False # 横、竖、主对角、副对角 directions = ((1, 0), (0, 1), (1, 1), (1, -1)) for dx, dy in directions: count = 1 for sign in (1, -1): nx, ny = x + sign * dx, y + sign * dy while self.in_range(nx, ny) and self.grid[ny][nx] == color: count += 1 nx += sign * dx ny += sign * dy if count >= 5: self.game_over = True return True return False

directions里四个元组分别对应向右、向下、右下、右上。每个方向用sign取 1 和 -1 向两侧扫,while里同时判断边界和颜色,扫到空位、对方棋子或棋盘外就停。count初始为 1 代表落子本身,两侧累加后大于等于 5 即胜。副对角线用(1, -1),x 增大的同时 y 减小,和屏幕坐标系的视觉方向一致。

这段代码的边界条件容易写错的地方是in_range(nx, ny)的判断顺序。必须先判断范围再取grid[ny][nx],Python 的and是短路求值,顺序反了会先越界访问。另外count >= 5而不是== 5,因为长连(六连及以上)在无禁手规则下也算赢,如果后面要加禁手,这里就得改成分别处理。

胜负判定返回布尔值,同时把game_over置真。主循环拿到 True 后不再切换回合,绘制层显示胜负文字。AI 搜索内部一般不调这个函数,而是自己维护一个更轻量的终局判断,避免搜索过程中修改game_over影响外层状态。

3.3 落子流程与重开一局

一局完整的落子流程是:人类点击 → 坐标换算 →place()→check_winner()→ 切 AI 回合 → AI 搜索 →place()→check_winner()→ 切回人类。重开一局要把所有状态清干净,包括棋盘数组、执子方、结束标志、历史记录和 AI 内部的置换表缓存:

def reset(self): self.grid = [[self.EMPTY] * self.size for _ in range(self.size)] self.current_player = self.HUMAN self.game_over = False self.history.clear() self.last_move = None # AI 侧如果有置换表/缓存,也要一并清

重开按钮一般绑在键盘事件上,比如按 R 键触发,主循环里检测event.type == pygame.KEYDOWN and event.key == pygame.K_r。也有人在窗口底部画一个矩形按钮,用event.pos判断点击区域,这种做法要在事件分发里先判断按钮区域,再判断棋盘区域,否则点按钮会被当成落子。

绘制部分按“棋盘线 → 星位 → 棋子 → 最后一手标记 → 状态文字”的顺序画,棋子用pygame.draw.circle()画实心圆,半径取GRID_SIZE // 2 - 2,留一点边距避免相邻棋子粘连。最后一手用一个小红点或者不同颜色的圆环标出来,方便复盘。棋盘线用pygame.draw.line()循环画 15 横 15 竖,星位固定在天元、四角星几个坐标上,这些坐标直接写死或者按size // 2推算。

如果棋盘尺寸改成 19 路,这套绘制逻辑要跟着改循环上限和边距,胜负判定的count >= 5不变,但 AI 的评估窗口长度要重新算。这些硬编码点在二次开发章节会集中列出来。

4. 评估函数与 α-β 剪枝:AI 的棋型打分表和搜索树砍枝

AI 这部分是整个项目的核心,也是最能体现“同样的搜索框架,评估函数写得好不好直接决定棋力”的地方。α-β 剪枝本身不复杂,难的是给每个局面打出一个靠谱的分,以及控制搜索树的分支因子。这一章从棋型打分讲到搜索实现,代码都是能直接替换进项目的版本。

4.1 棋型打分表与单点评估

五子棋的评估通常按棋型给分:五连最高,活四次之,冲四和活三再次,眠三、活二、眠二依次降低。分值的绝对大小不重要,重要的是相对关系,尤其是“己方活四”和“对方活四”的权重必须拉开,否则 AI 会拿自己的活四去换对方的冲四。

我用字符串匹配的方式识别棋型,把每个方向以落子点为中心的 9 格窗口转成字符串,1 表示己方,2 表示对方,0 表示空,3 表示墙,然后查表:

# ai.py PATTERN_TABLE = [ ("11111", "FIVE"), ("011110", "LIVE_FOUR"), ("011112", "RUSH_FOUR"), ("211110", "RUSH_FOUR"), ("01110", "LIVE_THREE"), ("010110", "LIVE_THREE"), ("011010", "LIVE_THREE"), ("001112", "SLEEP_THREE"), ("211100", "SLEEP_THREE"), ("01100", "LIVE_TWO"), ("00110", "LIVE_TWO"), ("01010", "LIVE_TWO"), ] SCORES = { "FIVE": 10_000_000, "LIVE_FOUR": 1_000_000, "RUSH_FOUR": 100_000, "LIVE_THREE": 50_000, "SLEEP_THREE": 5_000, "LIVE_TWO": 1_000, "SLEEP_TWO": 100, "ONE": 10, } def line_to_pattern(line, color): s = "" for v in line: if v == color: s += "1" elif v == 0: s += "0" elif v == -1: s += "3" else: s += "2" return s def score_line(line, color): pattern = line_to_pattern(line, color) best = 0 for sub, name in PATTERN_TABLE: if sub in pattern: best = max(best, SCORES[name]) return best

line_to_pattern()把数值数组转成字符模式,墙单独用 3 表示,这样边界处的棋型不会因为越界被误判成空位。score_line()里遍历模式表取最高分,而不是累加,因为同一方向可能同时匹配活三和活二,累加会重复计分。

单点评估以候选点为中心,取四个方向的 9 格窗口分别打分再求和:

def evaluate_point(self, grid, x, y, color): total = 0 for dx, dy in ((1, 0), (0, 1), (1, 1), (1, -1)): line = [] for step in range(-4, 5): nx, ny = x + step * dx, y + step * dy if self.in_range(nx, ny): line.append(grid[ny][nx]) else: line.append(-1) # 墙 total += score_line(line, color) return total

9 格窗口是因为五子棋的活四、冲四模式最长需要左右各 4 格才能完整判断。窗口不够长会漏判边界的冲四,窗口太长则增加无谓的计算量。这个函数在候选点排序和叶节点评估里都会用到,调用频率很高,能不用 numpy 就不用,纯 Python 的列表操作反而更快,因为单次窗口只有 9 个元素。

全盘评估则是对每个已落子位置的两侧颜色分别打分再相减,得到“AI 视角”的局面分。常见做法是只对最近落子周围做增量评估,全盘扫描只在搜索深度为 0 时执行一次。

4.2 Minimax 到 α-β 剪枝的实现

Minimax 的思路是 AI 回合取最大值,人类回合取最小值,一层层往下推。α-β 剪枝在这个基础上记录两个边界:α 是当前路径上 AI 已经能保证的最低分,β 是人类能保证的最高分,一旦 α ≥ β,后面的分支就不用看了,因为对手不会让局面走到这里。

def search(self, grid, ai_color, human_color): self.ai_color = ai_color self.human_color = human_color best_score = -float("inf") best_move = None moves = self.generate_moves(grid, ai_color) for x, y in moves: grid[y][x] = ai_color score = self.alphabeta(grid, self.depth - 1, -float("inf"), float("inf"), False) grid[y][x] = 0 if score > best_score: best_score = score best_move = (x, y) return best_move def alphabeta(self, grid, depth, alpha, beta, maximizing): winner = self.find_winner(grid) if winner == self.ai_color: return WIN_SCORE - (self.depth - depth) # 越快赢分越高 if winner == self.human_color: return -WIN_SCORE + (self.depth - depth) if depth == 0: return self.evaluate_board(grid) color = self.ai_color if maximizing else self.human_color moves = self.generate_moves(grid, color) if not moves: return 0 if maximizing: value = -float("inf") for x, y in moves: grid[y][x] = self.ai_color value = max(value, self.alphabeta(grid, depth - 1, alpha, beta, False)) grid[y][x] = 0 alpha = max(alpha, value) if alpha >= beta: break # β 剪枝 return value else: value = float("inf") for x, y in moves: grid[y][x] = self.human_color value = min(value, self.alphabeta(grid, depth - 1, alpha, beta, True)) grid[y][x] = 0 beta = min(beta, value) if alpha >= beta: break # α 剪枝 return value

WIN_SCORE要远大于任何棋型分,常见取 1 亿以上,并且减去self.depth - depth让浅层获胜优先,这样 AI 有速胜机会时不会绕路。find_winner()是搜索专用的轻量胜负判断,只检查最后一手,不修改game_over。generate_moves()返回排好序的候选点,排序质量直接影响剪枝效率,好的排序能让剪枝砍掉大半分支。

search()在外层遍历根节点的每个候选,分别试落、递归、撤销,记录最高分对应的坐标。这里没有做根节点的 α 更新,因为要比较所有候选才能选出最优走法,根节点剪枝的意义不大。如果候选点很多,可以在根节点也做排序,先试高分点,后续分支的 α 抬高后能剪掉更多。

4.3 候选点生成、深度控制与剪枝失效

候选点生成决定了搜索树的分支因子。全盘 225 个点都展开,深度 4 就是 225 的四次方,根本跑不完。常见的做法是以已有棋子为中心,取半径 2 以内的空点,去重后按单点评估分排序,只保留前 N 个:

def generate_moves(self, grid, color, radius=2): moves = set() for y in range(self.size): for x in range(self.size): if grid[y][x] != 0: for dy in range(-radius, radius + 1): for dx in range(-radius, radius + 1): nx, ny = x + dx, y + dy if self.in_range(nx, ny) and grid[ny][nx] == 0: moves.add((nx, ny)) if not moves: # 空棋盘时直接下天元 center = self.size // 2 return [(center, center)] scored = [(self.evaluate_point(grid, x, y, color), x, y) for x, y in moves] scored.sort(key=lambda item: item[0], reverse=True) return [(x, y) for _, x, y in scored[:self.candidate_limit]]

半径 2 是经验和性能的平衡点。半径 1 只覆盖紧邻的空点,容易漏掉跳活三的防守位置;半径 3 候选太多,排序开销上升。candidate_limit一般设 10 到 15,深度 4 配合这个限制,单步搜索能压到几百毫秒。

剪枝失效是调参时经常遇到的现象。明明加了 α-β,搜索时间还是没降下来,原因通常有三个:候选点没有排序,导致 α 值迟迟不抬高;评估函数里各类棋型分值差距太小,剪枝条件很难满足;搜索深度和候选数同时调大,指数增长盖过了剪枝收益。我的习惯是先把候选数压到 10,深度设 4,跑通再逐项往上加,每次只动一个参数,用time.time()打点看单步耗时。

另一个容易被忽略的点是迭代加深。从深度 2 开始搜,把上一层的最好走法排到候选列表最前面,再搜深度 3、4,虽然重复搜了浅层,但排序质量提升带来的剪枝收益往往能补回来,而且可以加时间上限,到点就返回当前最好的一手,避免窗口假死。

5. 常见问题排查:搜索卡死、棋子偏格、AI 送活三的现场记录

这个项目跑起来不难,难的是跑起来之后发现行为不对。下面五条是我在拆这类 pygame 棋类项目时反复遇到的现场,每条按现象、原因、解决三段写,照着对一遍能省不少时间。

5.1 点开始后窗口卡死:同步搜索和过高的深度

现象:程序启动正常,人类落第一子后窗口标题栏变灰,鼠标转圈,几秒甚至十几秒后才恢复,深度调得越高卡得越久。

原因:AI 搜索在主循环里同步执行,搜索期间 pygame 的事件队列没人消费,操作系统认为窗口无响应。深度和候选数同时偏大时,搜索树的节点数指数增长,几百毫秒变成几秒。

解决:先把depth降到 4、candidate_limit降到 10,确认能秒回;要保留高强度就把搜索放进threading.Thread,主线程继续跑事件循环,搜索结束后通过队列把落子坐标传回来。另一种做法是迭代加深加时间上限,每层搜完检查time.time(),超过 800 毫秒就中断返回当前最优。注意多线程下 pygame 的绘制仍然只能在主线程做,子线程只算坐标。

5.2 落子偏一格:像素坐标和网格坐标没对齐

现象:鼠标点在交叉点上,棋子却画到旁边一格,越靠棋盘边缘偏得越明显,有时候点在第一行却落到棋盘外。

原因:pixel_to_grid()用了int()向下取整,或者减去的边距和绘制时用的边距不是同一个值。另一个常见原因是绘制时给棋子加了半个格子的偏移,换算时没加。

解决:换算和绘制共用同一对MARGIN、GRID_SIZE常量,从config.py导入,不要在两处各写一遍。取整改用round(),并在启动时打印一次换算结果自检:点 (MARGIN, MARGIN) 应该得到 (0, 0),点 (MARGIN + 14GRID_SIZE, MARGIN + 14GRID_SIZE) 应该得到 (14, 14)。这两个点对得上,中间就不会偏。

5.3 AI 能赢不赢反而去堵:终局分数被棋型分盖过

现象:AI 明明有一条冲四能连成五,却跑去堵人类的活三,下一手被人类先成五。

原因:评估函数里五连的分值不够高,或者搜索到叶节点时没有先判断胜负,导致“下一步就赢”的局面被当成普通棋型打分。另一种情况是 AI 和人类的分数没有分开算,己方五连和对方五连用了同一个正分。

解决:WIN_SCORE设到远大于所有棋型分之和,alphabeta()入口先调find_winner(),命中就立刻返回,不再往下搜。评估全盘分时用“AI 分减人类分”,人类成五返回负的WIN_SCORE。改完用一个固定残局测:摆一个 AI 冲四、人类活三的局面,看 AI 是否直接成五。

5.4 重开一局 AI 不动:状态没清干净

现象:第一局正常,点重开后棋盘清了,但 AI 不回手,或者人类落子后 AI 直接下在上一局的某个位置。

原因:reset()只清了grid,没重置current_player和game_over,主循环认为还在 AI 回合或者游戏已结束。如果 AI 内部有置换表或历史启发缓存,键还是上一局的局面哈希,也会返回错误的走法。

解决:reset()里把grid、current_player、game_over、history、last_move全部重置,AI 侧的缓存单独提供一个clear_cache()在重开时调用。测试方法很简单:连续重开三局,每局都让人类先手落一子,看 AI 是否都能正常回手。

5.5 胜负判漏:grid[x][y]和grid[y][x]混用

现象:五连已经形成,程序没判赢,游戏继续;或者棋子画出来的位置和实际数据位置呈镜像对称。

原因:棋盘用grid[y][x]存储,某处代码写成了grid[x][y],在正方形棋盘上不会报越界,但读写的是另一个格子。坐标转换函数返回(x, y),落子时如果直接grid[pos]就会把元组当成下标。

解决:统一约定第一维是行、第二维是列,所有函数签名写明参数顺序,落子处用grid[y][x]并加注释。写一个最小自检:在 (3, 5) 落子,打印grid[5][3]应该是当前执子方,打印grid[3][5]应该是 0。这个检查跑通,后面的大半坐标问题都能提前暴露。

6. 二次开发:改棋盘尺寸、加禁手、把搜索内核挪到别的棋类

这套代码的扩展点比较清楚,改之前先认清哪些是硬编码。棋盘尺寸从config.py的size和绘制循环上限来,评估窗口的 9 格长度按size推算,胜负判定的count >= 5不用动。加禁手是改动量最大的一块,三三、四四、长连禁手需要在落子后额外判断该点形成的棋型组合,PATTERN_TABLE要扩展出“双活三”“双冲四”的识别,place()里加一个is_forbidden()校验。提高 AI 强度优先做候选点排序和迭代加深,置换表用 Zobrist 哈希做键,收益比单纯加深度明显。

改造项主要改动文件难度备注
棋盘改 19 路config.py、board.py、绘制循环低边距和格子间距要重算
加三三/四四禁手board.py、ai.py模式表中需要组合棋型判断
搜索加置换表ai.py中Zobrist 哈希,注意重开清缓存
换 UI 框架main.py、绘制层中棋盘和 AI 不用动
移植到别的棋类board.py、ai.py评估高规则和棋型表全换

参数集中管理是个好习惯,我会把搜索深度、候选上限、各棋型分值收进一个配置类,调参时只改一处:

# config.py class AIConfig: depth = 4 # 搜索层数,越大越慢 candidate_limit = 12 # 每层展开的候选点数 search_timeout = 0.8 # 迭代加深的时间上限,单位秒 win_score = 100_000_000 # 终局分,必须远大于棋型分 use_iterative_deepening = True

改完一定要用固定残局回归,别只靠随手下一局判断。我一般准备三个局面:AI 一步成五、人类活三必须堵、双方各有一个冲四的复杂局,每次改评估或搜索都跑一遍,看 AI 的选择有没有退化。有一次我把活三分值调高去修一个边角漏判,结果 AI 开始拿冲四换活三,就是靠这三个残局测出来的。

从那以后我每次动评估表,都强制先把这三个残局跑一遍再开窗口对局,血泪经验换来的习惯。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询