尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PyGame Pong 实战:事件循环、时间步长与碰撞判定

PyGame Pong 实战:事件循环、时间步长与碰撞判定 第一个能跑起来的 PyGame 程序多数人写的就是 Pong。窗口弹出来了球也在动两块板能上下挪但真正上手打两分钟就会发现手感很怪球贴着板边擦过去不反弹、快速球直接穿过球拍、两边分数跳得像抽奖、窗口一动就掉帧。这些不是代码写错了而是事件循环、时间步长、碰撞判定这三件事没有想清楚。下面这份内容围绕一个单文件、无外部素材依赖的 PyGame Pong 展开从环境准备一路写到完整代码中间把参数怎么定、反弹角怎么算、穿透为什么发生逐条拆开。适合刚学完 Python 基础语法、想找一个能立刻看到反馈的小项目练手的人也适合写过几行 PyGame 但总觉得能动但不好玩的人。读完你手上会有一份可以直接复制运行、并且知道每一行为什么这么写的代码。1. 从一个球两块板看这个项目真正练的是什么1.1 Pong 的规则少到可以用一句话说完Pong 的规则确实简单一个方形区域左右各一块竖直球拍球在中间来回弹谁没接住谁丢分。规则简单的好处是它把注意力从玩法设计上挪开了你被迫去面对游戏编程里最底层的那几个问题——输入怎么读、状态怎么更新、画面怎么重画、时间怎么计量。我一般把 Pong 拆成四件事来看。第一件是输入采样键盘按下的状态每一帧读一次而不是等一个按键事件再动第二件是运动积分球拍和球的位置根据速度和经过的时间往前推第三件是碰撞判定球和上下边界、球和球拍之间谁在什么时候碰上了第四件是回合管理球出界之后怎么计分、怎么重新发球、什么时候算赢。这四件事在任何一个动作类游戏里都会重复出现只是规模不同。所以拿 Pong 练手练的不是这个游戏本身是这套骨架。你把它写顺了后面做打砖块、做飞机大战、做横版跳跃都是往同一套骨架里填内容。1.2 为什么很多人的 Pong 能动但不好玩区别就在两个细节上反弹角度和球拍击球点。新手版本通常是球碰到板就把水平速度取反垂直速度原样保留。这样打起来无论你用板的上半部分还是下半部分接球球飞出去的角度都一样玩家没有任何操作空间只能眼睁睁等球过来游戏变成纯反应速度测试。真正耐打的 Pong反弹角度跟击球点相关用板的上沿接球球往上飞用下沿接球球往下飞用正中间接球球几乎水平飞回去。就这么一个改动玩家立刻有了我可以用板的角度去控制球的落点的感觉会主动去移动、去预判游戏才成立。这个思路后面还会反复用到代码里对应的是一个叫offset的归一化偏移量。1.3 这份代码的取向零素材、单文件、可复制我写的这个版本刻意避开了图片和音频文件球拍和球全用pygame.draw.rect画方块文字用系统字体渲染。原因很实际入门阶段最容易卡住的不是逻辑是资源路径。你从网上抄一份代码跑起来报FileNotFoundError: ball.png然后花半小时找图片心态就崩了。单文件还有个好处你可以整段复制进一个pong.py双击或者命令行python pong.py就能跑。等你确认逻辑没问题了再去把方块换成贴图那时候你就是在一个已知能跑的基础上做替换风险小得多。2. 环境准备让 PyGame 真正跑起来的三道关2.1 Python 版本和虚拟环境的选择PyGame 对 Python 版本的要求相对宽松3.8 到 3.12 都能装。但如果你用的是 Windows 上很新的 3.13偶尔会遇到没有预编译 wheel、需要本地编译的情况装起来会很难受。稳妥做法是选一个 3.10 或 3.11 的版本这两个版本上 PyGame 的预编译包最全pip install基本一次过。虚拟环境这件事我的态度是只要你不是在临时容器里跑一次性脚本就一定要建。理由很直接PyGame 会牵出一串依赖而且不同项目对 PyGame 版本的要求可能不一样。全局装的话某天你在另一个项目里需要旧版 PyGame一升级就把之前能跑的项目搞挂了回头还找不到原因。# 建环境Windows 和 macOS / Linux 命令一致 python -m venv .venv # 激活Windows .venv\Scripts\activate # 激活macOS / Linux source .venv/bin/activate激活成功的标志是命令行提示符前面多了一个(.venv)。看到这个前缀后续所有pip install都只影响这个环境。2.2 装 PyGame 并确认它是真的装上了python -m pip install --upgrade pip python -m pip install pygame我习惯写python -m pip而不是直接写pip。看起来啰嗦但它能保证 pip 装到当前这个 Python 解释器下面而不是系统里另一个来路不明的 Python。多环境机器上这个习惯能省下大量我明明装了为什么 import 不到的排查时间。装完之后别急着写代码先用官方示例验证。PyGame 自带一个可以跑的小游戏用它验证最快python -m pygame.examples.aliens能弹出一个飞船打外星人的窗口说明安装、显示驱动、事件循环全部正常。这一步跳过的代价是等你写完两百行代码发现窗口根本弹不出来你要同时怀疑自己的代码和环境排查范围直接翻倍。用python -m pip show pygame可以看到版本号和安装位置再配合一段最简验证脚本把版本号打出来import pygame print(pygame.version.ver) pygame.init() print(pygame.display.get_driver())get_driver()在 Windows 上一般返回windows在 macOS 上返回cocoa。如果这里就报错问题一定在环境而不是逻辑先解决环境再往下走。2.3 编辑器和我到底在哪个解释器里跑用 VSCode 的话最容易踩的坑是右下角解释器没切到虚拟环境。你在终端里pip install pygame成功了但在编辑器里按 F5 运行用的却是系统 Python于是ModuleNotFoundError: No module named pygame。解决办法是按CtrlShiftP打开命令面板执行 Python: Select Interpreter选中路径里带.venv的那一个。PyCharm 相对省心建项目时勾选 New environment using Virtualenv 就行之后新建的解释器会自动绑定。要注意的是如果你先建了项目再手动装包确认一下 PyCharm 的 Terminal 里也有(.venv)前缀别在系统终端里装着玩。还有一个经常被忽略的点工作目录。代码里如果要读写文件相对路径是相对于运行时的当前目录不是相对于脚本所在目录。Pong 这个项目零素材不受影响但这个习惯早点建立起来没坏处。3. 主循环与时间控制别把游戏速度绑死在帧率上3.1 每一帧其实只做三件事PyGame 的主循环结构非常固定不管游戏多复杂骨子里都是这么个形状while True: dt clock.tick(FPS) / 1000.0 # 1. 处理输入事件 # 2. 更新游戏状态 # 3. 重画整个画面有意思的是第二和第三点为什么必须分开。如果你在更新逻辑的时候顺手就往屏幕上画东西短时间看不出问题等画面元素一多就会遇到画到一半状态又变了的诡异现象。把所有绘制集中到最后一步逻辑层和显示层彻底分开这是后面加特效、加多图层时最省事的结构。事件处理这块pygame.event.get()必须在每一帧被调用它不是可选的通知回调。系统会把鼠标键盘消息塞进一个队列你不取队列就会一直涨涨到某个上限系统会认为程序无响应。所以就算你不需要处理任何事件也得把队列清空。3.2 clock.tick 的返回值才是真正的 dt初学的时候很容易写死一个固定值比如dt 1 / 60。这样写在一台稳定的机器上跑起来没问题但换个低性能设备实际帧率掉到 40 帧你仍然按 1/60 秒去推进位置游戏就会明显变慢——球飞得慢吞吞整局时间被拉长。这就是速度绑死在帧率上的典型症状。clock.tick(FPS)做了两件事一是把当前帧的耗时限制在目标帧率之内避免无意义地空转烧 CPU二是返回上一帧实际经过的毫秒数。拿这个返回值除以 1000 得到秒级的 dt用它去乘速度位置推进就和真实时间对齐了。60 帧的机器上 dt 约等于 0.0167 秒30 帧的机器上 dt 约等于 0.0333 秒后者每帧走两倍距离一秒之后两边的总位移是一致的观感上是同样的速度。注意dt 永远是上一帧到这一帧的间隔不是这一帧应有的长度。窗口被拖动、系统弹窗抢焦点、断点调试之后恢复dt 都可能突然变得很大。后面讲穿透问题时这一点会直接引发 bug。3.3 帧率抖动为什么会让手感崩掉有人会问既然用了 dt帧率高低不是没影响了吗关键在于 dt 是变动的而人的手感对单位时间内的响应速度非常敏感。举个具体场景你的代码把球的水平速度设成每秒 400 像素屏幕宽 960。球从中间飞到边上一共 480 像素需要 1.2 秒。这个时间在 60 帧下感觉刚好但在一个偶尔掉到 25 帧的环境里帧间隔变成 40 毫秒鼠标键盘的采样频率跟着降到每秒 25 次玩家按下的那一瞬间到球拍响应之间平均延迟会从 8 毫秒涨到 20 毫秒。这个差距人是能感觉到的表现为我明明按了它就是晚了一点点。所以性能优化在这个小项目里不是炫技是手感的一部分。而 PyGame 的绘制成本主要来自大面积填充和数量众多的blit。Pong 只有几个方块加几段文字screen.fill一次加四五次绘制压力很小60 帧稳稳的。真正会拖后腿的是每帧重新render文字因为字体渲染涉及栅格化成本远高于画一个矩形。后面代码里我加了一个文字缓存字典就是这个原因。4. 球拍手感的核心击球点决定反弹角4.1 先把参数表定下来调参之前先搞清楚每个参数管什么不然就是盲调。下面是我这套数值的起点你可以整套抄走跑顺了再按自己的手感微调。参数取值作用与调整方向WIDTH, HEIGHT960 x 600窗口尺寸。宽高比 1.6横向留足飞行距离FPS60目标帧率也是 dt 的上限参考PADDLE_W, PADDLE_H14 x 100球拍尺寸。高度是核心手感参数PADDLE_MARGIN28球拍到屏幕边缘的距离PADDLE_SPEED620 像素/秒球拍移动速度比球略快BALL_SIZE14球的边长BALL_SPEED_X400 像素/秒发球时的水平初速BALL_SPEED_Y180 像素/秒发球时的垂直初速BALL_ACCEL1.035每次击球后的水平加速倍率BALL_MAX_VX980 像素/秒水平速度上限防止快到无法反应HIT_SPIN260击球点偏移带来的垂直速度增量WIN_SCORE7先到几分算赢这里最关键的两个数是PADDLE_H和PADDLE_SPEED。球拍高度决定容错率100 像素在 600 高的屏幕上占六分之一新手能接住大部分球改成 60 就立刻变硬核。球拍速度必须略高于球的水平速度否则会出现球来了我知道往哪跑但跑不到的挫败感这是很多自写版本打起来别扭的隐藏原因。4.2 反弹角度的归一化算法前面提到击球点决定角度落到代码上是三步。第一步算出球心相对于球拍中心的偏移offset (ball.rect.centery - paddle.rect.centery) / (paddle.rect.height / 2)分母是球拍高度的一半。球心正好在球拍中心时offset是 0球心贴着球拍上沿时offset约等于 -1贴着下沿时约等于 1。这一步的意义是把像素偏移转成无量纲的比例好处是你后面改球拍高度角度映射关系不会跟着乱掉。第二步做个夹紧。理论上球心的偏移不会超过半径加半个球高但帧率抖动或极端位置下可能出现超范围值不做保护就会得到诡异的角度。一行max(-1.0, min(1.0, offset))就够。第三步把比例乘到一个固定增量上加到垂直速度里ball.vy offset * HIT_SPINHIT_SPIN取 260 的时候用上沿接球会让垂直速度多出 -260球明显往上飞用中心接球垂直速度几乎不变球平着回去。这个 260 怎么来的发球时垂直初速是 180水平初速 400夹角大约 24 度击球点拉到极限时垂直速度变成 180 加上 260 约 440水平速度经过几次加速后大致在 500 上下夹角接近 41 度。这个范围既保证能打出刁钻角度又不至于直接垂直飞出去。注意这里是用累加而不是直接赋值。原因是不想让垂直速度每次都从零开始重建那样球在多次对拉之后角度会越来越平回合会拖得很长。4.3 速度上限和 AI 的死区都是在保手感加速倍率BALL_ACCEL我设成 1.035看着很不起眼但复利效应很明显连续对拉 15 次之后水平速度是初始的1.035^15大约 1.67 倍。这个节奏是我反复试出来的——倍数设 1.1五六个回合之后球就快得看不清了设 1.01打到后面还是慢悠悠缺了紧张感。光有加速不够必须有上限。BALL_MAX_VX 980意味着球横穿屏幕最快约 1 秒加上垂直方向的移动实际飞行时间略长。这个速度下玩家从看到球到做出反应大概有 0.3 秒接近人类反应时间的舒适区间。垂直方向也要单独封顶。如果不限制理论上一次极限角度击球能把 vy 打得很高球会沿接近垂直的方向在上下墙之间疯狂弹射玩家根本够不着。BALL_MAX_VY 620保证了垂直分量永远在一个可控范围内。AI 那边有个概念叫死区dead zone。如果 AI 每帧都精确追着球的中心走它会高频抖动看起来像在抽搐而且难度会高到打不过。代码里给它加了 10 像素的死区和 0.68 的速度折扣效果是 AI 会平滑地朝球移动落点判断略有偏差玩家抓得住机会。5. 穿透、粘连、抖动碰撞里的三个高频翻车点5.1 高速球的隧道效应是怎么发生的先说最容易解释也最容易忽略的一个。假设球每秒飞 900 像素帧率 60那么每帧球前进 15 像素。球拍宽 14 像素。如果第 N 帧球在球拍左边 5 像素处第 N1 帧球已经跑到球拍右边 1 像素处在当前帧的位置上这个球从来没有和球拍重叠过。于是colliderect返回 False球直接穿过去了。这不是代码写错了这是离散采样和连续运动之间的固有矛盾名字叫隧道效应tunneling。解法是子步推进把一帧的位移拆成若干小步每一步的距离小于最薄的那个障碍物。max_step BALL_SIZE * 0.5 travel max(abs(self.ball.vx), abs(self.ball.vy)) * dt steps max(1, int(travel / max_step) 1) sub_dt dt / steps for _ in range(steps): self.ball.step(sub_dt) self._hit_paddle(self.player, left) self._hit_paddle(self.ai, right)max_step取球边长的一半也就是 7 像素这个值小于球拍宽度 14。steps加上max(1, ...)是为了保证至少走一步避免球速为 0 时除出问题。最关键的一点是碰撞检测必须放在子步循环内部每一步都查一次。如果只在循环外查一次等于白拆。这个写法还有个附带好处。因为碰撞检测在子步里做球的位置修正是逐步完成的球不会卡在球拍里然后突然被推出去视觉上是球贴着拍面弹开的观感更自然。5.2 只反弹一次方向判断为什么不能省我第一版代码里_hit_paddle是这么写的if self.ball.rect.colliderect(paddle.rect): self.ball.vx -self.ball.vx跑起来就出现了粘连球撞到拍子之后下一帧还在重叠区域里于是速度又被取反一次来回翻球会贴着拍面剧烈抖动几下才飞出去。在慢速球上不明显快速球上就是明显的粘滞感玩家会觉得球吸在板上了。修复的思路是加方向判断只处理正在朝这块板飞过来的情况if side left: if self.ball.vx 0: return self.ball.rect.left paddle.rect.right else: if self.ball.vx 0: return self.ball.rect.right paddle.rect.left左拍的判定条件是vx 0也就是球正在往左飞右拍反过来。这样即使重叠状态持续了几帧也只会触发一次反弹因为反弹之后 vx 方向变了条件立刻不成立。这个小改动是 Pong 从能跑到手感对的分水岭之一。同样的方向判断思路后面做打砖块时判断是从侧面撞砖还是从底面撞砖也是同一套逻辑。5.3 位置修正为什么改速度还不够上面那段代码里有两行容易被当成多余的self.ball.rect.left paddle.rect.right self.ball.rect.right paddle.rect.left这是在重叠发生时把球强制推到球拍外侧紧贴球拍边缘。不做这一步球会以重叠状态飞出去虽然速度对了但视觉上球是陷在球拍里的快速球上尤其明显。还有个细节要注意判定用的是rect.left和rect.right对应球拍内侧的那条边。左拍的右边缘是paddle.rect.right所以球被推到left paddle.rect.right右拍的左边缘是paddle.rect.left球被推到right paddle.rect.left。写反了球会被推进球拍内部然后疯狂反复触发症状和粘连一模一样。上下墙的反弹也要做位置修正道理相同if self.rect.top 0: self.rect.top 0 self.vy abs(self.vy) elif self.rect.bottom HEIGHT: self.rect.bottom HEIGHT self.vy -abs(self.vy)这里用abs()而不是取反号是因为墙是不动的球撞上墙之后垂直方向必然是离开墙的方向。用绝对值比取反更稳不会因为连续几帧都满足条件而反复翻方向。水平方向的球拍碰撞不能用绝对值因为球拍会移动、球可以从任意一侧来必须靠方向判断。6. 计分、发球和状态机给回合划定边界6.1 为什么需要一个 state 字段不加状态也能跑但马上会遇到几个说不清的问题球刚出界、分数还没更新的时候球又被更新了一次导致一局里扣两分发球瞬间球已经在动了玩家没准备好游戏结束后球还在飞分数还在涨。引入一个state字符串把这几种情形显式区分开state含义这个状态下做什么serve等待发球球固定在屏幕中央倒计时归零后切到 playingplaying对局中球按速度推进做碰撞和出界判定over分出胜负冻结球的位置只显示结果和重开提示update里按 state 分支只有playing才推进球if self.state serve: self.serve_timer - dt if self.serve_timer 0: self.state playing return注意serve状态下球拍依然在更新因为玩家需要这段时间去调整站位只是在update里先处理球拍、再判断状态。发球倒计时我给的是 0.9 秒短到不会让人觉得卡长到足够眨个眼确认球的位置。用字符串而不是数字枚举好处是调试的时候print(self.state)直接可读。状态多了之后可以考虑换成常量或者enum但三个状态用字符串完全够。6.2 得分和发球方向的关系球出界的判定用球完全离开屏幕为准而不是碰到边缘就算if self.ball.rect.left WIDTH: self.score[0] 1 self._after_point(1) elif self.ball.rect.right 0: self.score[1] 1 self._after_point(-1)左边球拍是玩家所以球从右边飞出去时玩家得分score[0]加一。同时把下一球的发球方向传成 1意思是朝右飞——也就是朝着刚刚失分的那一方飞过去。这个细节是体育比赛里的常规做法让刚丢分的人有球可接心理上舒服一点。_after_point负责判断这局是否结束def _after_point(self, direction): if max(self.score) WIN_SCORE: self.state over self.ball.vx self.ball.vy 0.0 else: self.start_serve(direction)用max(self.score)一行就涵盖了双方比写两个条件清晰。结束的时候把球速清零球会静静停在屏幕外不会在over画面背景里继续乱飞。6.3 重开逻辑为什么不能只重置分数一个常见的偷懒写法是重开时只把分数归零其他什么都不动。后果是球还停在左边的某个位置速度和方向都是上一局残留的值重开的第一秒完全是混乱的。正确做法是把所有可变状态一次性复位def restart(self): self.score [0, 0] self.player.reset() self.ai.reset() self.start_serve(random.choice((-1, 1)))球拍回到垂直居中分数清零球回到中心并随机选一个发球方向。这种把所有相关状态打包重置的习惯在状态一多的时候尤其重要。等到你给游戏加上连击数、道具、计时器重开就是一个最容易漏项的地方——漏掉一个变量症状往往在几十秒后才出现排查起来很痛苦。顺带一提reset()写成球拍和球各自的方法而不是在restart里直接改rect.centery是为了以后扩展。如果以后加了球拍拖尾效果或者状态标记只需要改reset一处。7. 完整代码与运行说明7.1 单文件完整实现把下面这段存成pong.py和之前建的虚拟环境放在同一目录下命令行运行python pong.py。import random import sys import pygame WIDTH, HEIGHT 960, 600 FPS 60 WIN_SCORE 7 PADDLE_W, PADDLE_H 14, 100 PADDLE_MARGIN 28 PADDLE_SPEED 620.0 BALL_SIZE 14 BALL_SPEED_X 400.0 BALL_SPEED_Y 180.0 BALL_ACCEL 1.035 BALL_MAX_VX 980.0 BALL_MAX_VY 620.0 HIT_SPIN 260.0 BG_COLOR (16, 18, 24) FG_COLOR (230, 234, 242) ACCENT_COLOR (104, 190, 255) AI_COLOR (255, 146, 120) LINE_COLOR (54, 60, 74) HINT_COLOR (96, 104, 120) class Paddle: def __init__(self, x, up_keyNone, down_keyNone, is_aiFalse, colorFG_COLOR): self.rect pygame.Rect(x, 0, PADDLE_W, PADDLE_H) self.rect.centery HEIGHT // 2 self.up_key up_key self.down_key down_key self.is_ai is_ai self.color color def reset(self): self.rect.centery HEIGHT // 2 def update(self, dt, keys, ball): if self.is_ai: self._update_ai(dt, ball) else: self._update_player(dt, keys) self._clamp() def _update_player(self, dt, keys): dy 0 if keys[self.up_key]: dy - 1 if keys[self.down_key]: dy 1 self.rect.centery dy * PADDLE_SPEED * dt def _update_ai(self, dt, ball): dead_zone 10 diff ball.rect.centery - self.rect.centery if abs(diff) dead_zone: step PADDLE_SPEED * 0.68 * dt self.rect.centery step if diff 0 else -step def _clamp(self): if self.rect.top 0: self.rect.top 0 if self.rect.bottom HEIGHT: self.rect.bottom HEIGHT def draw(self, surface): pygame.draw.rect(surface, self.color, self.rect, border_radius4) class Ball: def __init__(self): self.rect pygame.Rect(0, 0, BALL_SIZE, BALL_SIZE) self.vx 0.0 self.vy 0.0 def reset(self, direction): self.rect.center (WIDTH // 2, HEIGHT // 2) self.vx BALL_SPEED_X * direction self.vy BALL_SPEED_Y * random.choice((-1, 1)) def step(self, dt): self.rect.x self.vx * dt self.rect.y self.vy * dt self._bounce_walls() def _bounce_walls(self): if self.rect.top 0: self.rect.top 0 self.vy abs(self.vy) elif self.rect.bottom HEIGHT: self.rect.bottom HEIGHT self.vy -abs(self.vy) def draw(self, surface): pygame.draw.rect(surface, ACCENT_COLOR, self.rect, border_radius3) class Pong: def __init__(self): pygame.init() pygame.display.set_caption(Pong) self.screen pygame.display.set_mode((WIDTH, HEIGHT)) self.clock pygame.time.Clock() font_name microsoftyahei,simhei,arial self.font_big pygame.font.SysFont(font_name, 78, boldTrue) self.font_mid pygame.font.SysFont(font_name, 26) self.font_small pygame.font.SysFont(font_name, 18) self.player Paddle(PADDLE_MARGIN, pygame.K_w, pygame.K_s) self.ai Paddle(WIDTH - PADDLE_MARGIN - PADDLE_W, is_aiTrue, colorAI_COLOR) self.ball Ball() self.score [0, 0] self.state serve self.serve_timer 0.0 self.text_cache {} self.start_serve(random.choice((-1, 1))) def start_serve(self, direction): self.ball.reset(direction) self.state serve self.serve_timer 0.9 def restart(self): self.score [0, 0] self.player.reset() self.ai.reset() self.start_serve(random.choice((-1, 1))) def _hit_paddle(self, paddle, side): if not self.ball.rect.colliderect(paddle.rect): return if side left: if self.ball.vx 0: return self.ball.rect.left paddle.rect.right else: if self.ball.vx 0: return self.ball.rect.right paddle.rect.left half paddle.rect.height / 2.0 offset (self.ball.rect.centery - paddle.rect.centery) / half offset max(-1.0, min(1.0, offset)) self.ball.vx -self.ball.vx * BALL_ACCEL self.ball.vy offset * HIT_SPIN self.ball.vx max(-BALL_MAX_VX, min(BALL_MAX_VX, self.ball.vx)) self.ball.vy max(-BALL_MAX_VY, min(BALL_MAX_VY, self.ball.vy)) def _after_point(self, direction): if max(self.score) WIN_SCORE: self.state over self.ball.vx 0.0 self.ball.vy 0.0 else: self.start_serve(direction) def update(self, dt, keys): self.player.update(dt, keys, self.ball) self.ai.update(dt, keys, self.ball) if self.state over: return if self.state serve: self.serve_timer - dt if self.serve_timer 0: self.state playing return max_step BALL_SIZE * 0.5 travel max(abs(self.ball.vx), abs(self.ball.vy)) * dt steps max(1, int(travel / max_step) 1) sub_dt dt / steps for _ in range(steps): self.ball.step(sub_dt) self._hit_paddle(self.player, left) self._hit_paddle(self.ai, right) if self.ball.rect.left WIDTH: self.score[0] 1 self._after_point(1) elif self.ball.rect.right 0: self.score[1] 1 self._after_point(-1) def _draw_center_line(self): dash_h, gap 14, 12 y 0 while y HEIGHT: pygame.draw.rect(self.screen, LINE_COLOR, (WIDTH // 2 - 2, y, 4, dash_h)) y dash_h gap def _draw_text(self, text, font, color, center): key (text, id(font), color) surf self.text_cache.get(key) if surf is None: surf font.render(text, True, color) self.text_cache[key] surf self.screen.blit(surf, surf.get_rect(centercenter)) def draw(self): self.screen.fill(BG_COLOR) self._draw_center_line() self.player.draw(self.screen) self.ai.draw(self.screen) self.ball.draw(self.screen) self._draw_text(str(self.score[0]), self.font_big, FG_COLOR, (WIDTH // 2 - 90, 90)) self._draw_text(str(self.score[1]), self.font_big, FG_COLOR, (WIDTH // 2 90, 90)) if self.state serve: self._draw_text(准备发球, self.font_mid, HINT_COLOR, (WIDTH // 2, HEIGHT - 62)) elif self.state over: winner 玩家 if self.score[0] self.score[1] else 电脑 self._draw_text(winner 获胜 按 R 重开, self.font_mid, ACCENT_COLOR, (WIDTH // 2, HEIGHT // 2)) self._draw_text(W / S 移动 ESC 退出 R 重开, self.font_small, HINT_COLOR, (WIDTH // 2, HEIGHT - 26)) pygame.display.flip() def run(self): try: while True: dt self.clock.tick(FPS) / 1000.0 for event in pygame.event.get(): if event.type pygame.QUIT: return if event.type pygame.KEYDOWN: if event.key pygame.K_ESCAPE: return if event.key pygame.K_r: self.restart() keys pygame.key.get_pressed() self.update(dt, keys) self.draw() finally: pygame.quit() if __name__ __main__: Pong().run()7.2 跑起来应该看到什么窗口打开后是深色背景中间一条虚线左边一块浅色球拍右边一块偏橙色的 AI 球拍球在正中央停 0.9 秒然后朝一个随机方向飞出。按 W 和 S 控制左拍。球出界后分数立刻更新球回到中间重新发球。任意一方先到 7 分中间显示玩家获胜或电脑获胜按 R 重开。如果窗口一打开就闪退第一步先看命令行有没有异常输出。最常见的是pygame.error: No available video device说明你运行在无图形界面的环境里比如某些容器或远程终端这种情况换本地图形环境跑。如果画面出来了但卡顿先用print(self.clock.get_fps())打一下实际帧率。稳定在 58 到 60 之间是正常的如果掉到 30 以下问题一般在显示驱动而不是代码检查一下是不是在做屏幕录制或者开了高分辨率缩放。7.3 三处刻意写得啰嗦的地方第一处是try / finally包住主循环。游戏退出时的清理动作放在finally里能保证无论是窗口关闭、ESC 键还是异常抛出pygame.quit()都会执行。少了这一步某些系统上会出现进程残留在后台、再启动时报显示设备被占用的情况。第二处是文字缓存的 key 用了id(font)。因为字号不同的三个字体对象是三个不同实例把字体对象本身放进字典 key 会有哈希开销用id更快。这个优化在 Pong 里意义不大但它是个通用套路等你做分数榜、伤害飘字这类每帧变化的文字时会省下可观的性能。第三处是球拍和球都各自持有draw方法而不是把绘制逻辑全堆在Pong.draw里。看起来只是位置挪了一下实际上是在为以后换渲染方式做准备——把方块换成精灵图只改各自的draw主循环一行都不用动。8. 我实际调这个项目时踩的坑8.1 中文字体渲染成方块第一版我用pygame.font.SysFont(arial, 26)渲染准备发球结果屏幕上是一排豆腐块。原因是 arial 没有中文字形字体找不到对应的字形就画占位方块。SysFont的第一个参数支持逗号分隔的候选列表它会按顺序在系统已安装字体里找匹配。写成microsoftyahei,simhei,arial之后Windows 上命中微软雅黑macOS 上会退到 arial 然后中文还是方块——所以跨平台更稳的做法是给 macOS 加上pingfangsc或heitisc之类的中文字体名。想确认系统里到底有哪些字体pygame.font.get_fonts()会返回一个大列表把候选名往里扔试几次就能定下来。8.2 窗口不刷新和未响应这个问题的表现形式是窗口画出来了但内容一动不动标题栏显示未响应。原因几乎一定是主循环里漏了pygame.event.get()事件队列越堆越长。还有一种更隐蔽的情况某个函数里出现了死循环或者input()这种阻塞调用主循环进不去。排查方法是把print打在循环开头看它能打印几次。只打印一次说明卡在第一次循环里一次都不打印说明卡在初始化。8.3 关于浮点坐标的那点事代码里球的坐标用的是rect.x和rect.y而pygame.Rect内部只存整数。也就是说每走一步小数部分都会被截断。单步位移只有几像素的时候误差很小但累计几百帧之后球的真实轨迹和理想轨迹会有一点偏差表现为同一条路径下球的落点偶尔飘一下。真要追求精确可以把位置存成独立的浮点变量渲染的时候再取整self.x float(WIDTH // 2) self.y float(HEIGHT // 2) # 每步self.x self.vx * dt # 渲染前self.rect.center (round(self.x), round(self.y))这样速度计算全程用浮点只有画的时候才取整累积误差就没了。Pong 这个规模下两种写法的差异肉眼几乎看不出但如果你打算把速度和角度做成非常精细的参数这个改法值得提前做进去。8.4 音效和后续扩展加音效的思路是pygame.mixer.Sound配合不带后缀的 wav 文件但这个项目的门槛会因此上升——你得准备素材。零素材的做法是用array或numpy生成一段正弦波再用pygame.sndarray.make_sound转成 Sound 对象。要不要这么做看你但我提醒一点Sound 对象一定要在初始化阶段创建一次不要在每帧的碰撞回调里创建。每次make_sound都会分配一块内存并做格式转换放在游戏循环里会立刻掉帧而且这个 bug 从症状上很难联想到是音频。想继续往下加我建议按这个顺序先加双人对战模式把 AI 那块的输入换成另一套按键这一步能检验你的球拍类抽象是否真的够用然后加轨迹残影用半透明的方块记录前几帧的位置视觉提升很明显而逻辑几乎不动再往后可以给球加上旋转旋转量和击球点挂钩球画成一个小圆或者用旋转角去偏移贴图。每加一层你都会发现前面把逻辑更新和绘制分开的收益在放大。我在这个项目里最深的体会是参数调优花的时间比写代码多得多。最初我以为把速度定几个数字就完事了实际调下来PADDLE_H、HIT_SPIN、BALL_ACCEL这三个数改了不下二十遍每次都要连打十几局才能判断到底变好了还是变差了。所以别急着一次写到位先把整套参数集中放在文件开头改的时候只动那几个常量剩下的交给手去判断。
返回列表