简介:用 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 表示白棋,这个约定贯穿整个项目。不要用字符'B''W',因为字符比较开销更大,而且后期做蒙特卡洛模拟时,整数数组可以直接参与向量化运算。
2.2 计算一块棋的“气”到底要遍历什么
气是围棋程序最容易写错的地方。很多人只数当前棋子上下左右四个相邻空点,结果一块棋连在一起时就把气算重复了。源码包里用的是深度优先搜索,把连通的一个块当成整体来数气,而不是逐子统计后再去重。
class Board: def __init__(self, size=19): 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 == 3,history里存的不是落子坐标,而是完整棋盘深拷贝。这是因为提子可能会改变多处,只存坐标不够。深拷贝用[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 # 依赖列表,pygame>=2.0 └── README.md # 运行说明requirements.txt里只写pygame>=2.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, depth=1): """贪心 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, playouts=100): """对当前局面随机模拟 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, key=scores.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和换算出的行列坐标,多数问题一眼就能看出来。
本文还有配套的精品资源,点击获取