1. 为什么 Pong 是 PyGame 入门的最佳练手项目
Pong 这个东西,1972 年就被做出来了,规则简单到一句话能说清:两边各一块挡板,中间一个球,球碰墙反弹,漏过去对方得分。但我要说,它是一个被严重低估的练手项目。用 PyGame 做 Pong,你能一次性把游戏开发里最核心的几件事全部走一遍——游戏主循环、帧率控制、键盘输入、矩形碰撞检测、状态管理、分数系统、画面渲染。这些概念你在后面做任何游戏都逃不掉,Pong 是把它们压缩到最小规模的载体。
相比之下,很多教程一上来就让人写贪吃蛇或者俄罗斯方块,代码量瞬间到三四百行,新手看着一屏的类和方法,根本分不清哪行在干什么。Pong 的完整实现,去掉注释大概 150 行左右,你能从头到尾读懂每一行的作用。这就是它最大的价值:规模刚好卡在你大脑能完全装载的临界点上。
这篇内容适合三类人。第一类是刚学完 Python 基础语法,知道列表、字典、函数怎么写,但不知道拿这些知识做什么的人;第二类是已经在用 PyGame 但只会照着教程抄,想搞明白"为什么这么写"的人;第三类是想做一个能拿得出手的小作品,用来练手感、做课程作业或者给朋友展示的人。不管你属于哪一类,下面的内容都能直接用。
1.1 弹球的规则内核与最小可行版本
先把游戏拆干净。Pong 的规则内核只有四条:球在场地内运动;球碰到上下边界翻转垂直速度;球碰到挡板翻转水平速度;球越过左右边界时对方加分并重新发球。就这四条,没有任何多余的东西。
那什么是"最小可行版本"?就是能玩、不崩、规则完整,但暂时不加音效、不加菜单、不加 AI。我见过太多人在第一天就想把 AI 对手、道具系统、粒子特效全塞进去,结果写到一半自己绕晕了,项目永远停在半成品状态。靠谱的做法是先跑通上面那四条规则,把球打起来,然后再往上叠功能。
有一个细节值得提前想清楚:球的初始速度方向要不要随机。如果不随机,每次开局球都往同一个方向飞,玩两把就腻了。所以我在serve()里用random.uniform给了一个小范围的随机仰角,同时保证垂直分量不会太小——如果球几乎水平飞出去,玩家会觉得特别无聊,因为挡板几乎不用动。
1.2 环境准备:Python、pip 与 PyGame 安装实录
环境这块,我按最省事的路线说。Python 版本建议 3.9 以上,3.10 和 3.11 我都实测过没问题。Windows 上从官网下安装包,安装时务必勾上 "Add Python to PATH",这一条不勾,后面命令行里敲python会提示找不到命令,很多人卡在这里以为是装失败了。macOS 用官方安装包或者 Homebrew 都行,装完在终端敲python3 --version验证。
装 PyGame 就一行命令:
pip install pygame如果你电脑上同时有多个 Python 版本,用python -m pip install pygame更保险,能确保装到你实际运行代码的那个解释器里。装完写个两行脚本验证:
import pygame print(pygame.ver)能打印出版本号就说明通了。如果报ModuleNotFoundError: No module named 'pygame',九成是 pip 和 python 不是同一个环境,用python -m pip -V看看 pip 挂在哪个路径下,对比一下python -c "import sys; print(sys.executable)",两个路径的父目录不一致就是这个问题。
编辑器方面,VS Code 和 PyCharm 都行。VS Code 需要装 Python 扩展,然后在左下角选对解释器;PyCharm 新建项目时注意选择 "Existing interpreter" 而不是新建一个虚拟环境,否则你会发现 PyCharm 里 import 失败、命令行里却正常。这类环境问题在入门阶段占用了大量时间,我个人的建议是:在跑通第一个游戏之前,不要折腾虚拟环境和包管理工具,用系统解释器最不容易出岔子。
1.3 项目文件结构与设计思路
整个项目我就用一个文件,pong.py。为什么不拆模块?因为拆了之后新手要在三四个文件之间跳转,追踪一个变量的来源都费劲。单文件的好处是所有逻辑一目了然,等你把这版吃透了,再拆成entities.py、settings.py是水到渠成的事。
结构上从上到下分四层:配置常量、工具函数、游戏对象类、主循环。对象只有两个,Paddle和Ball,都是很薄的类,各自管好自己的状态和更新方法。
主循环里我采用一个设计决定,值得单独说:所有与时间相关的位移都乘以dt。这样游戏速度跟帧率解耦,你的电脑跑 60 帧、别人的跑 144 帧,球的移动速度是一样的。如果直接写ball.x += 5,那高刷屏上球会快得没法玩。这个坑我早期踩过,当时以为是电脑性能问题,折腾了半天才发现是没做时间归一化。
2. 核心机制拆解:坐标系、帧率与碰撞判定
这一节讲的是"为什么",是整篇内容里最应该仔细看的部分。很多人代码能跑,但说不清为什么这么写,换个场景就不会举一反三了。
2.1 PyGame 坐标系与 Rect 的取值规则
PyGame 的坐标系跟数学课上不一样:原点在窗口左上角,x 向右增大,y 向下增大。所以y = 0是顶部,y = HEIGHT是底部。这个设定第一次接触会别扭,但习惯之后就好了,因为屏幕渲染本来就是从上往下扫描的。
pygame.Rect是 PyGame 里最重要的工具类,它同时承载四个属性:x、y(左上角坐标)、width、height。基于这四个值,它还能派生出left、right、top、bottom、centerx、centery、center一整套访问器。关键点是:这些属性既可以读也可以写,写right会自动调整x。比如rect.right = 100等价于把矩形右边缘挪到 100 的位置,PyGame 会自动反算x。
这个特性在碰撞后归位时特别好用。球撞到左挡板后,我把ball.rect.left = paddle.rect.right,一句话就把球贴到挡板右侧,不需要自己算坐标。如果用裸的浮点数,你得写ball.x = paddle.x + paddle.width,还得考虑球的半径,容易漏。
另外提醒一点,Rect的坐标是整数。你把浮点数赋给rect.x,PyGame 会直接截断,不是四舍五入。所以如果你在Rect上做物理计算,误差会累积,球会越跑越偏。我的做法是:物理位置用Vector2存浮点,只在渲染和碰撞检测时同步到Rect。
2.2 帧率控制:Clock.tick 与 dt 的正确用法
pygame.time.Clock的tick(fps)方法做了两件事:限制循环每秒最多执行 fps 次,同时返回距离上次调用过了多少毫秒。这是 PyGame 里最容易被误用的 API。
常见的错误写法是把它当纯粹的限速器用:
while running: clock.tick(60) ball.x += 5 # 错误:速度与帧率绑定正确的写法是接住返回值,换算成秒,再乘以速度:
while running: dt = clock.tick(60) / 1000.0 ball.pos.x += BALL_SPEED * dt # 速度单位是"像素/秒"这样一来,BALL_SPEED = 330的含义就是每秒移动 330 像素,语义清晰,跨机器一致。我用"像素/秒"作为所有速度的单位,包括挡板移动速度,这样参数表看起来也统一。
还有一个坑必须处理:窗口拖动或者系统卡顿时,dt会突然变得很大。比如你按住标题栏拖了半秒,下一帧的dt就是 0.5 秒,球会瞬移穿过挡板,游戏直接乱掉。所以我在主循环里加了一行保护:
dt = min(dt, 1 / 30.0)意思是单帧时间最多按 1/30 秒算。这样即使真卡了,球也只是慢了一点点,不会穿模。
2.3 碰撞判定:为什么不能用整数坐标硬碰
最直觉的碰撞写法是判断球和挡板的矩形是否重叠,重叠就把水平速度取反。这个方法在小球低速时能用,但速度一上来就会出问题——穿透。
算一笔账:球的最大速度设为 900 像素/秒,60 帧下每帧移动 15 像素;挡板厚度只有 12 像素。也就是说,某一帧球还在挡板左边,下一帧已经跑到挡板右边了,中间那一帧的"重叠"状态从来没出现过。colliderect返回 False,球直接穿过去,玩家看到的就是"球从板子里钻过去了"。
解决办法有两个方向。一是把球的最大速度压到每帧移动小于挡板厚度,代价是球速上限很低,玩起来不带劲。二是子步进(substep):把一帧的移动拆成若干小步,每小步的位移不超过一个安全阈值,每走一步检查一次碰撞。
我选的是第二条路,阈值取 1/240 秒。60 帧下一帧拆成 4 个子步,每步球最多移动 3.75 像素,远小于挡板厚度,穿透问题彻底消失。这个开销完全可接受,现代 CPU 处理这点循环连零头都用不上。
顺带说一个容易忽略的点:碰撞检测要分轴处理。先移动 y、检查上下边界,再移动 x、检查挡板。如果同时移动 x 和 y 再统一检测,会出现球撞到墙角时同时翻转两个方向速度的怪异表现,甚至在角落反复弹跳卡住。
3. 完整代码实现:一份可直接运行的 Pong
下面这份代码我逐行调过,直接复制保存成pong.py就能跑。操作方式:左边玩家用W/S,右边玩家用方向键↑/↓,R重开,ESC退出,先到 7 分者胜。
3.1 全局配置与初始化
所有可调参数集中在文件开头,这是个小习惯但收益很大。想调手感的时候不用满文件找数字,改一个地方就行。
import math import random import sys import pygame # ---------- 屏幕 ---------- WIDTH, HEIGHT = 800, 600 FPS = 60 # ---------- 颜色 ---------- BG_COLOR = (14, 16, 22) FG_COLOR = (235, 238, 245) DIM_COLOR = (70, 76, 92) WIN_COLOR = (120, 220, 160) # ---------- 挡板 ---------- PADDLE_W, PADDLE_H = 12, 90 PADDLE_SPEED = 520.0 # 像素/秒 PADDLE_MARGIN = 36 # 挡板离左右边缘的距离 # ---------- 球 ---------- BALL_SIZE = 14 BALL_SPEED = 330.0 # 发球初速,像素/秒 BALL_SPEED_UP = 1.045 # 每次击球速度倍率 BALL_SPEED_MAX = 900.0 # 速度上限 MAX_BOUNCE_ANGLE = 0.9 # 挡板边缘最大反弹角,约 51.6 度 SUBSTEP = 1.0 / 240.0 # 子步进时间片 # ---------- 规则 ---------- WIN_SCORE = 7MAX_BOUNCE_ANGLE用弧度表示,0.9 弧度大概是 51.6 度。为什么不写 60 度?因为角度太大时球的水平速度分量会变小,球会长时间在场地中间垂直往返,游戏节奏变得拖沓。0.9 弧度是我反复试出来的一个平衡点,既能打出刁钻角度,球又始终有足够的横向推进力。
3.2 挡板类与键盘输入
挡板类里有个细节:我维护了一个浮点的self.y,而不是直接操作rect.y。原因前面说过,Rect只吃整数,如果每帧都往rect.y上累加小数,累积误差会让挡板移动速度偏慢。用浮点变量保存真实位置,只在需要渲染和碰撞时同步给Rect,既精确又干净。
class Paddle: def __init__(self, x, up_key, down_key): self.rect = pygame.Rect(x, HEIGHT // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) self.y = float(self.rect.y) self.up_key = up_key self.down_key = down_key self.score = 0 def reset(self): self.y = HEIGHT / 2 - PADDLE_H / 2 self.rect.y = round(self.y) self.score = 0 def update(self, dt, keys): direction = 0 if keys[self.up_key]: direction -= 1 if keys[self.down_key]: direction += 1 self.y += direction * PADDLE_SPEED * dt # 夹紧在场地内,注意用的是挡板高度而不是窗口高度 self.y = max(0.0, min(HEIGHT - PADDLE_H, self.y)) self.rect.y = round(self.y)键盘输入我用的是pygame.key.get_pressed(),不是事件队列。两者的区别很关键:事件队列(pygame.event.get()里的KEYDOWN)只在按下那一刻触发一次,适合"跳一下""开一枪"这类瞬时动作;get_pressed()返回的是当前所有按键的状态快照,按住不放就一直为 True,适合持续移动。挡板显然属于后者。如果你用事件队列写挡板移动,会发现要连点键盘才能动,手感极差。
同时按上下两个键会怎样?direction先减 1 再加 1,结果是 0,挡板停住。这个行为是合理的,不用额外处理。
3.3 球类与子步进碰撞
球类是整份代码里最需要仔细写的地方。核心是_step方法里的分轴移动,以及_try_hit里的角度反弹。
class Ball: def __init__(self): self.pos = pygame.Vector2(WIDTH / 2.0, HEIGHT / 2.0) self.vel = pygame.Vector2(0.0, 0.0) self.rect = pygame.Rect(0, 0, BALL_SIZE, BALL_SIZE) self.serve(random.choice((-1, 1))) def serve(self, direction): """direction: 1 表示发向右, -1 表示发向左""" self.pos.update(WIDTH / 2.0, HEIGHT / 2.0) angle = random.uniform(-0.42, 0.42) self.vel.update(math.cos(angle) * BALL_SPEED * direction, math.sin(angle) * BALL_SPEED) self._sync() def _sync(self): self.rect.center = (round(self.pos.x), round(self.pos.y)) def update(self, dt, left_paddle, right_paddle): remain = dt while remain > 1e-6: h = min(SUBSTEP, remain) self._step(h, left_paddle, right_paddle) remain -= h def _step(self, dt, left_paddle, right_paddle): half = BALL_SIZE / 2.0 # --- 先处理垂直方向与上下边界 --- self.pos.y += self.vel.y * dt if self.pos.y - half < 0: self.pos.y = half self.vel.y = abs(self.vel.y) elif self.pos.y + half > HEIGHT: self.pos.y = HEIGHT - half self.vel.y = -abs(self.vel.y) # --- 再处理水平方向与挡板 --- self.pos.x += self.vel.x * dt self._sync() if self.vel.x < 0: self._try_hit(left_paddle, +1) elif self.vel.x > 0: self._try_hit(right_paddle, -1) def _try_hit(self, paddle, direction): if not self.rect.colliderect(paddle.rect): return # 把球推出挡板,防止下一子步重复触发 if direction > 0: self.rect.left = paddle.rect.right else: self.rect.right = paddle.rect.left self.pos.x = float(self.rect.centerx) # 撞击点相对挡板中心的偏移,归一化到 [-1, 1] offset = (self.rect.centery - paddle.rect.centery) / (PADDLE_H / 2.0) offset = max(-1.0, min(1.0, offset)) angle = offset * MAX_BOUNCE_ANGLE speed = min(self.vel.length() * BALL_SPEED_UP, BALL_SPEED_MAX) self.vel.x = math.cos(angle) * speed * direction self.vel.y = math.sin(angle) * speed上下边界的处理有个小技巧:用self.vel.y = abs(self.vel.y)和self.vel.y = -abs(self.vel.y),而不是self.vel.y *= -1。看起来差不多,但多了层保险。假如某个 bug 导致球连续两帧都检测到越界,*= -1会翻转两次等于没翻转,球就卡在边界上了;用abs强制指定方向,无论检测多少次,球都只会朝场地内飞。这类"幂等"的写法在物理模拟里非常值得养成习惯。
发球角度限制在 ±0.42 弧度(约 ±24 度),是因为开局时角度太大会让接球方特别难受——球一出来就贴近上下边缘飞,挡板来不及移动。这个值也别太小,太小了球几乎是直线飞过去,第一拍毫无变化。
3.4 计分、发球与主循环
主循环里我加了一个state变量来区分"游戏中"和"结算中"两种状态。很多新手写法是直接在循环里break,结果窗口立刻关闭,玩家根本看不到谁赢了。用状态机就能在结算画面停留,等玩家按键再继续。
def make_font(size, bold=False): """优先用系统字体,取不到就退回 PyGame 内置字体""" for name in ("microsoftyahei", "pingfangsc", "notosanscjksc", "arialunicode", "arial"): try: return pygame.font.SysFont(name, size, bold=bold) except Exception: continue return pygame.font.Font(None, size) def draw_scene(screen, font_big, font_small, left, right, ball, state): screen.fill(BG_COLOR) # 中场虚线 for y in range(8, HEIGHT, 34): pygame.draw.rect(screen, DIM_COLOR, (WIDTH // 2 - 2, y, 4, 18)) # 挡板与球 pygame.draw.rect(screen, FG_COLOR, left.rect, border_radius=3) pygame.draw.rect(screen, FG_COLOR, right.rect, border_radius=3) pygame.draw.rect(screen, WIN_COLOR, ball.rect, border_radius=4) # 比分 score_l = font_big.render(str(left.score), True, FG_COLOR) score_r = font_big.render(str(right.score), True, FG_COLOR) screen.blit(score_l, (WIDTH // 2 - 90 - score_l.get_width() // 2, 40)) screen.blit(score_r, (WIDTH // 2 + 90 - score_r.get_width() // 2, 40)) # 操作提示 tip = font_small.render( "W/S \u2191/\u2193 R restart ESC quit", True, DIM_COLOR) screen.blit(tip, (WIDTH // 2 - tip.get_width() // 2, HEIGHT - 34)) if state == "over": winner = "LEFT" if left.score > right.score else "RIGHT" big = font_big.render(winner + " WINS", True, WIN_COLOR) small = font_small.render("press R to play again", True, FG_COLOR) screen.blit(big, (WIDTH // 2 - big.get_width() // 2, HEIGHT // 2 - 70)) screen.blit(small, (WIDTH // 2 - small.get_width() // 2, HEIGHT // 2 + 20)) def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Pong - PyGame") clock = pygame.time.Clock() font_big = make_font(72, bold=True) font_small = make_font(20) left = Paddle(PADDLE_MARGIN, pygame.K_w, pygame.K_s) right = Paddle(WIDTH - PADDLE_MARGIN - PADDLE_W, pygame.K_UP, pygame.K_DOWN) ball = Ball() state = "play" half = BALL_SIZE / 2.0 running = True while running: dt = clock.tick(FPS) / 1000.0 dt = min(dt, 1 / 30.0) # 防止卡顿导致的瞬移 # ---------- 事件 ---------- for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: running = False elif event.key == pygame.K_r: left.reset() right.reset() ball.serve(random.choice((-1, 1))) state = "play" # ---------- 更新 ---------- if state == "play": keys = pygame.key.get_pressed() left.update(dt, keys) right.update(dt, keys) ball.update(dt, left, right) # 左右出界判定 if ball.pos.x + half < 0: right.score += 1 ball.serve(1) elif ball.pos.x - half > WIDTH: left.score += 1 ball.serve(-1) if left.score >= WIN_SCORE or right.score >= WIN_SCORE: state = "over" # ---------- 渲染 ---------- draw_scene(screen, font_big, font_small, left, right, ball, state) pygame.display.flip() pygame.quit() sys.exit() if __name__ == "__main__": main()3.5 关于字体与出界判定的两个说明
make_font这个函数看起来有点多余,但它解决了一个真实问题:PyGame 默认字体在某些系统上渲染中文会显示成方块,而SysFont指定的字体名如果系统里没有,PyGame 会抛异常或者静默替换成默认字体。我在一个候选列表里挨个尝试,能取到就用,取不到最后退回pygame.font.Font(None, size)。这套逻辑在所有平台上都不会崩。
出界判定我用的是球心位置:ball.pos.x + half < 0表示球的右边缘已经越过屏幕左边界。为什么不在球心越过边界时就判分?因为那样视觉上球还有一半在屏幕里就已经加分了,看着别扭。用半宽做偏移,让球完全飞出视野再判分,观感更自然。这个细节很小,但玩家是能感受到的。
4. 手感调优:让弹球"好玩"而不是"能跑"
代码跑起来之后,真正的活才开始。我在调这个 Pong 的时候花在"手感"上的时间,比写第一版代码多得多。下面几组参数是我最终定下来的,也说说背后的取舍。
4.1 反弹角度决定上限
如果只用vel.x *= -1来反弹,你会发现一个致命问题:球永远不会改变垂直方向。无论从哪个位置打到挡板,球的轨迹都是同一条直线来回,打上二十拍也不会变。玩家很快就会发现规律,游戏瞬间失去乐趣。
正确的做法是让反弹角度由撞击位置决定。核心公式就三行:
offset = (ball_centery - paddle_centery) / (PADDLE_H / 2.0) offset = max(-1.0, min(1.0, offset)) angle = offset * MAX_BOUNCE_ANGLEoffset是撞击点相对挡板中心的偏移比例。打在最上边缘是 -1,打在正中间是 0,打在最下边缘是 +1。这个值再乘上最大反弹角,就得到了实际的出射角度。
这里有个必须注意的地方:offset一定要做夹紧。因为colliderect判定的是矩形重叠,球可能只擦到挡板一个角,此时算出来的 offset 会超过 ±1,角度就会超过设定上限,球可能被打出接近垂直的轨迹。夹紧之后,即使擦到角上,最大也就是 51.6 度。
另外MAX_BOUNCE_ANGLE的取值会直接改变游戏性格。我试过 0.6 弧度(约 34 度),球路温和,新手很容易接;也试过 1.2 弧度(约 69 度),球经常往上下边缘飞,刺激性够但接球全靠预判。0.9 弧度这个值介于两者之间,既能打出角度球调动对手,也不至于让玩家觉得无力。
4.2 速度递增与难度曲线
初速 330 像素/秒,如果一直不变,打到后面两个人都能轻松接住,比赛变成无限对拉。所以每次击球速度乘上 1.045:
speed = min(self.vel.length() * BALL_SPEED_UP, BALL_SPEED_MAX)为什么是 1.045 而不是 1.1?算一下:1.045 的 15 次方约等于 1.93,也就是打满 15 拍速度翻倍;1.1 的 15 次方是 4.17,15 拍后球快得根本接不住。1.045 这个系数能让一回合的对拉有明确的"升温感",从慢悠悠到紧张刺激大约需要十几拍,节奏刚刚好。
BALL_SPEED_MAX = 900是必须加的保险。没有上限的话,长回合之后球速会突破天际,加上子步进再密也挡不住。900 像素/秒在 60 帧下单帧移动 15 像素,子步进 4 次每次不到 4 像素,安全余量充足。
有个副作用需要注意:球速变快的同时,反弹角度会按新速度重新计算,垂直分量也会跟着放大。这是符合直觉的——球越快,角度球的威胁越大,这也是让游戏后期越来越紧张的原因。
4.3 视觉与音效反馈
视觉上我做得很克制,只有三个元素:挡板、球、中场虚线。配色上背景用深蓝黑(14, 16, 22),元素用接近白的浅灰,中间虚线用暗灰。这种低对比度的深色背景有个好处——长时间盯着眼睛不累,而且球用小尺寸高亮色,视线会自动被吸引过去。
球的颜色我用了偏绿的 (120, 220, 160) 而不是纯白,这样即使球和挡板在视觉上重叠,你也能一眼分辨出球在哪。这个细节在做快速类游戏时特别有用。
加音效也很简单,两行代码的事:
hit_sound = pygame.mixer.Sound("hit.wav") hit_sound.play()如果没有音效文件,可以用pygame.mixer.Sound(buffer=...)生成一个简单的方波,不过更省事的办法是直接跳过。音效不是必需品,它对游戏性的提升远小于手感调优。先把角度和速度调舒服了,再考虑加声音。
5. 常见问题与排查实录
这部分是我在不同机器上反复部署这套代码时攒下来的问题清单,基本覆盖了新手会遇到的绝大多数情况。
5.1 安装与环境类问题
问题一:ModuleNotFoundError: No module named 'pygame'。最常见的成因是 pip 和 python 指向不同的解释器。排查方法:分别运行python -m pip -V和python -c "import sys; print(sys.executable)",看两者路径是否在同一个环境下。如果不是,用python -m pip install pygame重装。
问题二:安装时卡在 "Building wheel for pygame"。这通常出现在 Python 版本太新、官方还没发布对应轮子的情况下。解决办法是降一个 Python 次版本,比如从 3.13 降到 3.11,或者换用pip install pygame-ce(社区维护版本,轮子更新更快,API 完全兼容)。
问题三:窗口打开了但一片黑,鼠标转圈。大概率是没写事件循环,或者主循环里没有处理pygame.event.get()。PyGame 必须持续消费事件队列,不消费的话操作系统会认为程序无响应。
问题四:中文显示成方块。用前面make_font的写法,给一组候选字体名挨个尝试。或者干脆只显示英文,Pong 的界面其实不需要中文。
5.2 逻辑类 bug 与定位方法
现象:球偶尔从挡板中间穿过去。这是穿透,检查两点:一是子步进的SUBSTEP是否足够小,二是BALL_SPEED_MAX是否过高。有个快速验证方法:在_try_hit里加一句print("hit"),正常对拉时每次击球应该打印一次。如果球明显碰到挡板但没打印,就是穿透了。
现象:球贴着上下边界抖动。边界处理用了*= -1而不是abs()。前面解释过,用绝对值写法保证幂等。
现象:得分之后球没重置。检查serve()有没有在加分分支里调用。这是最常见的漏写。
现象:挡板移动忽快忽慢。有没有乘dt。如果不乘,帧率波动时速度就不稳定。
现象:球反弹后角度变得极其垂直,甚至卡在挡板里来回弹。检查offset有没有夹紧,以及_try_hit里有没有把球推出挡板。推出那两行(self.rect.left = paddle.rect.right)非常关键,少了它,球在重叠状态会被反复判定为碰撞,速度会指数级暴涨。
下面这张表是我自己的速查清单,出问题的时候按顺序过一遍,基本都能定位:
| 现象 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| 球穿过挡板 | 未做子步进或速度上限过高 | 在碰撞函数里打印日志 |
| 球在边界抖动 | 用*= -1而非abs() | 改成绝对值写法 |
| 挡板速度不稳 | 位移未乘dt | 打印 dt 看波动 |
| 按下 R 无反应 | 事件判断写在 state 分支内 | 把事件处理提到分支外 |
| 窗口无响应 | 未消费事件队列 | 确认event.get()被调用 |
| 得分不重置球 | serve()未调用 | 检查加分分支 |
| 中文显示方块 | 系统字体缺失 | 改用英文或候选字体列表 |
| 球速越打越快失控 | 缺少BALL_SPEED_MAX夹紧 | 打印vel.length()观察 |
5.3 我踩过的三个坑
第一个坑是把子步进写成了递归。我一开始想的是"如果这一步检测到碰撞就重新计算",结果写出了自我调用的函数,球打在挡板上直接触发 RecursionError。后来改成简单的 while 循环按固定时间片切分,问题消失。物理模拟里,循环永远比递归可靠。
第二个坑是发球方向写死。最初我每次都用random.choice随机方向,结果发现连续几次都发向同一边,玩家会有"系统针对我"的感觉。后来改成上一回合谁得分就往谁那边发(或者反过来,视规则而定),体感上更公平。这个细节不影响正确性,但影响体验。
第三个坑是窗口尺寸和 Rect 的边界搞混。挡板夹紧的时候我一开始写的是min(HEIGHT, self.y),漏了减去挡板高度,结果挡板的下半截能滑出屏幕外面,看着像是掉下去了。改成min(HEIGHT - PADDLE_H, self.y)才正常。这类"差一个自身高度"的错误在 UI 布局里特别常见,写完多看一眼边界条件。
6. 从单机到完整作品:几个值得动手的扩展方向
能玩之后,接下来怎么把这个东西变成一个真正拿得出手的作品?我按投入产出比排一下顺序。
6.1 给右边加一个会犯错的电脑对手
加 AI 是性价比最高的扩展,代码量不到二十行,但游戏立刻从"需要两个人"变成"一个人也能玩"。
思路很简单:让右侧挡板追踪球的 y 坐标,但追踪速度比球慢,并且引入一个反应延迟。如果让它完美追踪,游戏永远打不完;如果太慢,又毫无挑战。我常用的写法是给 AI 一个"目标位置",每帧让挡板朝目标移动固定距离,同时加一个随机偏移模拟判断失误:
def update_ai(self, dt, ball, difficulty=0.8): target = ball.rect.centery # 球远离时不追,模拟"反应延迟" if ball.vel.x < 0: target = HEIGHT / 2 error = random.uniform(-1, 1) * (1 - difficulty) * 60 target += error diff = target - self.rect.centery step = PADDLE_SPEED * difficulty * dt if abs(diff) <= step: self.y = target - PADDLE_H / 2 else: self.y += step * (1 if diff > 0 else -1) self.y = max(0.0, min(HEIGHT - PADDLE_H, self.y)) self.rect.y = round(self.y)difficulty参数从 0.5 到 0.95 之间调,0.8 是个不错的默认值:AI 大部分球能接住,但对角度刁钻的球会漏。关键点是那个"球远离时不追"的判断——如果 AI 一直贴着球跑,它永远是最后一个位置接球,看着像作弊。让它在球飞向对手时回到中间,行为就自然多了。
6.2 加道具、加模式、加打包
道具系统是个很好玩的方向。比如球每撞到一次上下边界,就在场地里随机生成一个小方块;挡板吃到方块后宽度增加 30%,持续 5 秒。实现上你需要一个PowerUp类,管理位置、存活时间和效果类型。难点在于"效果叠加"——如果同时吃到两个加宽道具,宽度会不会无限增长?我的做法是加一个计时器,吃到新的就重置计时,而不是叠加,简单可控。
双人本地对战已经有了,想扩展的话可以加"三局两胜",把WIN_SCORE改成局数制,每局结束后回到发球状态但保留分数。
打包成可执行文件用pyinstaller:
pyinstaller --onefile --windowed pong.py--windowed参数用于隐藏控制台窗口。注意打包后如果用了外部资源文件(图片、音效),需要用--add-data参数一起打进去,并在代码里用sys._MEIPASS处理路径。这一步不做的话,打包出来的 exe 会在别人电脑上报找不到文件。这是打包环节最常见的坑,我第一次打包时就栽在这里,本地跑得好好的,发给朋友直接闪退。
关于"PyGame 写的游戏是否需要编译",答案是:Python 代码本身不编译,但可以用 PyInstaller 打包成独立的可执行文件,对方不需要装 Python 和 PyGame 就能运行。代价是体积会大一些,一个简单的 Pong 打包出来大概 30 到 50 MB,因为打包器会把整个 Python 运行时塞进去。如果只是发给懂技术的朋友,直接发.py文件更划算。
我个人在实际做这类小游戏时的体会是:不要在功能上贪多,把手感打磨到"愿意多玩两把"的程度,价值远大于堆一堆半成品功能。我调这个 Pong 花时间最多的部分,不是加 AI 也不是加道具,而是反反复复打了几十局,就为了确认球速从 330 升到 700 的过程中,紧张感的曲线是不是平滑的。当你自己愿意一遍遍重开的时候,这个游戏基本就成了。
还有一个我一直用的小习惯:每次改完参数,先在纸上记一下改了什么、体感如何,攒够五六条再一起比较。凭感觉调参很容易陷入"改来改去又回到原点"的循环,留下记录之后你会发现,真正有效的调整往往就那么两三个。