
简介一份从零搭建的用Python与Pygame实现的围棋小游戏源码是面向Python初学者和游戏开发爱好者的完整项目范例它将围棋对弈过程搬进程序让玩家能与电脑或另一名玩家在图形界面中落子、提子最终分出胜负。压缩包内共有六个文件分别包含主程序、依赖说明、项目文档、预览图片与字体文件整体体积很小只有不到七百KB适合下载后快速打开学习目录层次清晰。程序实现中综合运用了Pygame的窗口初始化、事件监听、图形绘制、文本渲染等机制代码按功能拆成游戏入口、棋盘逻辑、玩家控制与AI策略几个模块帮助读者理解游戏循环结构和面向对象设计。尤其值得关注的是其中还加入了一个简易AI算法能够自动选择落子位置可作为进一步研究搜索算法和AI对战思路的入口。目前已有1162人学习下载配合文档学习既可巩固Python基础也能掌握Pygame的常用API更可通过修改AI难度或界面样式把项目改造成属于自己的围棋小游戏。1. 用 Pygame 写围棋重头戏不在界面而在规则状态机一个打着“Python Pygame 围棋小游戏源码”名号的压缩包里最容易让人误判难度的是开头部分画一张 19 路棋盘、放两种颜色的棋子、响应鼠标点击这些 Pygame 基础操作花不了多少代码。可一旦真的点下去提子、禁着点、打劫这三个规则细节会立刻浮出来而且全部卡在数据结构上而不是界面上。任何一个处理错下到中盘就会明显感觉到棋局状态和真实围棋对不上该提的棋没提掉、某个点明明能落子却被拦住、同一个劫反复横跳永远不收。这篇把一套能完整跑完一局的 Python Pygame 围棋实现路线拆开来讲棋盘怎么存、气怎么算、提子怎么触发、终局怎么收以及最后怎么把这个源码包变成别人也能双击运行的程序。适合想用 Python 入门做一个完整 Pygame 项目的开发者也适合手头有规则引擎、正想补一层可操作界面的读者。2. 棋盘建模与算气先让数据结构“懂”落子2.1 为什么要先把棋盘抽象成状态机常见坏习惯是从画棋盘、绑鼠标事件开始写。棋盘被定义成一张可视画布每个点存一个颜色落子时直接把颜色覆盖上去。这种写法前十分钟很爽一旦要处理提子和禁着点业务逻辑就要反向操作这张画布坐标换算、界面刷新和规则合法性全绞在一起改一处崩三处。我一般会把棋盘拆成两层状态层只保存二维数组和规则函数渲染层只负责读数组画点。规则层不依赖 pygame任何规则函数都可以单独脱离窗口环境去测试渲染层再简陋只要状态层是对的整盘棋就不会出原则性错误。源码包里值得被抄走的通常也就是这一层。2.2 棋盘数据结构选型与坐标常量围棋有 9 路、13 路、19 路三种常见规格源码里最常见的做法是用一个配置常量控制默认 19 路、demo 里给 9 路。数据结构上用二维列表存 0/1/2 三个值0 空、1 黑、2 白。不用 numpy 的原因是这个项目根本用不到批量矩阵运算每一步都只是小块区域的修改numpy 反而会引入 dtype 和拷贝方面的额外心智负担一个纯 Pygame 小游戏没必要为了几个二维数组多带一个依赖。BOARD_SIZE 9 # 9 / 13 / 19 三档可选 EMPTY, BLACK, WHITE 0, 1, 2 board [[EMPTY] * BOARD_SIZE for _ in range(BOARD_SIZE)] DIRS ((1, 0), (-1, 0), (0, 1), (0, -1)) def neighbors(x, y): for dx, dy in DIRS: nx, ny x dx, y dy if 0 nx BOARD_SIZE and 0 ny BOARD_SIZE: yield nx, nyneighbors写成生成器而不是直接返回列表是因为落子流程里至少会多次遍历邻居检查占位、提对方、查自己气每次为四个坐标临时分配一个列表没有必要。BOARD_SIZE作为全局常量被渲染、坐标换算、AI 搜索共同引用改动路数只动一处。2.3 用迭代洪泛计算棋块与气棋块是能通过上下左右相连的一组同色棋子气和提子都以棋块为单位。计算棋块最容易写错的方式是递归Python 默认递归深度限制在 1000 层左右19 路上一块几十上百子的棋很可能直接抛RecursionError。换成显式栈做迭代洪泛就没有这个顾虑。def group_and_libs(board, x, y): color board[x][y] if color EMPTY: return set(), set() stack [(x, y)] group set() libs set() while stack: px, py stack.pop() if (px, py) in group: continue group.add((px, py)) for nx, ny in neighbors(px, py): v board[nx][ny] if v EMPTY: libs.add((nx, ny)) elif v color and (nx, ny) not in group: stack.append((nx, ny)) return group, libs这里有两个细节。group集合同时充当已访问标记绕过它就能避免无限循环libs用集合去重同一个空点同时接触两块同色棋时气数只算一次。返回的两个集合一个代表“要提掉哪些点”一个代表“这块棋现在有几口气”后续提子和禁着点判断全部基于这两个返回值。2.4 落子判定顺序先落、再提、后查自杀很多源码在这里犯同一个错误落子前先检查“自己落下去有没有气”没气就拒绝。这在周围没有对方棋子时说得通但假如这一步落下去刚好能提掉对方一块棋提子制造出的空点会重新给己方棋块供气先查自杀就把这一步合法棋误杀掉了。教科书顺序必须反过来先落子提走对方无气棋块最后检查自己的棋块是否还活着。def try_move(board, x, y, color, captures): if board[x][y] ! EMPTY: return False opponent WHITE if color BLACK else BLACK board[x][y] color captured 0 for nx, ny in neighbors(x, y): if board[nx][ny] ! opponent: continue group, libs group_and_libs(board, nx, ny) if not libs: captured len(group) for px, py in group: board[px][py] EMPTY _, own_libs group_and_libs(board, x, y) if not own_libs: board[x][y] EMPTY return False captures[color] captured return Truecaptures是一个按颜色记录累计提子数的字典后续终局计分和界面提示都要用。函数通过参数传入、原地修改棋盘不持有全局状态方便在同一进程里开多局或者做自动化测试。参数含义非常固定参数名类型含义boardlist[list[int]]棋盘状态函数可能原地修改x, yint落子坐标从 0 开始colorint1 表示黑棋2 表示白棋capturesdict[int,int]累计提子数函数按颜色累加captures用可变对象而不是返回值带出是为了后续做悔棋时能连同提子数一起回退。目前这个函数还缺“全局盘面历史”的检查那是打劫规则的地盘放到第 4 章补全。3. Pygame 绘制与鼠标交互让一局棋“点得准、画得动”3.1 网格与屏幕坐标的双向换算Pygame 的屏幕坐标系原点在左上角x 向右、y 向下棋盘的 0 行 0 列也在左上角。多数源码会把换算逻辑直接写进事件循环结果MARGIN、CELL这些魔数散落各处。更稳的写法是封装两个函数一个从逻辑坐标到屏幕像素一个从鼠标像素反推逻辑坐标。WIDTH HEIGHT 720 MARGIN 40 # 棋盘四周留白 CELL (WIDTH - 2 * MARGIN) // (BOARD_SIZE - 1) def grid_to_screen(x, y): return MARGIN x * CELL, MARGIN y * CELL def screen_to_grid(pos): px, py pos gx round((px - MARGIN) / CELL) gy round((py - MARGIN) / CELL) if not (0 gx BOARD_SIZE and 0 gy BOARD_SIZE): return None sx, sy grid_to_screen(gx, gy) if (px - sx) ** 2 (py - sy) ** 2 (CELL * 0.45) ** 2: return None return gx, gyCELL的计算用的是(WIDTH - 2 * MARGIN) // (BOARD_SIZE - 1)不是WIDTH // BOARD_SIZE。原因是棋盘有 19 条竖线但间距只有 18 格按格子数除会整体错位一截。screen_to_grid里先round做就近吸附再用距离平方判断鼠标有没有真正落在交点附近。如果省掉最后那个距离判断玩家点在两条线中间也会被强行吸附成一个落点体感就是“我没点那它下了”。这个平方距离判定的阈值取CELL * 0.45比半格略小既保证容易点中又避免误点。3.2 棋盘与棋子的绘制策略绘制层面最容易浪费性能的地方是反复调用pygame.draw.line。一块 19 路棋盘有 38 条线加九个星位每帧重画一遍纯属浪费。正确姿势是把静态的棋盘表面缓存成一张grid_surface主循环每一帧先整张blit再往上面叠棋子。grid_surface pygame.Surface((WIDTH, HEIGHT)) grid_surface.fill((222, 184, 135)) # 木色底 for i in range(BOARD_SIZE): start grid_to_screen(0, i) end grid_to_screen(BOARD_SIZE - 1, i) pygame.draw.line(grid_surface, (80, 60, 40), start, end, 2) pygame.draw.line( grid_surface, (80, 60, 40), grid_to_screen(i, 0), grid_to_screen(i, BOARD_SIZE - 1), 2 ) if BOARD_SIZE 9: star [(2, 2), (2, 6), (6, 2), (6, 6), (4, 4)] elif BOARD_SIZE 13: star [(3, 3), (3, 9), (9, 3), (9, 9), (6, 6)] else: star [(3, 3), (3, 9), (3, 15), (9, 3), (9, 9), (9, 15), (15, 3), (15, 9), (15, 15)] for sx, sy in star: px, py grid_to_screen(sx, sy) pygame.draw.circle(grid_surface, (80, 60, 40), (px, py), 4)星位星标在不同路数的棋盘上位置不同9 路的角星在第二线13 和 19 路在第三线天元都在正中。这里按BOARD_SIZE分支给表比硬编码坐标更利于扩展。后面每次需要换棋盘尺寸只改BOARD_SIZE星位自动跟着变。棋子绘制没有捷径遍历棋盘数组逐个画圆即可。361 个点对现代 CPU 来说每帧全量重绘毫无压力不需要做脏矩形优化。画白棋时建议底色用接近纯白而不是(255, 255, 255)在木色背景上略偏暖的白色观感更自然同时加一圈 1px 灰色描边能让棋子轮廓更清楚。def draw_pieces(screen, board, last_moveNone): for y in range(BOARD_SIZE): for x in range(BOARD_SIZE): center grid_to_screen(x, y) if board[x][y] BLACK: pygame.draw.circle(screen, (30, 30, 30), center, int(CELL * 0.44)) elif board[x][y] WHITE: pygame.draw.circle(screen, (244, 244, 244), center, int(CELL * 0.44)) pygame.draw.circle(screen, (120, 120, 120), center, int(CELL * 0.44), 1) if last_move: mark grid_to_screen(*last_move) pygame.draw.circle(screen, (200, 60, 40), mark, 5)last_move最后一手的红色标记是必要的视觉反馈否则在密集落子区域很难看清刚下了哪里。红点画在最上层不会被棋子覆盖。3.3 事件循环里的一次落子流程渲染准备好了剩下就是把鼠标事件接进规则层。主循环的骨架是 Pygame 标准事件循环唯一需要小心的是落子动作要在MOUSEBUTTONDOWN里完成而不是MOUSEBUTTONUP否则点击和落子之间会有明显延迟感棋盘响应显得拖沓。def main(): board [[EMPTY] * BOARD_SIZE for _ in range(BOARD_SIZE)] captures {BLACK: 0, WHITE: 0} history set() current BLACK last_move None passes 0 pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(Pygame 围棋) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: pos screen_to_grid(event.pos) if pos and make_move(board, pos[0], pos[1], current, captures, history): last_move pos current WHITE if current BLACK else BLACK passes 0 elif event.type pygame.KEYDOWN: if event.key pygame.K_SPACE: # 停一手 passes 1 current WHITE if current BLACK else BLACK elif event.key pygame.K_r: # 重开 board [[EMPTY] * BOARD_SIZE for _ in range(BOARD_SIZE)] captures {BLACK: 0, WHITE: 0} history set() passes 0 last_move None screen.blit(grid_surface, (0, 0)) draw_pieces(screen, board, last_move) pygame.display.flip() pygame.quit()这段代码里出现了一个还没有定义的高层函数make_move它是在try_move之外再包一层“禁着点/打劫/历史记录”判断的完整入口。先卖个关子第 4 章给出它的实现。键盘的空格表示“停一手”R 重开是纯 Pygame 项目里成本最低的控制方式要不要做悔棋按键取决于你是否愿意再维护一个历史状态栈我一般先不加等规则层稳定后再补。4. 禁着点、打劫与终局判定把规则补到能完整收掉一盘棋4.1 禁着点提子之后再检查自己的气第 2 章的try_move已经包含禁着点判断这里把判定逻辑单独拎出来强调一次。禁着点指的是落子后己方棋块无气、且不能提掉任何对方棋块的交叉点。它和“落子后自己无气”的区别就在于有没有先执行提子。顺序错位的典型表现是某些“打吃后连回”的棋会被程序拒绝。比如黑棋三个子被白棋围住黑在断点落一子形成“接不归”并顺带提掉白棋一子如果先查自己气黑会认为自己仍然无气从而拒绝落子正确的做法是让黑先提白提完之后黑棋获得一口气这个点合法。try_move里“先落、再提、后查”的顺序就是为这种场景兜底。4.2 打劫检测用盘面哈希集合拦住循环打劫本质是“全局盘面不允许完全重复”。最简实现是维护一个历史集合每一步落子后把整盘状态序列化成不可变对象存进去下一位玩家要下的点如果会导致盘面与历史中某个状态完全一致就判定为非法。这个实现策略通俗叫“全局同形再现检测”是劫规则里最省事、也最不易漏判的版本。def board_key(board): return bytes(v for row in board for v in row) def make_move(board, x, y, color, captures, history): clone [row[:] for row in board] caps captures.copy() if not try_move(clone, x, y, color, caps): return False key board_key(clone) if key in history: return False board[:] clone captures.update(caps) history.add(key) return Truemake_move先深拷贝棋盘在副本上执行try_move全部通过后再提交到真实棋盘。这个“试跑”设计比“先落子、非法再回滚”要简单得多因为回滚一个已经提掉对方棋子的盘面非常容易出错。性能方面克隆 19×19 的二维列表约 361 次赋值每一步棋只做一次瓶颈远不在这一层。board_key用bytes把数值列表压平成不可变串作为集合元素自然是可哈希的整局棋几百手累计不过几十到几百 KB非常便宜。这里有个容易被忽略的边界劫的禁止对象不是“不能立即回提”而是“不能回到上上次出现过的完全相同的盘面”。真正的打劫场景里黑提劫、白找劫材、黑应劫绕一圈后盘面必须已经变化否则白棋不能立刻把劫提回来。所以历史集合要存的是每一次实际落子后的盘面而不是只存上一手。4.3 连续停一手与终局计分双方都确认无棋可下时棋局结束。界面上的实现是空格停一手的计数任一方落子就清零一旦passes 2触发终局。触发之后需要算分。正规围棋有“数目法”和“数子法”两套体系源码演示没必要把死子确认那套完整流程做进来用“区域归属”近似已经很实用对每个空的连通区域做洪泛如果这个区域的边界只接触一种颜色就把整个区域记为该方的地。def score_board(board): visited set() scores {BLACK: 0, WHITE: 0} for x in range(BOARD_SIZE): for y in range(BOARD_SIZE): if board[x][y] ! EMPTY or (x, y) in visited: continue stack [(x, y)] area set() border set() while stack: px, py stack.pop() if (px, py) in area: continue area.add((px, py)) for nx, ny in neighbors(px, py): v board[nx][ny] if v EMPTY: stack.append((nx, ny)) else: border.add(v) visited | area if len(border) 1: scores[border.pop()] len(area) return scoresborder记录该空域的相邻颜色集合只有一种颜色时这个区域划给对方。既碰黑又碰白的空域按公共地带处理不计分。这个近似规则在双方已经把所有死子提干净的正常对局里结果和正式规则差得不多唯一的问题是死子没提干净时会把死子误当领地属已知局限源码演示可接受。4.4 配置化对局参数棋盘路数与贴目对局规则和渲染参数分散在全局变量里是这个小项目最难受的维护点。9 路棋盘的角上规律、13 路的星位、19 路的默认贴目都不一样把它们收敛进一个 dataclass 是常见做法from dataclasses import dataclass dataclass class GameConfig: board_size: int 19 komi: float 6.5 # 贴目黑贴 6.5 目 enable_ko: bool True # 是否启用打劫规则 time_limit: int 0 # 0 表示不限时komi是黑棋先手优势的补偿正规对局常见贴 6.5 目或 7.5 目用float而不是int是为了制造半目胜避免平局。9 路棋盘的贴目通常比 19 路小常见取 5.5。enable_ko可以让你在做玩法实验时临时关掉打劫方便单独验证提子逻辑。渲染层的BOARD_SIZE、grid_surface都改为从config.board_size读取主循环就干净了。5. 一个能下到终局的 CPU 对手与源码打包注意点5.1 一个轻量级 AI先看吃子再看空点Pygame 围棋源码里最常见的 AI 是“量力打分式”对每个空点模拟落子模拟后评估当前棋盘的得失选得分最高的一手。这个策略天然兼顾了吃子和占地代码量远小于蒙特卡洛对上不熟悉劫的玩家已经能打。def evaluate_position(board, color): opp WHITE if color BLACK else BLACK score 0 for x in range(BOARD_SIZE): for y in range(BOARD_SIZE): if board[x][y] ! EMPTY: continue me sum(1 for nx, ny in neighbors(x, y) if board[nx][ny] color) enemy sum(1 for nx, ny in neighbors(x, y) if board[nx][ny] opp) score me * 2 - enemy return score def ai_choose(board, color, captures, history): opp WHITE if color BLACK else BLACK best None best_score -10**9 for x in range(BOARD_SIZE): for y in range(BOARD_SIZE): clone [row[:] for row in board] caps captures.copy() if not make_move(clone, x, y, color, caps, history): continue score caps[color] * 30 evaluate_position(clone, color) if score best_score: best_score, best score, (x, y) return bestcaps[color] * 30把“吃子”的权重调得远高于一般占点数值 30 是经验值想换风格可以把权重降到 5AI 就偏向扩张而不是战斗。这个 AI 每步要遍历全部空点并做一次全盘克隆9 路棋盘响应很快19 路会有一点延迟属于可接受范围。5.2 wheel 编译错误与单文件打包源码包发给别人最常见的第一道坎是error: failed to build pygame when getting requirements to build wheel。出现这个不一定是你代码的问题而是 pip 没找到适配当前 Python 版本的预编译 wheel回退到源码编译而本机又缺编译依赖。处理顺序很简单python -m pip install --upgrade pip setuptools wheel pip install pygame --only-binary :all:--only-binary :all:的意思是“只要预编译包不要源码编译”。如果这行命令直接报没有可用版本说明当前 Python 版本太新或平台没有对应轮子可以换成社区维护的pygame-ce它对新版 Python 的适配通常更快。打包分发用 PyInstallerpip install pyinstaller pyinstaller --onefile --windowed --name weiqi main.py--windowed让运行时不再弹出黑色控制台--onefile生成单文件但首次启动会慢一些。只要代码里不依赖外部图片和字体文件这个打包流程几乎不会翻车。5.3 按快捷键自检规则层最后给出一组自检路径比临时乱点更有效动作操作预期结果普通落子鼠标左键棋子吸附到最近交叉点提子人为围住对方一块棋对方整块消失提子数增加禁着点在己方无气点落子落子被拒绝棋盘无变化打劫提劫后立刻回提回提被拒绝终局连续按两次空格弹出计分结果对局里出现诡异行为时按顺序查三件事try_move有没有先提子再查自己气make_move的历史集合是否每次落子后都更新passes计数器是否在成功落子后被清零。多数规则层 bug 都逃不出这三条。本文还有配套的精品资源点击获取