ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Pygame从零实现乒乓球游戏:物理模拟与AI策略实战

用Pygame从零实现乒乓球游戏:物理模拟与AI策略实战 简介一款用 C 编写的乒乓球桌面游戏内置人机对战与双人对抗两种模式玩家分别通过一组键盘按键控制适合对游戏开发感兴趣的初学者进行项目拆解。压缩包共包含五十二个文件总大小约二点九一兆字节涵盖源代码、头文件、编译中间文件、可执行程序、调试信息以及位图背景等既可直接运行体验也可对照源码分析实现逻辑。目前已有八百二十五人下载学习具备一定参考热度。这款游戏借助双缓存技术减少重复绘制保证球体快速运动时画面流畅球体行为由独立的球模块处理涉及运动轨迹、碰撞检测与反弹规则配合简单的人机 AI 与实时对抗形成了完整的交互闭环。通过这份工程读者可以学习到双缓存渲染、键盘输入处理、基础物理模拟和游戏循环设计等关键内容也可以直接运行可执行文件感受成品效果。1. 乒乓球游戏别以为它简单这是物理模拟与AI策略的最小闭环很多人觉得乒乓球游戏不过就是两块拍、一个球、来回弹花一晚上就能写完。但真到动手的时候你会发现最简单的规则里全是决策球拍按什么速度移动球的反弹角度怎么变电脑对手凭什么判断落点这些问题的答案恰好覆盖了坐标系统、碰撞检测、帧率与物理解耦以及一个能让玩家愿意继续打下去的AI策略。下面我会用 Python Pygame 把这个项目从零实现到能跑把AI对手的三档难度参数和五个常见坑都交代清楚。适合刚入门 Python 想从命令行跨到图形程序的人也适合正在找“最小但不玩具”的强化学习练手题的从业者。2. 从零到能玩Pygame跑通乒乓球的三个关键选型2.1 为什么选Pygame而不是Turtle或Godot开发效率、物理与迭代速度选框架先看目标。乒乓球这个项目需要的图形能力只有三种画矩形、画圆、显示文字需要的交互只有键盘需要的物理只有二维直线运动的碰撞。用 Turtle 写也能玩但 Turtle 的绘图循环自带刷屏延迟要额外靠 time.sleep 去控制节奏帧率一旦不稳球的位移就完全变成玄学。用 Godot 是另一条路只是导入引擎、建场景、写 GDScript、处理导出资源对一个只有两个矩形加一个圆的游戏来说学习成本超过了项目本身。Pygame 正好卡在中间它保留事件循环和矩形碰撞这些“游戏引擎最小集”又不会替你管理精灵和场景核心逻辑全都摊在桌面上。我一般会把它定位成“能动的计算框架”渲染和输入是现成的但物理、策略、状态机都是你自己的。这意味着同一个球运动类可以原封不动搬进后续的强化学习环境。如果你只是想快速玩起来Pygame 的另一层优势是纯 Python 项目没有任何外部资源依赖把 main.py 发给同事他本地装一个包就能跑。可复现成本决定一个项目能不能传下去这是技术选型里我最看重的一条。对比项TurtlePygameGodot渲染循环由 turtle 内部维护帧率控制很粗糙自建主循环clock.tick 精确锁帧场景树循环控制粒度较粗矩形碰撞无内置需要手写坐标比较Rect.colliderect 直接用有 Area/CollisionShape但要配场景键盘输入onkey 只能绑定单键get_pressed 支持多键同按有 Input 单例多一层映射项目体积极小难以扩展一个文件夹即可分发引擎本体数百 MB与 AI/RL 对接逻辑与绘图强耦合逻辑层可独立抽取需要写扩展或走通信接口从这个表能看出Pygame 在“乒乓球”这个尺度上几乎没有多余的负担。别被网上那些“Pygame 是玩具”的说法带偏它只是把引擎该替你做的部分留给了你而乒乓球恰恰是需要你亲手处理这些部分的最小场景。2.2 最小运行环境版本匹配与两行验证命令Pygame 2.x 官方支持 Python 3.8 以上用 3.10 或 3.11 最稳妥后面接 gymnasium 的时候不用再处理老版本 Python 的兼容问题。安装用 pip 即可但我建议养成用 python -m pip 的习惯否则在多 Python 环境里经常出现装到了全局、代码却跑在虚拟环境里的尴尬# 在当前 Python 环境安装 pygame注意 python3 -m pip 而不是裸 pip python3 -m pip install pygame # 验证安装打印 Pygame 版本号与 SDL 支持版本 import pygame pygame.init() print(pygame.version.ver) # 输出的字符串就是 pygame 版本号 print(pygame.get_sdl_version()) # 底层 SDL 版本排查兼容问题时用这里有一个容易踩的点新装好的 pygame 在终端里 import 正常但一进游戏就报 “pygame.init() failed”多数是音频设备驱动初始化失败。解决方法是先看 init 返回的元组哪个模块失败或者只初始化当前需要的模块# 只初始化视频与字体模块音频后面单独处理 pygame.display.init() pygame.font.init()Pygame 的初始化粒度很细视频、字体、混音器都能独立 init。对乒乓球来说display、font、mixer 三个模块就够了事件系统不需要单独初始化。这一步能避开 Linux 笔记本上音频设备缺失导致的整体初始化失败是环境配置里最值得留的一行。2.3 帧率与球速的基本单位为什么clock.tick(60)不能省球速最直观的单位是“每帧移动多少像素”。比如球速为 6就是指每帧把球的坐标增加 6 像素在 60 FPS 下对应每秒 360 像素。之所以不直接写“每秒像素”是因为我们逐帧做逻辑更新把速度写成每帧距离代码里就不用再乘 dt。但如果你把 clock.tick 注释掉帧率会冲到几百上千球会在你眨眼的瞬间穿过整个屏幕之前调好的所有速度参数都会失去意义import pygame W 960 H 640 FPS 60 pygame.init() screen pygame.display.set_mode((W, H)) clock pygame.time.Clock() # 主循环tick 放在循环末尾保证每帧消耗的真实时间约为 1/60 秒 running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False # 逻辑更新和绘制放这里 pygame.display.flip() clock.tick(FPS)我见过不少新手把 tick 放在循环外或者用 time.sleep(0.016) 代替。time.sleep 的问题在于绘制本身也耗时绘制时间加上 sleep 后实际帧率一定低于 60球的位移单位就漂了。clock.tick 会根据上一帧到当前帧的真实流逝时间自动调整等待时间让循环尽量稳定在目标帧率这是“像素/帧”速度模型的前提。那什么时候该用 dtdelta time而不是固定帧率当帧率可能波动、比如窗口拖动或后台渲染压力大时用 dt 乘以速度可以把位移和帧率解耦。但乒乓球这个项目本身不重渲染固定 60 FPS 足够稳定用固定帧率代码更简单、调参更直观。我的建议是乒乓球就用固定帧率如果以后做需要频繁读写磁盘的游戏再换 dt。参数建议W960、H640 这个分辨率能让球从一端飞到另一端大约有 1.5 秒这是给玩家留出的合理反应窗口。把窗口缩到 640x480球速不变的情况下难度会明显上升因为横向距离变短了。调试时先固定 FPS 和窗口尺寸只动球速百分比是最省时间的做法。3. 实现乒乓球核心逻辑球拍、球速与碰撞反弹的代码细节3.1 球拍移动用键盘状态而不是事件触发避免“按住无效”常见误用是在事件循环里通过 KEYDOWN/KEYUP 累计一个变量比如按下左键时 x 8。这么写的问题是按住键时操作系统会进入按键重复机制Pygame 的 KEYDOWN 在按键保持期间不一定按固定频率触发结果就是球拍移动一顿一顿。对乒乓球这种需要精细跟位的游戏正确做法是每帧读取键盘状态# 玩家用 W / S 控制左拍W 上移S 下移 keys pygame.key.get_pressed() if keys[pygame.K_w]: player_paddle.centery - PLAYER_SPEED if keys[pygame.K_s]: player_paddle.centery PLAYER_SPEED # 把球拍限制在屏幕范围内顶部和底部各留出球的高度余量 if player_paddle.top 0: player_paddle.top 0 if player_paddle.bottom H: player_paddle.bottom H逻辑说明pygame.key.get_pressed() 返回一个所有按键状态的布尔序列按住键时每帧返回 True松开立即变 False不存在按键重复的节流问题。移动量 PLAYER_SPEED 的单位同样是“像素/帧”和球的单位保持一致。边界限制这里有个细节新手的第一个版本通常用 if player_paddle.y 0: player_paddle.y 0这没有错但之后要计算球拍法向、或者给 AI 提供“球拍中心”参考点时直接操作 Rect 的 top/bottom 字段会让后续代码更整洁。Pygame 的 Rect 属性是实时联动的改 top 会自动改 centery调试坐标时非常方便。3.2 球的运动速度分量、上下反弹与角度注入球不要用单一 angle speed 的极坐标方式管理。虽然在带斜面的碰撞里极坐标好算但乒乓球的两侧球拍都是垂直平面x 方向速度的符号表达横向飞行方向y 方向符号表达上下倾向两个分量可以独立翻转。这个模型在碰撞、AI 落点预测、以及后续强化学习的 state 表示里都能直接用import random # 初始化球放到屏幕中心向左飞向 AI 侧y 方向带随机分量 ball pygame.Rect(W // 2 - BALL_SIZE // 2, H // 2 - BALL_SIZE // 2, BALL_SIZE, BALL_SIZE) ball_vx -6 # x 方向初速向左飞单位 像素/帧 ball_vy random.randint(-3, 3) # y 方向随机分量保证开局角度不单调 # 每帧的位移量单独累加 ball.x ball_vx ball.y ball_vy # 上下边界反弹碰到边缘把 y 方向速度取反 if ball.top 0: ball.top 0 ball_vy -ball_vy if ball.bottom H: ball.bottom H ball_vy -ball_vy逻辑说明ball.x 和 ball.y 是 Rect 的整数属性直接做移位会把浮点位移截断成整数长期累积下来球会漂移尤其是 AI 落点预测用的是浮点坐标两者混用是常见 bug 来源。我一般会额外维护一对浮点坐标 ball_fx、ball_fy每帧先加浮点速度再赋值给 Rect 用于渲染和碰撞。这个“浮点逻辑坐标 整数渲染坐标”的分离做法在碰撞密集的游戏里能省掉大量难以定位的偏移问题。3.3 碰撞检测colliderect的边界修正与sub-step方案Pygame 内置的 Rect.colliderect 是 AABB 相交测试对乒乓球这种轴对齐矩形够用。但直接每帧检测“球是否碰到拍”再翻转方向会有两个典型问题穿拍和吞拍。穿拍问题在第5章细说这里先给最小可用版本# 左侧玩家球拍接球 if ball.colliderect(player_paddle) and ball_vx 0: # 只处理向左飞的球避免反弹后再次触发同类碰撞 ball.left player_paddle.right 1 # 强制推到拍外防止下一帧再次 collide ball_vx -ball_vx # 反转横向方向 ball_vy random.randint(-2, 2) # 注入随机角度防止节奏单调 ball_vy max(-MAX_VY, min(MAX_VY, ball_vy)) # 限幅避免角度失控逻辑说明ball_vx 0 这个条件很关键。反弹之后 ball_vx 变成正数即使球与拍仍有相交下一帧也不会再反转一次这个判断直接消除了大半吞拍抖动。ball.left player_paddle.right 1 是把球“推出”拍体代价是这一帧少走几个像素视觉上基本看不出但物理上保证下一次检测时球已经和拍分离。如果球速继续调高超过球拍厚度的一半上面的方案还是不够。这时用细分步长把一帧位移拆成若干小步每步都做碰撞检测# 每帧最多拆 4 步超过 16 步反而会出现浮点截断误差 steps 4 step_x ball_vx / steps step_y ball_vy / steps for _ in range(steps): ball.x step_x ball.y step_y if ball.colliderect(player_paddle): ball.left player_paddle.right 1 ball_vx -ball_vx break细分步长不算真正的连续碰撞检测但对 960x640 这个尺度、球速在 12 以下完全够用。步数设太多反而有问题拆 16 步时每步位移只有 1 像素以下Rect 的整数属性会把这部分截断导致球看起来不走。我一般在球速从 8 提到 12 时开 4 步再往上就同时限制球速上限不要硬靠细分步长兜底。3.4 得分、重置与局间状态完整可运行核心代码到这里一个双人同键盘对战版的核心逻辑可以拼出来。完整代码贴在下面复制即可运行左侧用 W/S、右侧用方向键import pygame import random W, H, FPS 960, 640, 60 BALL_SIZE 16 PADDLE_W, PADDLE_H 12, 90 PLAYER_SPEED 8 MAX_VY 6 pygame.init() screen pygame.display.set_mode((W, H)) pygame.display.set_caption(Pong Game) clock pygame.time.Clock() player pygame.Rect(30, H // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) ai pygame.Rect(W - 30 - PADDLE_W, H // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) ball pygame.Rect(W // 2 - BALL_SIZE // 2, H // 2 - BALL_SIZE // 2, BALL_SIZE, BALL_SIZE) ball_vx, ball_vy -6, random.randint(-3, 3) score_human, score_ai 0, 0 font pygame.font.SysFont(Arial, 28) def reset_ball(): global ball_vx, ball_vy ball.center (W // 2, H // 2) # 随机决定开局方向先让上一局丢分方发球这里简化为随机 ball_vx -6 if random.random() 0.5 else 6 ball_vy random.randint(-3, 3) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False keys pygame.key.get_pressed() if keys[pygame.K_w]: player.centery - PLAYER_SPEED if keys[pygame.K_s]: player.centery PLAYER_SPEED player.clamp_ip(screen.get_rect()) # 右侧球拍暂时用方向键控制实现双人对战 if keys[pygame.K_UP]: ai.centery - PLAYER_SPEED if keys[pygame.K_DOWN]: ai.centery PLAYER_SPEED ai.clamp_ip(screen.get_rect()) # 球位移 ball.x ball_vx ball.y ball_vy if ball.top 0: ball.top 0 ball_vy -ball_vy if ball.bottom H: ball.bottom H ball_vy -ball_vy # 玩家侧碰撞 if ball.colliderect(player) and ball_vx 0: ball.left player.right 1 ball_vx -ball_vx ball_vy random.randint(-2, 2) ball_vy max(-MAX_VY, min(MAX_VY, ball_vy)) # AI 侧碰撞 if ball.colliderect(ai) and ball_vx 0: ball.right ai.left - 1 ball_vx -ball_vx ball_vy random.randint(-2, 2) ball_vy max(-MAX_VY, min(MAX_VY, ball_vy)) # 得分与重置 if ball.left W: score_human 1 reset_ball() if ball.right 0: score_ai 1 reset_ball() screen.fill((20, 20, 30)) pygame.draw.rect(screen, (200, 200, 200), player) pygame.draw.rect(screen, (200, 200, 200), ai) pygame.draw.ellipse(screen, (255, 255, 255), ball) score_text font.render(f{score_human} : {score_ai}, True, (255, 255, 255)) screen.blit(score_text, (W // 2 - 30, 20)) pygame.display.flip() clock.tick(FPS)几个说明reset_ball 里用随机方向发球作为双人游戏没问题clamp_ip 把 Rect 限制在窗口范围内替代手写边界判断得分文本的 W//2-30 是粗略居中不同字体宽度会有几个像素偏差不影响功能。这个版本已经能两个人玩但很多人要的是一个能单机练习的电脑对手下一章就把右侧球拍换成会预判球路的 AI。4. 让电脑陪你练球乒乓球AI对手的预测算法与三档难度4.1 追踪拍还是预测拍没有AI的乒乓游戏玩不过三分钟给右侧球拍写 AI最快的方法是“追踪拍”每帧把 AI 拍中心往球当前的中心移动移动量受限if ball.centery ai.centery: ai.centery - AI_SPEED elif ball.centery ai.centery: ai.centery AI_SPEED追踪拍的优点是代码只有三行缺点是 AI 永远在追球屁股。球刚离开玩家拍面时它可能还在中间等球到了右侧它才刚启动球速一快它永远慢半拍。结果就是玩家打两个角度就能把 AI 调动得来回奔波赢起来没有成就感。真正让乒乓球 AI “像个人”需要的是预判在球还没飞到拍面前就计算落点提前把拍挪到那个位置。追踪与预测的差别本质上代表了两种策略思想反应式控制与基于模型的预测。在游戏 AI 里预测拍的做法更接近人类选手“看球的轨迹找落点”的直觉也是后面能拿来接强化学习环境的基础。4.2 预测落点用球速比换算剩余时间算出target_y预测的前提是球在两次碰撞之间的运动是匀速直线运动乒乓球两拍之间没有其他障碍这个前提成立。算法分三步计算球到 AI 拍面的水平距离 dx ai.left - ball.right用 dx 除以球的 x 方向速度得到到达时间 t dx / ball_vx预测球的 y 坐标target_y ball.centery ball_vy * t。线性预测没有考虑上下墙反弹。如果球在飞行途中会碰上下边缘预测值就会有偏差。处理方式有两种一是只处理“不会碰墙”的球二是写一个小仿真函数逐帧外推直到球到达 AI 拍面返回当时的 y 坐标。第二种更贴真实物理而且容易调参def predict_target_y(ball, ball_vx, ball_vy, ai_left_x, step_limit240): # 模拟未来 240 帧用浮点坐标逐帧外推返回球到达 AI 拍面时的 y 中心坐标 fx, fy ball.centerx, ball.centery vx, vy ball_vx, ball_vy for _ in range(step_limit): fx vx fy vy if fy 0 or fy H: vy -vy # 球碰到上下边缘翻转 y 分量 if fx ai_left_x: return fy return H // 2 # 超时未到回中场等待逻辑说明这个仿真最多迭代 240 帧按 60 FPS 相当于 4 秒足够覆盖球从一端到另一端的完整飞行。用浮点坐标 fx/fy 累加避免 Rect 整数截断导致预测位置偏移。碰到上下墙只翻转 vy与游戏里的反弹规则一致。预测结果交给 AI 拍class AIPaddle: def __init__(self, max_speed6, dead_zone8, error10, reaction_frames4): self.y H // 2 self.max_speed max_speed self.dead_zone dead_zone # 误差小于它就不动防止高频抖动 self.error error # 预测误差像素数随机加减 self.reaction_frames reaction_frames # 每 N 帧重新预测一次模拟人眼观测节奏 self._frame_counter 0 def update(self, ball, ball_vx, ball_vy, ai_left_x): self._frame_counter 1 # 隔 reaction_frames 帧才重新计算目标数值越大反应越迟钝 if self._frame_counter % self.reaction_frames ! 0: return raw_y predict_target_y(ball, ball_vx, ball_vy, ai_left_x) target_y raw_y random.randint(-self.error, self.error) diff target_y - self.y if abs(diff) self.dead_zone: return step self.max_speed if diff 0 else -self.max_speed self.y step self.y max(0, min(H, self.y)) # 边界钳制参数说明max_speed 是 AI 每帧最多移动的像素决定它接高速球的极限dead_zone 是目标点与当前位置的差值阈值小于阈值不动防止 AI 在目标点附近来回抖error 是人为加在预测点上的随机偏差模拟人类判断误差reaction_frames 是每隔多少帧重新计算一次预测点数值越大 AI 反应越迟钝。这四个参数互相制约是三档难度的基础。4.3 三档难度参数表反应延迟、最大速度与随机误差的调配我持续调了几十局后总结出一组比较稳的默认参数难度max_speeddead_zoneerrorreaction_frames效果简单4153010反应慢预判偏差大适合热身普通68104能接住大部分斜线球水平略低于真人困难8301追得上高速球预判准确需要靠角度调动简单档的 30 像素误差意味着预测点可能偏出一个球拍高度球拍 90 像素高它接不住刁钻球是正常现象。普通档的 error 10 和 reaction_frames 4 的组合比较平衡玩家大概有一半的球需要用角度拉扯才能拿分。困难档的 dead_zone 收紧到 3AI 几乎钉在预测落点上此时唯一的破绽是球在击球瞬间的随机角度注入这也符合真实比赛里“变线”战术的意义。值得单独指出的是困难档不是把 max_speed 拉到 10 就完事。AI 每帧最多移动 8 像素而球的横向速度通常也在 6 到 8二者数值接近就会出现 AI 拍到位但球已经穿过拍面的情况渲染上就是“拍子追着球但永远差半个身位”。真正的困难是靠 reaction_frames1 和 error0 实现的“全知型预判”而不是无脑高速。5. 乒乓球游戏常见问题排查穿透、乱码、音频失效与CPU飙高以下五个问题里前两个属于碰撞物理后三个是渲染与环境的坑都是我实际跑这个项目时反复踩过的。每条按现象、原因、解决三个步骤写直接照做就能定位。5.1 球速一快就穿透球拍离散碰撞检测的上限现象把球速从 8 提到 12 之后球经常“穿”过球拍飞到对面拍子看起来碰到了球但球不反弹。原因每帧只做一次碰撞检测等价于默认球在这一帧内只移动了很小距离。当球速是 12一帧位移 12 像素而球拍厚度也是 12 像素时球在某帧的起点可能在拍左、终点在拍右两帧之间与拍没有相交就穿过去了。球速大于球拍厚度是穿透的临界条件。解决优先限制球速上限把每帧位移控制在拍厚的 70% 以内需要更快节奏时用细分步长每帧拆成 4 步steps 4 step_x ball_vx / steps step_y ball_vy / steps for _ in range(steps): ball.x step_x ball.y step_y if ball.colliderect(ai) and ball_vx 0: ball.right ai.left - 1 ball_vx -ball_vx break注意拆分后每步位移变小但如果拆得太多浮点增量赋给 Rect 整数属性时会连续截断球反而跑得更慢。4 步是 960x640 窗口下的经验值换小窗口可以改成 2 步。5.2 反弹一瞬间被卡进拍体连续collide把方向反复反转现象球碰到拍子后有时会停在拍子里剧烈抖动或者反弹一次后又被“吸”回拍内最终向错误方向飞出屏幕。原因反弹逻辑只在碰到时把 vx 取反但球位置还在拍体内。下一帧球从拍体内部开始移动colliderect 又检测到相交vx 又取反来回震荡一两次后位置和速度就全乱了。解决反弹后立刻把球推到拍外并加上“只处理朝拍方向飞行的球”的判断if ball.colliderect(player) and ball_vx 0: ball.left player.right 1 # 强制脱离拍体 ball_vx -ball_vx这个“先推离再翻转”的做法本质上是模拟物体接触后的形变分离。球速很高时推离距离大视觉上会看出球跳了一小步但总比穿透好。还有一个更稳妥的办法记录“这个拍子的最后触碰帧”之后 2 到 3 帧内跳过该拍子的碰撞检测给球一个安全的离场期。5.3 中文标题与中文界面乱码Pygame的字体与编码坑现象pygame.display.set_caption(乒乓球游戏) 后窗口标题显示为乱码或方块游戏内用 SysFont 渲染中文分数也出豆腐块。原因Pygame 的窗口标题走 SDL 的本地化处理与操作系统代码页有关而 SysFont 默认使用 pygame 自带的 freesansbold.ttf这个字体文件没有中文字形渲染中文自然变成方块。解决窗口标题用英文最省心游戏内文字选用系统内置中文字体# 先列出系统里可用的中文字体名 fonts pygame.font.get_fonts() cn_candidates [f for f in fonts if any(k in f for k in (noto, simhei, yahei, pingfang))] print(cn_candidates) # 取第一个可用字体渲染中文 font pygame.font.SysFont(cn_candidates[0] if cn_candidates else arial, 28)注意 SysFont 的字体名是逻辑名不是文件路径不同操作系统差异很大。Windows 上有 simheimacOS 上有 pingfangLinux 常见 notosanscjksc。先过滤再选能在换电脑时不至于界面变成一片方块。这是典型的“本地能跑、换台机器就黑脸”问题。5.4 音效播放一半没声mixer初始化顺序与音频格式现象代码里 Sound(path).play() 不报错但没有声音或者第一次播放有连续播放第二次开始变哑。原因旧版 Pygame 的 mixer 初始化默认只有 8 个 channel如果同一时间播放多段音效占满 channel后面的声音会被直接丢弃另外 mp3 在部分 SDL 版本解码不稳定wav 最稳。解决先初始化 mixer 再 init 整个 pygame限制 channel 数播放前显式占位pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init() pygame.mixer.set_num_channels(4) hit_sound pygame.mixer.Sound(hit.wav) # 在碰撞逻辑处播放固定 channel 0保证不会占用过多 channel pygame.mixer.Channel(0).play(hit_sound)pre_init 必须在 pygame.init() 之前调用才能生效44100 采样率、-16 位有符号、双声道、512 缓冲是 SDL 的经典配置兼容性最好。音效文件使用英文路径别放带中文的目录下。还有一个小坑channel 用完后 Sound.play() 会静默失败set_num_channels(4) 之后让每个音效固定走一个 channel问题就消失了。5.5 窗口切后台后CPU占用飙高渲染循环没有“让权”现象游戏窗口最小化或切到其他程序后风扇开始转任务管理器里 Python 进程 CPU 占用接近 100%。原因主循环里的 display.flip() 和 clock.tick(60) 在窗口失焦后依然执行SDL 在后台持续做双缓冲更新等于每秒钟还在白跑 60 帧。解决监听窗口失焦状态失焦时只做事件轮询不做逻辑更新也不渲染while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False # 窗口失焦时不更新逻辑让出线程避免 CPU 空转 if not pygame.key.get_focused(): pygame.time.wait(50) continue # 归位后恢复正常逻辑 # ... 主循环其余部分pygame.key.get_focused() 返回 False 表示当前窗口不接收键盘事件用 wait(50) 把线程让出去。这个处理顺带解决另一个体验问题调试代码切到编辑器时游戏立刻安静下来回到窗口马上恢复后台不再空转。6. 把乒乓球游戏变成强化学习训练场reset与step接口改造6.1 去掉渲染的PongEnvreset与step的最简接口我最终做这个项目是为了拿它验证简单的强化学习策略。Pygame 的渲染部分对训练没有任何贡献反而拖慢迭代速度。常见做法是把核心逻辑抽出来去掉绘制只保留状态更新、碰撞和得分得到一个纯 Python 的 PongEnvimport random class PongEnv: def __init__(self, seed42): random.seed(seed) self.reset() def reset(self): # 状态向量球坐标、球速、两侧拍中心 y 值 self.ball_x, self.ball_y 480.0, 320.0 self.ball_vx random.choice([-6, 6]) self.ball_vy random.uniform(-3, 3) self.paddle_y 280.0 self.ai_y 280.0 return self._state() def step(self, action): # action 取值 -1/0/1对应球拍向上/不动/向下 self.paddle_y max(0, min(H - PADDLE_H, self.paddle_y action * 8)) # AI 侧用上一章的 AIPaddle 更新这里省略 self.ball_x self.ball_vx self.ball_y self.ball_vy done False reward 0 if self.ball_x 0: reward, done -1, True elif self.ball_x W: reward, done 1, True return self._state(), reward, done def _state(self): # 用浮点状态代替 Rect保证训练环境与渲染解耦 return (self.ball_x, self.ball_y, self.ball_vx, self.ball_vy, self.paddle_y, self.ai_y)这里用到的 W/H/PADDLE_H 是第3章的全局常量抽取时一并带过来即可。reset 返回一个 6 维状态向量step 只接受一个动作整数这个接口与 gymnasium 的规范一致后面嵌套进 StandardEnv 不需要改游戏逻辑。reward 设计为“丢球 -1、得分 1、存活不加分”稀疏但明确。6.2 固定随机种子验证两个策略对比时只看接住率耦合了 pygame 时钟的游戏无法复现同一局球路代码一旦改动之前的对比数据全部作废。把逻辑层抽出来之后用 random.seed(42) 固定开局方向与随机速度同一策略跑 N 局就完全可复现。验证 AI 策略不要只看赢多少分统计接住率更可靠# 玩家策略每帧朝球的 y 方向移动称为跟随策略 def follow_policy(env_state): _, ball_y, _, _, player_y, _ env_state if ball_y player_y: return 1 if ball_y player_y: return -1 return 0 total, caught 0, 0 for _ in range(100): env PongEnv(seed42) state env.reset() done False while not done: act follow_policy(state) next_state, reward, done env.step(act) total 1 caught reward state next_state print(f接住率: {caught / total:.2%})这套验证习惯是我在做这个项目时养成的所有修改都必须能通过“固定种子复跑”这一关不能复现的改动一律不顺。做过一次去渲染化之后你会意识到 Pygame 在这套方案里只是一个显示设备真正承载游戏的可复用部分是纯 Python 逻辑层。我最大的教训就是一开始不要把显示和逻辑写死在一起否则每次想换训练方式都要重写一遍太费时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表