
1. 做这个转盘之前先别急着写 pygame 代码如果你接到一个“写个运气类小游戏”的需求或者学完 Python 基础想找个有画面感的练习项目用 pygame 一步步实现一个可视化的幸运大转盘是我很推荐的方向。转盘这东西表面看着就是个“会转的圆”真正拆开以后它牵扯到图形绘制、角度计算、动画状态切换、结果判定这一整套逻辑是一个能把基本功和工程意识一起练到的小项目。这个项目最终能做什么呢运行起来就是一个完整的抽奖程序屏幕上有一个划分成多个奖项扇区的圆盘点击“开始抽奖”按钮转盘开始旋转并逐渐减速最后停在某个扇区上程序告诉你中了哪一项。整个过程全部可视化代码量不大但看完这篇以后你能带走的不只是一份能跑的代码还有一套“以后遇到类似小游戏需求都适用的拆解方法”。适合谁来读呢有两种人一是刚学完 Python 语法、想通过项目巩固 pygame 和基础数学知识的初学者二是活动运营、宣传展示等相关场景里需要快速做出一个可演示抽奖工具的人。前者能在这里看到每一步为什么这么写后者能直接拿到完整脚本改改参数就上线。我会按自己实际开发的顺序来讲从空白窗口一直写到完整版本途中所有容易翻车的地方都会单独拎出来说。1.1 一张转盘拆开看核心组成到底有哪些很多初学者上来就盯着“怎么画圆”这一点这恰恰是思路最容易跑偏的地方。我习惯先把东西拆成功能块盘体一个圆形的底板决定整个转盘的视觉主体。扇区被均分的若干块扇形区域每块对应一个奖项是转盘的信息载体。标签文字每个扇区内的奖品名称需要跟随扇区角度排布。指针固定在某个方向通常是正上方用来指向最终停留的扇区。抽奖按钮触发旋转的交互入口。旋转动画从高速转动到逐渐减速停止的过程。结果判定根据最终角度算出指针落在哪个扇区并反馈结果。这八个部分里前面五个偏“界面”后面三个偏“逻辑”。我写这种小项目时习惯先做完静态界面、再做动画、最后做判定因为判定依赖角度角度依赖绘制顺序顺序反了你后面会改得很难受。1.2 为什么选 pygame 而不是其他方案做可视化转盘其实有三类常见路线网页方向用 CSS 动画或者 Canvas桌面方向用 Tkinter游戏方向就是 pygame。我选 pygame 的原因很直接它的事件循环机制天然适合做“持续刷帧 鼠标交互”这种实时程序绘图 API 也足够底层你能清楚地看到每一帧做了什么对理解动画本质非常有帮助。另外一点是 python 生态的连贯性。Tkinter 也能画圆但它的动画刷新方式比较别扭很多时候要靠after()去模拟帧循环写起来绕。pygame 的逻辑则非常直观每一帧清屏、绘制、翻页这种模型一旦理解以后做任何小游戏都通用。这个项目里我们用的是 pygame 最核心的那几个 API——绘制多边形、绘制圆形、字体渲染、图片旋转、事件监听覆盖面广但都不复杂练完等于把 pygame 的地基打了一遍。2. 动笔之前坐标系和角度是最容易翻车的地方写转盘真正难的不是 pygame 的绘图函数而是你脑子里的“几何直觉”和屏幕上的“实际坐标”对不上。这一块你要是啃下来后面写代码会非常顺啃不下来程序跑出来你会发现扇区顺序乱七八糟、指针指向不对最后只能靠乱试碰运气。2.1 屏幕坐标系跟数学课上的坐标系不一样我们上学时画的坐标系原点在中间y 轴向上角度逆时针方向为正。pygame 不一样原点在屏幕左上角x 轴向右y 轴向下。这意味着你用math.cos和math.sin去算圆上的点时角度是“顺时针方向”增长的而不是数学里熟悉的“逆时针”。这个差异对转盘来说反而是件好事。转盘的扇区本来就是顺时针排列的从右侧 3 点钟方向开始一路转到 12 点、9 点、6 点所以你只需要把“顺时针为正”的坐标系直接当作基础画出来的扇区顺序就是对的。还有一个容易懵的点在 pygame 的坐标体系下12 点方向对应多少度很多人的直觉是 0 度或者 90 度实际上应该是 270 度也就是 -90 度。这个数字后面做结果判定时要直接用先记下来。我用一个生活类比帮你记住你把钟表平放在桌上3 点方向是 0 度顺时针往下转6 点是 90 度9 点是 180 度12 点就是 270 度。整个转盘的指针我们就固定放在 270 度这个方向。2.2 扇形怎么画用多边形逼近圆弧pygame 里没有直接画扇形的函数。它提供的pygame.draw.polygon可以画任意多边形所以经典做法是把圆心作为第一个点然后沿圆弧每隔一个小角度采样一个点把这一系列点连起来最后再回到圆心。这样画出来的图形就是一个“圆润程度可控”的扇形。采样步长很关键。我一般用 2 度也就是从扇区起始角到结束角每 2 度取一个圆周点然后把圆心和这些点一起丢给polygon。步长如果太大比如一步跨 10 度扇区边缘会有明显锯齿步长太小又没必要比如 1 步 0.5 度八个扇区加起来要多算几千个点白白增加开销。实际上对一个半径 200~300 像素的转盘来说2 度步长视觉上已经非常平滑。2.3 角度取模负数才是真正的坑转盘转起来之后角度会一直累加可能超过 360也可能在某种计算里变成负数。Python 的%运算符对负数会返回正余数比如-30 % 360的结果是 330这个特性在角度处理里特别好用可以放心用% 360把角度归一化到 0~359。真正容易犯的错是在“反推指针落在哪个扇区”的时候把除数方向搞反。后面我会给出完整公式这里先提醒一句结果判定前先拿纸笔把“指针角度、扇区起始角、当前旋转角”三者的关系画出来比你在代码里试错快十倍。别笑我在这个点上真的调试过一小时最后发现就是少写了一个括号。3. 静态盘面一步步画出来这一节先做“不会转的转盘”。静态画对了后面所有动态逻辑才有意义。我建议你专门建一个draw_sector函数不要把所有绘图代码堆在主循环里因为主循环每帧要执行好几遍拆成函数以后调试哪儿出了岔子能马上定位。3.1 初始化窗口和全局参数这一段很固定直接抄就行import math import random import sys import pygame WIDTH, HEIGHT 600, 700 FPS 60 CENTER (WIDTH // 2, 320) RADIUS 260 PRIZES [ 谢谢参与, 五元红包, 一等奖, 十元红包, 二等奖, 再来一次, 三等奖, 小礼品, ] SECTOR 360 // len(PRIZES) FAN_COLORS [ (229, 80, 90), (250, 205, 90), (229, 80, 90), (250, 205, 90), (229, 80, 90), (250, 205, 90), (229, 80, 90), (250, 205, 90), ]这里两个细节说一下一是CENTER的 y 坐标我故意没有设在屏幕正中间而是偏上因为下方要留出按钮和结果显示区域二是SECTOR用360 // len(PRIZES)计算而不是写死 45这样以后你增删奖项数量扇区角度会自动跟着变不会出现 7 个奖项每个 45 度导致转盘空一块的尴尬。3.2 扇区绘制函数draw_sector核心函数就是前面说的“多边形逼近扇形”def draw_sector(surface, center, radius, start_deg, end_deg, color): points [center] for deg in range(start_deg, end_deg 1, 2): rad math.radians(deg) points.append(( center[0] radius * math.cos(rad), center[1] radius * math.sin(rad), )) points.append(center) pygame.draw.polygon(surface, color, points) pygame.draw.polygon(surface, (255, 255, 255), points, 2)start_deg和end_deg是扇区的起始和结束角度。因为上面说过 pygame 坐标系里角度是顺时针增长的所以扇区就是从起始角顺时针铺到结束角。每次画完填充色再描一条白色细边框扇区之间的分界会特别清楚。颜色上我用了红黄交替的方案这种相邻色对比既是幸运大转盘的经典视觉风格又能让玩家一眼看出扇区边界。你完全可以改成马卡龙色系或者公司主题色只要保证相邻扇区颜色不同就行。3.3 文字怎么摆沿半径方向旋转奖项文字不能放在圆心也不能沿水平方向乱摆。合适的做法是放在扇区角平分线上、半径大约 60% 左右的位置并且让文字“顺着半径方向”排布也就是字形从圆心向外延伸。这样不管转到哪个位置文字都跟随扇区一起转动视觉上最像真实的转盘。实现分两步。第一步算文字中心位置mid math.radians(start_deg SECTOR / 2) text_x CENTER[0] RADIUS * 0.62 * math.cos(mid) text_y CENTER[1] RADIUS * 0.62 * math.sin(mid)第二步是旋转文字。先用font.render渲染出文字图片再用pygame.transform.rotate旋转。这里有一个我踩过的实际坑如果不给文字 surface 留透明通道旋转后空白区域会被黑色填充屏幕上会出现一堆黑方块。解决办法是font.render时开启抗锯齿并且不传背景色这样生成的就是带透明通道的 surfacetext_img font.render(prize, True, (255, 255, 255)) rotated pygame.transform.rotate(text_img, -(mid_deg) - 90) surface.blit(rotated, rotated.get_rect(center(text_x, text_y)))旋转角度的公式是-(扇区中心角) - 90。这个公式我当时调试的时候试了好几个符号组合最终确定下来是能让文字顶部朝外、顺着半径方向阅读的。如果你想把文字改成“顶部朝圆心”的方向把-90改成90再加个180就能得到反过来的效果这个你试一下就明白不用死记知道原理就行。3.4 指针和中心装饰指针必须固定在屏幕正上方也就是 270 度方向在任何情况下都不要让它跟着转盘旋转。画法很简单画一个三角形顶点指向转盘边缘内侧def draw_pointer(surface): x, y CENTER tip (x, y - RADIUS 24) left (x - 18, y - RADIUS - 20) right (x 18, y - RADIUS - 20) pygame.draw.polygon(surface, (50, 50, 50), [tip, left, right])中心再画一个圆盖住所有扇区的连接点视觉上会干净很多pygame.draw.circle(surface, (255, 255, 255), CENTER, 34) pygame.draw.circle(surface, (50, 50, 50), CENTER, 34, 3) pygame.draw.circle(surface, (50, 50, 50), CENTER, 10)这一步做完你已经有一个完整的静态转盘了。跑一下代码确认扇区顺序、颜色、文字方向都正常再继续往下做动画这样问题会好定位很多。4. 让转盘转起来动画状态与减速模型静态画面搞定以后接下来的问题就是转盘怎么转、怎么停下来、停下来之后怎么处理。这里我会用一个非常朴素的状态机来管理。4.1 旋转状态管理转盘在一个游戏循环里只有两种大状态空闲和旋转中。空闲时可以点击按钮旋转中要禁止重复点击。实现上我用一个布尔变量spinning来控制空闲状态按钮可点击点击后把spinning置为True并给转盘一个初始角速度。旋转状态每一帧更新角度、衰减速度直到速度低于阈值停表并计算结果。完成状态显示结果文字spinning回到False等待下一次点击。这个结构看起来简单但它是所有小游戏程序都通用的“状态机雏形”。如果你以后做更复杂的游戏只需要把布尔变量换成枚举状态再加一些状态切换条件就扩展成真正的状态机了。4.2 速度衰减模型为什么乘系数而不是减比较笨的做法是每帧给速度减去一个固定值比如speed - 0.1。这样会有一个问题速度从 20 减到 0 的过程中最后几帧明显“滑行”得特别久观感上很像刹不住车。真实转盘应该是“转动惯性 摩擦阻力”也就是每帧乘以一个小于 1 的系数速度下降是等比衰减的快的时候降得多、慢的时候降得少最后平滑停下来。我的做法是speed * math.pow(0.97, dt * 60) rotation_angle (rotation_angle speed * dt * 60) % 360dt是上一帧到这一帧的时间差来自clock.tick(FPS)。为什么要用dt归一化因为如果不做归一化帧率一变转盘的旋转速度和衰减速度都会变。比如在 30 帧每秒的机器上程序会觉得每一帧世界都过去得更久于是转得飞快。用dt归一化以后不管在什么帧率下观感基本一致。衰减系数0.97是我实测比较舒服的一个值从初速度 20 左右减到停住大约要两三秒。喜欢转久一点改成0.98想要快速出结果改成0.95这个参数你完全可以按需调。4.3 点击按钮的交互处理按钮先创建一个矩形区域然后在事件循环里判断鼠标左键是否落在区域内。为了体验好一点我还会在鼠标悬停时换一个更深的颜色button_rect pygame.Rect(WIDTH // 2 - 70, 620, 140, 48) # 事件循环中 if event.type pygame.MOUSEBUTTONDOWN and event.button 1: if button_rect.collidepoint(mouse_pos) and not spinning: speed random.uniform(15, 22) spinning True result_text 初始速度用random.uniform(15, 22)取一个随机值这样每次转动的时间和最终停留位置都不一样游戏才有随机性。这里可以说一个我当时犯过的错最初我把初始速度固定写死成 18结果每次停下来的位置几乎在同一个区域附近。因为衰减系数固定、初速固定转过的圈数也固定结果就是“看似随机实则每次都差不多”。用了随机初速以后这个问题立刻消失了。5. 结果判定指针到底停在哪个扇区这是整个项目里数学味道最重、也最容易被忽略的部分。很多人转盘动画做得很好结果判定却用一堆 if 列表硬凑代码又臭又长。其实一个公式就能解决。5.1 从当前角度反推奖品索引回想我们绘制扇区的顺序第 i 个扇区的起始角度是i * SECTOR局部角度再叠加当前旋转角rotation_angle就是它在屏幕上的实际位置。指针固定在屏幕 270 度方向正上方。那么要判断指针落在哪个扇区本质上就是求指针所在的角度 270 度对应到转盘未旋转时的局部角度是哪一个再把这个局部角度除以扇区大小得到索引。公式如下def get_win_index(rotation_angle): local (270 - rotation_angle) % 360 return int(local // SECTOR) % len(PRIZES)我解释一下为什么是270 - rotation_angle。屏幕上的实际角度等于局部角度加上旋转角那么反过来已知屏幕角度求局部角度就是屏幕角度减去旋转角。指针的屏幕角度是 270所以局部角度是270 - rotation_angle。再用% 360归一化到 0~359最后整除扇区角度就得到索引。因为我画扇区是从 3 点钟方向逆/顺时针排列的所以这个整除的结果天然对应奖项列表的顺序。5.2 边界情况和精度问题有一个边界值得说明如果指针正好压在两个扇区的分界线上int(local // SECTOR)会把它归到下一个扇区也就是角度较大一侧的那个扇区。这个行为其实是符合扇区绘制约定的——扇区的“起始角度”属于当前扇区而终止角度已经不属于了。所以理论上不会出现“两个扇区都算中奖”的歧义你也不需要再额外做容错。但如果你想要更稳妥的判定可以在公式里加一个半扇区偏移让判定值指向每个扇区的中心角而不是起始角。比如local (270 - rotation_angle SECTOR / 2) % 360然后再整除。两者都可以我建议新手用不加偏移的版本因为和绘制逻辑一一对应出了 bug 更好查。6. 完整代码可以直接复制运行的版本前面各节的代码片段全是项目里的实际片段我把它们拼起来就是一个完整的可运行脚本。你新建一个 Python 文件把下面内容整个复制进去有 pygame 环境就能直接跑。import math import random import sys import pygame WIDTH, HEIGHT 600, 700 FPS 60 CENTER (WIDTH // 2, 320) RADIUS 260 PRIZES [ 谢谢参与, 五元红包, 一等奖, 十元红包, 二等奖, 再来一次, 三等奖, 小礼品, ] SECTOR 360 // len(PRIZES) FAN_COLORS [ (229, 80, 90), (250, 205, 90), (229, 80, 90), (250, 205, 90), (229, 80, 90), (250, 205, 90), (229, 80, 90), (250, 205, 90), ] BG (240, 240, 248) TEXT_COLOR (255, 255, 255) BUTTON_IDLE (52, 152, 219) BUTTON_HOVER (39, 120, 178) def get_font(size): for name in [microsoftyahei, simhei, pingfangsc, notosanscjk, wenquanyimicrohei]: font pygame.font.SysFont(name, size) if font: return font return pygame.font.SysFont(None, size) def draw_sector(surface, center, radius, start_deg, end_deg, color): points [center] for deg in range(start_deg, end_deg 1, 2): rad math.radians(deg) points.append(( center[0] radius * math.cos(rad), center[1] radius * math.sin(rad), )) points.append(center) pygame.draw.polygon(surface, color, points) pygame.draw.polygon(surface, (255, 255, 255), points, 2) def draw_wheel(surface, rotation_angle): for i, prize in enumerate(PRIZES): start i * SECTOR rotation_angle end start SECTOR draw_sector(surface, CENTER, RADIUS, start, end, FAN_COLORS[i]) mid_deg start SECTOR / 2 mid math.radians(mid_deg) text_x CENTER[0] RADIUS * 0.62 * math.cos(mid) text_y CENTER[1] RADIUS * 0.62 * math.sin(mid) font get_font(24) text_img font.render(prize, True, TEXT_COLOR) rotated pygame.transform.rotate(text_img, -mid_deg - 90) surface.blit(rotated, rotated.get_rect(center(text_x, text_y))) pygame.draw.circle(surface, (255, 255, 255), CENTER, 34) pygame.draw.circle(surface, (50, 50, 50), CENTER, 34, 3) pygame.draw.circle(surface, (50, 50, 50), CENTER, 10) def draw_pointer(surface): x, y CENTER tip (x, y - RADIUS 24) left (x - 18, y - RADIUS - 20) right (x 18, y - RADIUS - 20) pygame.draw.polygon(surface, (50, 50, 50), [tip, left, right]) pygame.draw.circle(surface, (255, 255, 255), (x, y - RADIUS - 4), 5) def draw_button(surface, button_rect, hovered): color BUTTON_HOVER if hovered else BUTTON_IDLE pygame.draw.rect(surface, color, button_rect, border_radius8) font get_font(24) img font.render(开始抽奖, True, (255, 255, 255)) surface.blit(img, img.get_rect(centerbutton_rect.center)) def get_win_index(rotation_angle): local (270 - rotation_angle) % 360 return int(local // SECTOR) % len(PRIZES) def main(): pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(幸运大转盘) clock pygame.time.Clock() button_rect pygame.Rect(WIDTH // 2 - 70, 620, 140, 48) rotation_angle 0.0 speed 0.0 spinning False result_text while True: dt clock.tick(FPS) / 1000.0 mouse_pos pygame.mouse.get_pos() for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() if event.type pygame.MOUSEBUTTONDOWN and event.button 1: if button_rect.collidepoint(mouse_pos) and not spinning: speed random.uniform(15, 22) spinning True result_text screen.fill(BG) hovered button_rect.collidepoint(mouse_pos) draw_button(screen, button_rect, hovered) draw_wheel(screen, rotation_angle) draw_pointer(screen) if spinning: speed * math.pow(0.97, dt * 60) rotation_angle (rotation_angle speed * dt * 60) % 360 if speed 0.05: speed 0.0 spinning False result_text 恭喜中得 PRIZES[get_win_index(rotation_angle)] if result_text: font get_font(26) img font.render(result_text, True, (229, 80, 90)) screen.blit(img, img.get_rect(center(WIDTH // 2, 580))) pygame.display.flip() if __name__ __main__: main()这段代码在保证完整性的同时我已经把不必要的重复抽取成了函数。get_font这个辅助函数特别注意一下它尝试一串常见中文字体名目的就是解决不同操作系统下默认字体不显示中文的问题。如果你在自己电脑上第一次跑发现中文全是方块优先去检查这个函数有没有找到合适的字体。7. 常见问题与排查技巧实录写这类项目时代码逻辑之外的问题往往比逻辑问题更折磨人。下面几个问题是我自己在开发中真实碰到的整理成速查表你可以直接对照着排。现象常见原因处理办法中文全部显示成方块pygame 默认字体不包含中文字形用pygame.font.SysFont指定系统中文字体名更稳妥的做法是下载一份 ttf 字体用pygame.font.Font(字体文件路径, size)加载文字旋转后出现黑色方块文字 surface 没有透明通道旋转时空白区被黑色填充font.render时开启抗锯齿且不传背景色或者用pygame.Surface((w, h), pygame.SRCALPHA)自行创建透明图层转盘转动一卡一卡主循环没有锁帧或者每帧重复创建字体对象主循环最前面加dt clock.tick(FPS) / 1000.0把字体对象放到循环外创建点击按钮偶尔没反应按钮 rect 在事件处理时还没定义导致引用报错或判断失效在主循环开始前就定义好button_rect事件和绘制共用同一个矩形指针停在两个扇区之间不知道算谁边界判定存在精度模糊感用local // SECTOR整除边界天然归入下一个扇区如果仍不放心加SECTOR / 2半扇区偏移再整除转到后来停特别慢体验拖沓衰减系数设置得太大速度衰减太慢把0.97调成0.95游戏节奏明显变快反之想要更长的悬念效果就调成0.98窗口在高分屏下看起来很糊系统对窗口做了缩放但 pygame 分辨率没变用pygame.display.set_mode创建窗口后在系统显示设置里匹配缩放比例也可以把WIDTH/HEIGHT等比例调大第 5 行的“半扇区偏移”我再多说一句。我的代码里get_win_index用的是整除起始角逻辑上和绘制完全一致一般不需要偏移。但如果你把文字位置看作扇区的“代表点”可能心里会觉得应该按中心角来算。两种写法结果在绝大多数情况下一样只在指针恰好压线时会有细微差别。这个属于个人风格问题用哪种都可以。还有一个外挂级别的调试技巧在旋转过程中打印rotation_angle和get_win_index(rotation_angle)到控制台看最终结果出来之前打印的最后几个值就能确认判定逻辑有没有线性地跟随指针变化。如果发现索引跳变异常回看 5.1 的公式大概率是符号写反了。8. 还能怎么玩几个立竿见影的扩展方向项目做到这里已经是一个完整的可玩状态但离“拿得出手”还差一口气。我推荐几个扩展方向按性价比从高到低排加音效是最快提升质感的方式。用pygame.mixer.Sound加载一个短促的“咔哒”声然后在旋转过程中检测指针是否跨过了扇区边界每次跨过就播放一下。实现思路是每一帧计算当前get_win_index(rotation_angle)和上一帧对比不一样就播放音效。这个机制还能暗示“指针扫过每个格子”配合转速衰减会非常带感。改成“先定结果再转过去”的模式也很值得尝试。做法是先随机抽取一个奖项计算它要达到屏幕 270 度位置所需的目标角度然后从当前角度出发做缓动插值。这种方案的好处是结果可控适合活动场景下你需要人为控制中奖概率的情况缺点是需要额外处理跨圈和方向问题比现在这种“直接衰减”的方案复杂一些。还有就是记录抽奖历史。抽完一次就把本次结果追加到一个列表里界面上显示最近几次的抽取记录。这个需求在实际活动里出现频率极高而且实现成本很低无非是多画一个文本区域而已。再往上可以加“抽奖次数上限”“奖品库存扣减”“同一奖项不能连续中”这类业务规则本质上都是在result_text生成那一行做文章。最后说一点我自己的感受这个转盘项目虽然不大但它把游戏程序里最常见的几个环节——绘制、动画、交互、判定——全部过了一遍。我当初写第一版的时候花时间最多的反而不是 pygame API而是那些“看起来不起眼”的细节中文显示、角度符号方向、旋转后黑边、帧率归一化。这些坑每一个都很小但每个都能耗掉你半小时以上的调试时间。这篇里我把它们全抖出来了你照着走能省下不少冤枉路。如果你是自己练手的初学者我建议拿到完整代码后不要急着跑先把第 3 节的绘制函数、第 5 节的判定公式自己在纸上推一遍再运行对照结果。等你把draw_sector改成一个画 12 个扇区的版本还不出错的时候这个项目就算真正吃透了。