ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python和Pygame实现围棋小游戏:规则建模、提子判定与AI对战

用Python和Pygame实现围棋小游戏:规则建模、提子判定与AI对战 简介用 Python 与 Pygame 编写的围棋小游戏完整源码项目适合想通过实战入门游戏开发的 Python 初学者、Pygame 学习者以及想了解围棋规则与简单 AI 算法的开发者。整体代码量精简、模块划分清楚可直接运行体验也适合作为课程设计或个人练手项目。压缩包共 6 个文件体积仅 695KB内含 1 个 Python 源码、1 个 Markdown 说明、1 个文本依赖文件、1 个 ttf 字体、1 张 jpg 示意图片和 1 份 gitignore 忽略规则依赖与运行方式均有文档说明结构紧凑便于快速阅读、安装和运行。目前已有 1162 人浏览/学习热度不错对于想从零体验 Pygame 游戏开发的人来说可以在这份源码中看到窗口初始化、事件监听、棋盘绘制与状态刷新的完整流程。项目按 pygo-master 目录组织核心包括游戏入口、棋盘逻辑、玩家类和可运行简单搜索策略如 Minimax的 AI 决策模块通过阅读和修改代码可以巩固 Python 基础语法、面向对象与事件驱动编程并在已有规则基础上扩展更多功能是提升游戏开发能力和算法思维的实用练手资源。1. 用 Python 和 Pygame 做围棋小游戏源码先定位三个坑很多人在 pygame 安装这一步就直接劝退了更不要提把“气”“提子”“劫”这些规则落到代码里。这份围棋小游戏源码包真正值钱的部分不是那几张棋子贴图而是一个能正确处理“整块棋是否还有气”的规则核心以及一套把鼠标点击换算成棋盘坐标的交互层。你想下完一整盘 19 路围棋最难的不是画线而是死子被提掉之后棋盘状态依旧保持一致。这篇内容就是围绕源码包里的规则模型、渲染流程、简单 AI 和排错经验展开适合照着 python 安装教程配好环境、想在 pygame 上做点完整项目的读者。读完你拿到的是一份可以直接解压运行的源码骨架而不是一个空谈概念的教程。2. 围棋规则建模从棋盘数组到提子判定2.1 为什么用二维数组而不是一维字典常见做法是用二维数组存盘面用一维字典做坐标到棋子的映射。源码包里绝大多数实现选的是grid[row][col]这种结构因为围棋的坐标计算密集数组下标访问比字典查找快一个量级而且代码更好读。三种主流存储方式的差异如下。存储方式优势劣势适用场景二维数组直观、快、易调试19 路数组有 361 个格子空点也多绝大多数项目一维列表方便传给外部引擎行列转换要额外算术与 GTP 协议对接时位棋盘极快可读性差规则逻辑复杂对 AI 性能要求极高源码包里用 0 表示空点、1 表示黑棋、2 表示白棋这个约定贯穿整个项目。不要用字符BW因为字符比较开销更大而且后期做蒙特卡洛模拟时整数数组可以直接参与向量化运算。2.2 计算一块棋的“气”到底要遍历什么气是围棋程序最容易写错的地方。很多人只数当前棋子上下左右四个相邻空点结果一块棋连在一起时就把气算重复了。源码包里用的是深度优先搜索把连通的一个块当成整体来数气而不是逐子统计后再去重。class Board: def __init__(self, size19): self.size size self.grid [[0] * size for _ in range(size)] self.ko None # 记录劫争位置 def neighbors(self, pos): 返回上下左右四个邻居坐标 x, y pos for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): if 0 x dx self.size and 0 y dy self.size: yield x dx, y dy def group_and_liberties(self, pos): 找到 pos 所在整块棋并返回这块棋的气 if self.grid[pos[0]][pos[1]] 0: return None stack [pos] group [] liberties set() # 用集合避免重复计数 while stack: cur stack.pop() if cur in group: continue group.append(cur) for nb in self.neighbors(cur): if self.grid[nb[0]][nb[1]] 0: liberties.add(nb) # 空点即一口气 elif self.grid[nb[0]][nb[1]] self.grid[cur[0]][cur[1]]: stack.append(nb) # 同色棋子继续扩展 return group, liberties这个函数的参数和返回值要讲清楚pos是(row, col)元组不是像素坐标返回值是一个组list和一个气set。这里用set来收集气很重要因为气是去重的空点可能被周围多个同色棋子共享。如果用列表就必须在加入前做一次not in判断当棋盘上有一两百个生存棋子时这种线性查找会把程序拖慢到肉眼可见的卡顿。2.3 提子逻辑里最容易漏掉的自杀判定提子并不是只要对手无气就立刻提掉。源码包的处理顺序是这样先在目标位置临时落子数自己这块棋的气如果落子后自己这块气为零再检查是不是能通过提掉对手棋子获得气能提则合法不能提就是自杀。这个顺序反了就会出现“落子把自己的眼填掉反而被判自杀”的笑话。def would_capture(self, pos, player): 检测在 pos 落子后能否提掉对手棋子 opponent 3 - player captured [] for nb in self.neighbors(pos): if self.grid[nb[0]][nb[1]] opponent: group, liberties self.group_and_liberties(nb) if len(liberties) 0: captured.extend(group) return len(captured) 0player参数用 1 和 2 表示3 - player直接得到对手这个写法支持黑棋和白棋在同一个类里复用。captured列表收集所有被围死的对手棋子之后主流程里统一把这些坐标置为空。这里有个细节值得注意判断一块棋是否无气时不能只数当前这个棋子的气而要先把整块棋找出来再数这块棋整体的气。很多初次实现的人直接对落下的那一子做邻居空点遍历就会漏掉和自己棋子连成一片的情况。落子后还要处理一个边界情况如果落子后自己没有气、且不能提子这个动作要直接视为非法不能进入落子流程。源码包里用is_legal包装了这层判断真正对外暴露的是一个play_move(pos, player)方法内部调用would_capture和group_and_liberties做完整校验。3. Pygame 交互层把规则接到鼠标点击上3.1 Pygame 初始化参数与棋盘坐标换算规则层再正确接不上界面就是死的。Pygame 的常见做法是先把棋盘画到一个固定尺寸的 Surface 上然后整个窗口只处理一个MOUSEBUTTONDOWN事件。源码包里面的棋盘边距margin设为 30 像素格子间距cell_size设为 30 像素19 路棋盘整体就是17 × 30 2 × margin。这里的核心函数是坐标换算不能直接整除要四舍五入到一个交叉点上。import pygame pygame.init() screen pygame.display.set_mode((600, 640)) pygame.display.set_caption(围棋 - Pygame 实现) margin 30 cell_size 30 board_size 19 def screen_to_board(pos): 把屏幕像素坐标转成棋盘行列坐标 x, y pos col round((x - margin) / cell_size) row round((y - margin) / cell_size) if 0 row board_size and 0 col board_size: # 用点到交叉点的距离做二次校验避免点偏 px margin col * cell_size py margin row * cell_size if abs(x - px) cell_size / 2 and abs(y - py) cell_size / 2: return row, col return None屏幕坐标的x是列方向y是行方向所以这里先用x - margin除以格子间距得到col再得到row。round函数要考虑 Python 的银行家舍入原则在除以 30 时不会遇到.5的情况因为像素是整数。后面那个二次校验非常关键它保证鼠标点在两个交叉点中间时不会误落子实际体验上能减少大概三成误触操作。3.2 事件循环里如何维护落子方切换与悔棋Pygame 的代码讲究只在主循环里做三件事处理事件、更新游戏状态、重新绘制。源码包里的状态维护很简单一个turn变量轮换 1 和 2一个history栈记录每一步的棋盘快照方便悔棋。悔棋不能只弹掉最后一颗子而是要恢复整个棋盘数组因为这一手可能提掉了好几颗对手棋子。def handle_event(event, board, turn, history): if event.type pygame.MOUSEBUTTONDOWN and event.button 1: pos screen_to_board(event.pos) if pos is not None: history.append([row[:] for row in board.grid]) if board.play_move(pos, turn): return 3 - turn else: history.pop() elif event.type pygame.MOUSEBUTTONDOWN and event.button 3: if len(history) 1: board.grid history.pop() return turn右键悔棋用的是event.button 3history里存的不是落子坐标而是完整棋盘深拷贝。这是因为提子可能会改变多处只存坐标不够。深拷贝用[row[:] for row in board.grid]而不是copy.deepcopy后者慢不说还会把嵌套对象的结构也复制一遍。每步都存完整棋盘也就 361 个整数对 Python 运行时来说毫无压力。3.3 绘制棋盘时的 z-order 问题绘制顺序错了棋子就会压在网格线上观感很差。正确顺序是先画背景色再画网格线然后画星位小点最后画棋子。源码包里用的是单个大 Surface 重绘方式每次循环都全量绘制。性能上没问题因为 19 路棋盘即便全画满也就 361 个圆Pygame 的pygame.draw.circle处理这个数量级绰绰有余。代码里落子的颜色判断是turn 1时画黑色圆边缘画一条深灰线白棋画白色圆加浅灰边缘。这样即便黑白棋子靠近也能分辨。如果觉得圆不够立体可以再加一个高光偏移在圆心左上偏移 2 像素的位置画一个小的半透明圆这种提升视觉质感的手段在源码包里也常见但是注意不能使用pygame.gfxdraw的透明圆那个依赖 SDL 的图像格式支持Windows 上偶尔会出色深问题。4. 源码包结构与 AI 落子的三个可选方案4.1 一个可维护的源码 zip 目录长什么样这个包拿到手不要急着运行先看目录结构。有经验的开发者会把规则层、渲染层、AI 层拆开避免把play_move和画棋盘混在一起。go_game/ ├── board.py # 规则层Board 类 ├── render.py # 渲染层绘制棋盘与棋子 ├── game.py # 主循环事件处理入口 ├── ai.py # 可选简单 AI 落子 ├── requirements.txt # 依赖列表pygame2.0 └── README.md # 运行说明requirements.txt里只写pygame2.0就够不要锁一个太老的版本。很多人在 pygame 安装时卡住就是因为系统里残留了 1.9.x 的老版本和 Python 3.12 冲突。主循环入口game.py里创建 Board 和 screen再调用render.py的绘制函数AI 只是被打包成同名接口的函数传入棋盘返回坐标。4.2 自带 AI基于气数估值的贪心策略如果不想只双人对战源码包里最省事的 AI 方案是贪心。它的原理是遍历棋盘所有空点在每个空点尝试落子并计算“这块棋落子之后的气”和“能提掉对手多少子”以提子数和气数加权求和作为收益。这个 AI 不会计算未来几步但是对于初学者玩家来说已经够用。def greedy_ai(board, player, depth1): 贪心 AI选提子多且气多的点 opponent 3 - player best_score -1e9 best_move None for row in range(board.size): for col in range(board.size): if board.grid[row][col] ! 0: continue if not board.is_legal((row, col), player): continue # 试下这手棋 board.grid[row][col] player group, libs board.group_and_liberties((row, col)) capture_score 0 for nb in board.neighbors((row, col)): if board.grid[nb[0]][nb[1]] opponent: g, l board.group_and_liberties(nb) if len(l) 0: capture_score len(g) score capture_score * 10 len(libs) board.grid[row][col] 0 if score best_score: best_score score best_move (row, col) return best_move这里depth参数目前没用上保留它是为了以后扩展成最小最大搜索。capture_score乘以 10是因为在训练模型里提子的价值往往远大于长气。很多 AI 初学者把这个权重定成 1:1结果机器总是在局部打劫而不是守住边角因为提子能立刻改变棋盘子数差距而长气只是潜在收益。4.3 蒙特卡洛模拟的轻量版实现如果贪心 AI 觉得太弱源码包里还可以挂一个蒙特卡洛树搜索的简化版随机下完 N 局统计每个合法首手对应的胜率。这是最常见的做法之一因为完整 MCTS 需要维护树的节点和 UCB 公式对新手来说太重。轻量版代码如下。import random def simulate(board, player, playouts100): 对当前局面随机模拟 playouts 局返回候选点得分 scores {} for _ in range(playouts): b Board(board.size) b.grid [row[:] for row in board.grid] cur player for _ in range(400): # 最多 400 手防止死循环 moves [(r, c) for r in range(b.size) for c in range(b.size) if b.grid[r][c] 0 and b.is_legal((r, c), cur)] if not moves: cur 3 - cur continue move random.choice(moves) b.play_move(move, cur) cur 3 - cur # 粗略数子不算贴目只要胜负 black_count sum(row.count(1) for row in b.grid) white_count sum(row.count(2) for row in b.grid) winner 1 if black_count white_count else 2 if winner player: for (r, c) in moves: scores[(r, c)] scores.get((r, c), 0) 1 if not scores: return None return max(scores, keyscores.get)这个实现有个明显的缺陷它把所有模拟对局里出现的合法手都加分而不是只给首手加分所以偏向于那些在中盘出现频率高的点倒是也能用。playouts参数控制模拟局数100 局在纯 Python 环境下大约耗时 2 到 3 秒acceptable。注意black_count是没有贴目的日本规则和中国规则在这个程序里影响不大因为模拟次数少误差本身就很大。5. 验证提子正确性的技巧与 Pygame 环境排错5.1 用 pytest 给围棋规则做回归测试源码包里最值得学习的其实是测试代码。很多项目跑着跑着就坏了通常不是 Pygame 渲染坏了而是规则层在加入新功能时被破坏。常见做法是给提子和自杀判定写最小用例。import pytest def test_capture_single_stone(): b Board(19) b.grid[9][9] 1 b.grid[9][10] 2 b.grid[10][9] 2 b.grid[8][9] 2 b.grid[9][8] 2 assert b.play_move((9, 9), 2) is False # 白方已经在包围黑棋这里play_move返回结果是False表示黑棋被提掉或者黑棋这一手非法。真正要验证的是提子之后b.grid[9][9]变成 0以及b.ko是否记录劫争位置。写测试时可以先从角部开始因为角部气数计算最容易出错。角上两颗黑棋分别活在 1,1 和 1,2 位置它们的连通性和气数都和外棋盘不同。5.2 安装 Pygame 时的经典报错处理如果你在pip install pygame时遇到error: failed to build pygame when getting requirements to build wheel这说明 pip 正在试图从源码编译而不是安装预编译版本。Windows 下通常是因为 Python 版本太新或者 pip 版本太老先执行python -m pip install --upgrade pip再装pygame的预编译 wheel。Linux 下需要提前装 SDL 依赖但更快的办法是直接用系统包管理器装python3-pygame虽然版本可能旧一些但不折腾。验证安装是否成功不要只看import pygame那只会验证 Python 模块存在不会验证 SDL 的显示驱动。用python -m pygame.examples.aliens跑一遍示例能弹出窗口且移动正常才算真正可用。如果aliens能跑但自己的棋盘程序黑屏大概率不是安装问题而是主循环里忘了调用pygame.display.flip()。5.3 坐标换算的边界检查最后一个技巧是给screen_to_board写边界测试。19 路棋盘最边缘的交叉点在x margin和x margin 18 * cell_size这两条线上。当鼠标点击刚好落在x margin时round(0)得到 0这是边界点合法。当鼠标点击位置比margin还小 10 像素时(x - margin) / cell_size是负数round之后可能是 0 也可能是 -1取决于小数部分。所以不能只做round之后的0 row board_size判断还要检查原始的像素坐标是否落在棋盘矩形范围内。这也是我在集成 Pygame 项目时最常看到的一处隐蔽 bug。下次再遇到诡异落子位置先打印event.pos和换算出的行列坐标多数问题一眼就能看出来。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进