简介:一款基于 Python 与 Pygame 的围棋小游戏源码包,适合 Python 初学者、游戏开发爱好者用于学习事件驱动编程、面向对象设计与基础 AI 算法。项目围绕围棋核心规则展开,包含主程序、棋盘逻辑、玩家与 AI 模块,可运行可修改,有助于理解合法落子判断、胜负判定以及基于 Minimax 的简单电脑决策思路。整个压缩包共 6 个文件,以 .py 源码、.md 说明文档、.txt 配置、.gitignore、字体文件和示例图片组成,整体仅 695KB,结构轻量、便于下载和二次开发。目前已有 1162 人学习下载。通过阅读 main.py、game_board.py、player.py 与 ai.py 等模块,读者既能巩固 Python 语法和 Pygame 窗口、事件、图像处理等核心组件用法,也能掌握棋盘状态更新、绘制刷新和简单 AI 策略的实际落地方式,是快速上手 Python 小游戏开发的实用素材。
1. 用 Python 和 Pygame 写围棋,为什么值得拆一遍
围棋在棋类游戏里是个异类:规则只有两条(围地和提子),但状态复杂度远超国际象棋。用 Python 这种解释型语言配合 Pygame 做 19 路棋盘,性能上注定不是对手,可我反而觉得这正是它适合当教材的原因。这份 pygo-master 源码包里没有一堆分散的模块,核心就是一个 go.py,外加 static、img、font 资源目录和 requirement.txt,结构非常干净。如果你想在免费 Python 源码大全这类合集里翻出一个能直接跑起来的项目,刚按 Python 安装教程配好环境的话,这个围棋小游戏会比俄罗斯方块更能逼你处理数据结构、事件循环和简单 AI。我会按实际代码拆开讲,包括怎么建模棋盘、怎么提子、怎么在 Pygame 主循环里把逻辑和渲染接起来。
2. Pygame 事件驱动与围棋规则建模
这章把地基打牢,不然直接看 go.py 会被一堆状态变量绕晕。
2.1 Display、Surface 与事件循环的关系
Pygame 不是游戏引擎,而是一组底层库的集合。创建窗口用pygame.display.set_mode(),它返回一个 Surface,所有绘制都在这个 Surface 上完成。围棋的棋盘是静态网格,不需要每帧重新加载图片,只需要在落子后重绘变化的部分。环境配置按 Python 安装教程走就行,如果用 pip 安装,最简单的是pip install pygame。装完后可以用python -c "import pygame; print(pygame.version.ver)"验证版本。注意 Python 3.10 以下和以上对 pygame 的 wheel 支持不同,后面第 5 章会讲。
事件循环是 Pygame 的核心模式:pygame.event.get()拉取队列里的所有事件,比如鼠标点击、键盘按键和窗口关闭。围棋只需要响应鼠标左键,但你要分辨是「点击棋盘交叉点」还是「点击界面按钮」,所以坐标换算要写对。很多人在这个阶段把事件处理和绘制逻辑混在一个大循环里,导致调试时很难定位是规则出错还是渲染出错。
2.2 用二维数组表示棋盘状态
19 路围棋盘有 361 个交叉点。最简单也最稳的建模是二维数组,board[y][x]取值为 0(空)、1(黑)、2(白)。为什么不用一维数组?因为判断气的邻居时二维索引更直观,调试时把棋盘打印成 19 行数字,一目了然。项目正文里没给出具体结构,但这类小游戏十有八九是这个方案。
BOARD_SIZE = 19 EMPTY, BLACK, WHITE = 0, 1, 2 def init_board(): # 初始化全空棋盘 return [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)] def neighbors(x, y): # 四个相邻方向,越界跳过 for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): nx, ny = x + dx, y + dy if 0 <= nx < BOARD_SIZE and 0 <= ny < BOARD_SIZE: yield nx, nyinit_board生成一个全部为空的棋盘。neighbors返回相邻四个方向的坐标,不越界。后续计算气、提子、判断合法性都要用到它,建议把它作为公共函数。BOARD_SIZE在 go.py 里可以改成 9 或 13,做小棋盘测试时更快。
2.3 气的计算与提子规则
围棋规则中,「气」是一个连通块直接相邻的空点。落子后先提对方无气的块,再提自己无气的块。没有气的己方落子通常叫自杀,多数规则禁止。用集合去重可以避免重复计算同一块棋。
def find_group(board, x, y): # 深度优先找同色连通块 color = board[y][x] if color == EMPTY: return set() group, stack = {(x, y)}, [(x, y)] while stack: cx, cy = stack.pop() for nx, ny in neighbors(cx, cy): if board[ny][nx] == color and (nx, ny) not in group: group.add((nx, ny)) stack.append((nx, ny)) return group def count_liberties(board, group): # 统计一个连通块的气数 liberties = set() for x, y in group: for nx, ny in neighbors(x, y): if board[ny][nx] == EMPTY: liberties.add((nx, ny)) return len(liberties)find_group用深度优先搜索把同色连通块全部找出来,count_liberties数这个块周围有几个空点。提子的时候遍历对方所有连通块,气数为 0 就移除。这里有个初学者常踩的坑:落子后如果先提对方再判断自己,那么自己的相邻块会因为对方被提走而有了新气,自杀判断不能只看落子前。
下面表格列出 Pygame 中本项目涉及的关键组件,方便对照 go.py 里的调用。
| 模块 | 用途 | go.py 场景 |
|---|---|---|
| display | 创建窗口、设置标题 | set_mode((720, 720)) |
| event | 读取鼠标、键盘事件 | 点击落子、按 R 重置 |
| draw | 绘制线段、圆形 | 画棋盘网格和棋子 |
| font | 渲染文字 | 显示对局状态和中文提示 |
| image | 加载图片 | 加载皮肤图片到 Surface |
| time | 控制帧率 | pygame.time.Clock().tick(30) |
2.4 屏幕坐标与棋盘坐标的换算
Pygame 的鼠标事件返回的是窗口像素坐标,要落子必须先换成交叉点坐标。常见做法是固定 margin 和 cell_size,用四舍五入找最近交叉点:
def screen_to_board(pos, margin, cell_size): x, y = pos board_x = round((x - margin) / cell_size) board_y = round((y - margin) / cell_size) if 0 <= board_x < BOARD_SIZE and 0 <= board_y < BOARD_SIZE: return board_x, board_y return None参数说明:margin是棋盘四周的留白,cell_size是相邻交叉点之间的像素间距。窗口宽度应满足MARGIN*2 + CELL_SIZE * (BOARD_SIZE-1),这样棋盘才能居中。换算时用round而不是int,避免点击两线中间时永远靠向原点。如果返回None,说明点击在棋盘外,直接忽略。
3. go.py 的结构与主循环实战
先别急着跑代码,把资源包拆开看。
3.1 解压后的文件清单
pygo-master 目录下文件不多,只有 go.py 作为核心程序,static、img、font 三个目录放资源,外加 .gitignore、requirement.txt 和 README.md。这种单文件设计的好处是学习成本低,坏处是所有函数都堆在一个文件里,命名稍乱就要靠搜索跳转。先看表格:
| 文件/目录 | 作用 |
|---|---|
| go.py | 游戏主程序,入口 |
| static/ | 静态配置或辅助资源 |
| img/ | 棋盘背景、棋子图片 |
| font/ | 中文字体文件 |
| .gitignore | Git 忽略规则 |
| requirement.txt | 依赖清单 |
注意依赖文件是 requirement.txt,不是常见的 requirements.txt,安装时要按实际文件名执行。这种细节在小项目里经常被忽略。运行前先安装依赖:
pip install -r requirement.txt如果这里只写了pygame,那它会拉取当前 Python 环境对应的最新版。建议之后改成pygame>=2.0,Pygame 2 的事件和文本渲染比 1.x 稳定很多。
3.2 主循环骨架:事件、更新、渲染
从文件命名看,go.py 承担了主程序,Pygame 项目的循环结构通常如下:
import pygame import sys def main(): pygame.init() screen = pygame.display.set_mode((720, 720)) pygame.display.set_caption("Go - Pygame") clock = pygame.time.Clock() board = init_board() turn = BLACK while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: x, y = screen_to_board(event.pos, MARGIN, CELL_SIZE) if x is not None: # 在这里调用 try_place_stone pass # 渲染棋盘 clock.tick(30) if __name__ == "__main__": main()这个骨架是 Pygame 通用写法。pygame.init()初始化所有模块,set_mode返回屏幕 Surface。event.button == 1表示鼠标左键按下。pygame.QUIT事件必须处理,否则点关闭按钮时进程会卡住。clock.tick(30)限制帧率到 30 FPS,围棋交互频率不高,30 足够,反而能降低 CPU 占用。真正要落子时,把坐标传给规则函数,再更新 board 变量。
3.3 用 font 和 image 加载资源
围棋界面需要显示对局状态,比如「黑方执子」「白方超时」。Pygame 默认字体不含中文字形,所以要用 font 目录下的字体文件。常见做法:
import os font_path = os.path.join("font", "simhei.ttf") font = pygame.font.Font(font_path, 24) text_surface = font.render("黑子落子", True, (255, 255, 255)) screen.blit(text_surface, (10, 10))参数说明:Font第一个参数是字体路径,第二个是字号。render的第三个参数是抗锯齿开关,中文小字号建议开 True。如果字体路径错误,Pygame 会抛异常,可以用pygame.font.get_fonts()先查看系统可用字体。图片资源用pygame.image.load加载,加载后调用.convert()可以把像素格式转成屏幕格式,blit 时更快。如果图片尺寸和窗口不匹配,用pygame.transform.scale缩放。
到这里,go.py 的骨架已经能跑起来了,剩下的核心问题是规则引擎。
4. 规则引擎完整实现与 AI 落子
这是整个项目最值得读的部分,也是围棋和五子棋这类简单棋类最大的区别。
4.1 合法落子的完整流程
落子前要判断三件事:坐标为空;落子后提掉对方无气块;不自杀。更进一步还要考虑打劫。下面是一个可用的实现:
def opponent(color): return WHITE if color == BLACK else BLACK def try_place_stone(board, x, y, color): if board[y][x] != EMPTY: return False board[y][x] = color # 临时落子 captured = 0 # 提子数,可用于界面显示 for nx, ny in neighbors(x, y): if board[ny][nx] == opponent(color): group = find_group(board, nx, ny) if count_liberties(board, group) == 0: for gx, gy in group: board[gy][gx] = EMPTY captured += len(group) my_group = find_group(board, x, y) if count_liberties(board, my_group) == 0: # 自杀检查,撤销落子 board[y][x] = EMPTY return False return True代码逻辑:先临时落子,然后检查四个方向的对手连通块,气数为 0 就整块提掉。提完之后再检查落在位置上的自己这块棋还有没有气,没有就是自杀,撤销落子并返回 False。注意这里先提对方再检查自己非常关键,否则会出现「看似自杀,实际提了对方之后自己又有气」的漏判。captured变量可以累加,用于界面显示双方吃子数。
4.2 打劫与历史状态
围棋的劫规则单独拎出来说,是因为它很容易被忽略。简单实现是记录上一手被提掉的那个点,下一次落子禁止下在那里,除非能提回更多子。go.py 如果只做单文件教学,这个粒度够了。
class GameState: def __init__(self): self.board = init_board() self.turn = BLACK self.ko_point = None self.last_capture = None类里有board、turn、ko_point和last_capture四个属性。每次成功落子且只提了一子时,把被提的位置记入ko_point;下一次落子如果目标坐标等于ko_point,直接拒绝。但实际围棋中,打劫必须「隔一手」才能提回,这个简化模型并不完善。更完整的做法是保存上一步棋盘快照,回推一步对比当前棋盘是否完全相同。对于 19 路棋盘,深拷贝代价并不高,毕竟一次对局最多几百手。
4.3 终局数子与胜负
围棋终局判断很复杂,小游戏通常会用简化数子方式。一个可用思路是:双方都跳过落子后,把每个空区域边界颜色找出来,清一色属于黑方就计为黑地,清一色属于白方就计为白地,边界混合的区域不算任何人的地。
def find_region(board, x, y): # 找空区域,类似 find_group if board[y][x] != EMPTY: return set() region, stack = {(x, y)}, [(x, y)] while stack: cx, cy = stack.pop() for nx, ny in neighbors(cx, cy): if board[ny][nx] == EMPTY and (nx, ny) not in region: region.add((nx, ny)) stack.append((nx, ny)) return region def score_board(board): visited = [[False] * BOARD_SIZE for _ in range(BOARD_SIZE)] score = {BLACK: 0, WHITE: 0} for y in range(BOARD_SIZE): for x in range(BOARD_SIZE): c = board[y][x] if c in (BLACK, WHITE): score[c] += 1 elif not visited[y][x]: region = find_region(board, x, y) borders = set() for rx, ry in region: visited[ry][rx] = True for nx, ny in neighbors(rx, ry): if board[ny][nx] != EMPTY: borders.add(board[ny][nx]) if borders == {BLACK}: score[BLACK] += len(region) elif borders == {WHITE}: score[WHITE] += len(region) return score[BLACK], score[WHITE]这段代码把棋盘遍历一遍,先数棋子,再处理空区域。visited保证每个空区域只算一次。注意这种简化不会处理双活,在自由对局里可能出现争议,但作为学习项目够用。如果想要更严格,可以再判断眼位和双活,不过那就是单独一篇博文的量了。
4.4 一个能用的 AI 落子策略
如果 go.py 有 AI 对手,大概率不会用蒙特卡洛树搜索,因为单文件写不下。最常见的是浅层 Minimax 或者「模拟落子 + 评估」。评估函数用双方气的差值,简单且反应速度快。
def evaluate(board, color): opp = opponent(color) my_air = sum(count_liberties(board, find_group(board, x, y)) for x in range(BOARD_SIZE) for y in range(BOARD_SIZE) if board[y][x] == color) opp_air = sum(count_liberties(board, find_group(board, x, y)) for x in range(BOARD_SIZE) for y in range(BOARD_SIZE) if board[y][x] == opp) return my_air - opp_air def ai_move(board, ai_color): best_score = float('-inf') best_move = None for x in range(BOARD_SIZE): for y in range(BOARD_SIZE): if board[y][x] != EMPTY: continue snapshot = [row[:] for row in board] if try_place_stone(snapshot, x, y, ai_color): score = evaluate(snapshot, ai_color) if score > best_score: best_score = score best_move = (x, y) return best_moveai_move遍历所有空点,复制棋盘后模拟落子,再用evaluate计算局面分。评估里的count_liberties单独对每个连通块求气,然后累加,这样等于奖励那些气多的棋型,同时惩罚对方的气。因为每次都重新找连通块,19 路全盘搜索会比较慢,实际跑的时候建议把BOARD_SIZE临时改成 9。如果你希望 AI 更「贪吃」,可以把评估函数改成净提子数,也就是模拟后captured的差值。
| 评估策略 | 计算方式 | 对局风格 |
|---|---|---|
| 净提子数 | 落子后双方累计提子差 | 激进,能提就提 |
| 总气差 | 己方总气减对方总气 | 稳健,兼顾逃跑和包围 |
| 领地估算 | 用数子函数计算地盘差 | 最接近真围棋,但慢 |
5. 运行排错与两个实用小技巧
这一章不写大道理,只说我会在跑这种源码包时碰到的真问题。
5.1 安装 pygame 失败与 Python 版本匹配
很多人在pip install pygame时遇到error: failed to build 'pygame' when getting requirements to build wheel。这个错误通常是因为当前 Python 版本没有对应平台的预编译 wheel,安装流程被迫走源码编译,而本机缺少编译工具链。解决办法是升级 pip,或者直接用预编译版本:
pip install --upgrade pip pip install pygame --pre如果还不行,就装社区维护的pygame-ce,它的包名是 pygame-ce,但导入仍是import pygame。在 requirement.txt 里把pygame改成pygame-ce即可。这个资源里写的是 requirement.txt,其中依赖通常就是pygame,跑不起来时优先检查这一点。
5.2 中文字体显示成方块的排查
围棋 UI 显示中文时,如果全是方块,第一件事检查 font/ 目录下的文件是否被 Git 忽略。.gitignore如果写了*.ttf或font/,拉下来就没有字体文件。第二件事是路径拼接,不要写死/home/user/font/simhei.ttf,用相对路径加os.path.join。第三件事是确认字体格式,pygame.font.Font支持.ttf和.otf,不支持.ttc,遇到.ttc需要转换或换字体。
5.3 用常量驱动棋盘尺寸,让 19 路和 9 路通吃
最后是一个我很常用的技巧:把棋盘尺寸全部抽成常量,而不是写死在所有函数参数里。
BOARD_SIZE = 19 CELL_SIZE = 36 MARGIN = 24 WINDOW = MARGIN * 2 + CELL_SIZE * (BOARD_SIZE - 1)这样做的直接好处是,调试ai_move时把BOARD_SIZE改成 9,窗口大小、网格间距、坐标换算全部自动跟着变,不需要去每个函数里找魔法数字。如果你修改 go.py 想加一个「快速对局」模式,只需要在启动时让BOARD_SIZE = 9,然后重新初始化一遍窗口。这比单独建一个 9 路棋盘类省事得多。
本文还有配套的精品资源,点击获取