☰
Python从零实现打砖块:主循环、碰撞检测与状态机入门
2026/11/3 12:27:20 网站建设 项目流程

翻到一个编号 0095 的 Python 教学视频,前半截是历史故事,后半截是打砖块,这组合看着有点跳,其实非常讲究。打砖块这个游戏,是七十年代那家靠乒乓球街机起家的公司做出来的单人版产品,任务落到一个跑去求职、连简历都没带的年轻人身上,条件是用尽量少的芯片把它做出来。这个故事的每一层——求职、约束、实现——恰好对应编程入门的三个核心问题:为什么学、学什么、怎么落地。这篇东西我准备把两条线拧在一起讲:一条是这段轶事里真正值得拎出来说的工程信息,另一条是用 Python 从零写一个能跑、手感还过得去的打砖块。适合刚学完语法不知道拿什么练手的人,也适合教别人编程、想找个能把“循环、坐标、向量、碰撞、状态机”一次讲透的案例的人。代码我给完整的,参数我给推导过程,坑我一个一个指出来。

1. 一段求职轶事,为什么成了编程课的经典开场

1.1 故事本身其实只有三句话

流传的版本很多,主干大致是这样:一个没念完大学的年轻人,跑到一家做街机的小公司前台,说自己想来上班,被拒了;第二天又来了,说自己什么都肯干;公司最后把他留下来当技术员,先做最基础的板子调试。后来公司要上一款单人版的“打砖块”,任务派给他,附加条件很硬——芯片数量必须压到极低,因为每多一颗芯片,整机成本就上去一截,街机柜的利润本来就薄。

故事的戏剧性在于它是真的被做出来了,而且做得很优雅。但作为搞技术的人,我更在意里面那三层信息。第一层,门槛不是学历,是能不能交付一个东西;第二层,真正的工程难度从来不在“能不能做”,而在“在约束下还能不能做”;第三层,游戏是最适合验证程序的形态,因为它有画面,错了一眼就能看出来。

这三层放到今天一条都没过时。你去找工作,作品集比简历管用;你写代码,性能和依赖就是你的芯片数量约束;你学编程,最忌讳的就是只看不写,看十遍语法不如让一个球在屏幕上弹起来一次。

1.2 打砖块为什么比 Hello World 更适合当练习靶子

Hello World 能验证的东西太少了,它只证明你的解释器装好了、路径没配错,仅此而已。它连“程序的状态会随时间变化”这件事都体现不出来。

打砖块不一样。你要让球动起来,就得有主循环;球要有位置,你就得理解坐标系;球要反弹,你就得接触向量和符号;砖块要被消掉,你就得管集合的增删;球掉下去要扣命,你就得引入状态机;砖块消完要通关,你就得写判断条件。一个不到两百行的小游戏,把入门阶段能遇到的抽象概念全串起来了,而且是肉眼可见地串起来。

更关键的是反馈速度。语法错了会报错,逻辑错了球会飞出去、会卡在砖块里、会穿墙,你能立刻看到现象,然后倒推原因。这种“现象—假设—验证”的闭环,是编程能力真正长出来的地方。我在带人的时候有个很朴素的判断标准:能把打砖块从零写到没有明显 bug 的人,基础语法那关基本过了;能把手感调到玩起来舒服的人,对数值和边界条件是有感觉的。

对比一下就很清楚:

练习项目覆盖的知识点反馈直观度可扩展性
Hello World输出、环境低几乎没有
猜数字输入、分支、循环中低
通讯录管理数据结构、文件读写低中
打砖块循环、坐标、向量、碰撞、状态机、渲染高很高

所以这个选题不是随便挑的,它是被验证过很多年的教学标本。

2. 把打砖块拆开:五个必须想清楚的结构

2.1 数据模型:屏幕背后到底有几个“东西”

新手最容易犯的错是边写边加变量,写到最后ball_x、ball_y、ball_vx、ball_vy、ball_speed散落一地,改一个功能要翻半天。正确做法是先画一张表,把游戏里所有的“东西”列出来,每个东西有哪些属性,哪些会变。

对象关键属性是否每帧变化
球位置 x/y、速度 vx/vy、半径、是否吸附在挡板上位置和速度都变
挡板矩形位置、宽度、速度上限位置变
砖块矩形、是否存活、分值只在被击中时变
计分板分数、剩余生命事件驱动变化
游戏状态待发球 / 进行中 / 失败 / 通关事件驱动变化

这张表画完,代码结构就定了一半。球的位置和速度是连续变化的量,适合放列表或向量类;砖块是一批同构对象,适合放列表,用alive标记或者直接从列表里删;游戏状态是离散的,适合用字符串常量或枚举。

我个人的偏好是:能用pygame.Rect表达的东西就不要自己算x, y, w, h,因为这个类型自带colliderect、centerx、clamp这些方法,能省掉大量重复代码和手误。球的位置用两个浮点数单独存,因为Rect只支持整数,球速一旦不是整数就会被截断,动起来一顿一顿的。

2.2 主循环:一帧里到底按什么顺序做事

主循环的顺序错一格,就会出很诡异的现象。标准顺序应该是这样:

  1. 处理输入事件(键盘、窗口关闭)
  2. 更新逻辑(移动球、移动挡板)
  3. 碰撞检测与响应(球撞墙、撞挡板、撞砖块)
  4. 清理被消掉的砖块
  5. 绘制所有东西
  6. 控制帧率,拿到这一帧的耗时

为什么“先更新再检测”而不是反过来?因为检测的对象是更新后的位置。如果你先检测再移动,那就是在检查上一帧的球在哪,永远慢一帧,速度快的时候会明显看到球先穿进去再弹出来。

为什么绘制要放最后?因为绘制是只读操作,它反映的是当前状态。如果你边算边画,很容易画出半新半旧的画面。

这里还有个隐蔽的坑:帧率控制那一步必须放在循环的末尾,而且必须用它的返回值当dt。很多人写成clock.tick(60)之后不取返回值,直接用固定的1/60当步长,结果在高刷屏上跑得飞快、在低配机器上跑得慢吞吞。

2.3 坐标系约定:屏幕的 y 轴是往下的

这是第一次写图形程序的人最容易被绊倒的地方。数学课上 y 轴往上,屏幕上的 y 轴往下,原点在左上角。所以“球往上飞”意味着vy是负数,而不是正数。

这个约定导致两个直接后果。第一,所有和竖直方向有关的符号都要反过来想一遍,写反弹的时候特别容易写成vy = -vy结果球往下钻。第二,很多人喜欢用一个统一的“速度大小speed”加上一个角度来表示方向,这时候角度到分量的换算要格外小心,因为标准数学里sin对应 y、cos对应 x,转到屏幕坐标之后,向上的方向是(-sin θ · speed, -cos θ · speed)。

我建议在代码开头就用注释把这套约定写死,比如“y 向下为正,向上为负,挡板反弹后 vy 必为负”,后面每次写符号都回来对一眼。这看着很笨,但能省掉大量调试时间。

2.4 碰撞检测:为什么 AABB 就够了,什么时候不够

球和砖块的碰撞,严谨做法是圆与矩形求最近点,算出距离和法线。偷懒做法是把球看成一个正方形,直接做矩形相交测试,也就是 AABB。

AABB 的优点是快、代码短,缺点在边角:当球从砖块的角附近擦过去时,判定会略微提前或延后,看起来像“隔空反弹”。对于打砖块这种节奏的游戏,肉眼基本看不出来,所以我一般直接用 AABB 起步。

但 AABB 有个更麻烦的问题:它没法告诉你“从哪一面撞上的”,你只能再补一段逻辑去判断重叠方向。常见写法是算两个方向的重叠深度:

overlap_x = min(ball.right, brick.right) - max(ball.left, brick.left) overlap_y = min(ball.bottom, brick.bottom) - max(ball.top, brick.top) if overlap_x < overlap_y: vx = -vx # 从左右方向撞上 else: vy = -vy # 从上下方向撞上

这段逻辑在大多数情况下是对的,但有一个经典失效场景:球从正上方高速落下,同时横向位移很大,进入砖块时上下重叠深度反而比左右大,于是被判定成侧面撞击,球就横着飞了。想彻底解决就得上圆与矩形的最近点法,用接触点的法线决定反弹方向。这个东西代码不长,我在第 3 节给完整实现。

2.5 状态机:不加它,代码一定会烂

刚开始写的时候,大部分人是靠一堆布尔量管状态的:is_playing、is_dead、is_win、ball_attached。写着写着就会冒出if is_playing and not is_dead and not is_win这种条件,然后出现“暂停状态下球还在动”“死后还能发球”之类的怪事。

正确姿势是把状态收敛成一个变量,取值只有几个:

状态含义允许的动作
READY球吸附在挡板上,等待发球左右移动挡板、空格发球
PLAYING球在飞移动挡板、碰撞、计分
DEAD掉球,等待重开短暂延时后进入 READY
WIN砖块清空显示提示、等待重开

状态机的好处是每个状态下该做什么、不该做什么,一目了然。你写if state == PLAYING的时候,脑子里不需要同时记着五个布尔量。这个习惯一旦养成,后面写界面、写流程控制都会顺很多。

另外,READY 状态下球是“跟着挡板走”的,这一点值得单独想一下:球的位置不独立更新,而是每帧重新计算为挡板中心上方一点。这就是所谓的“吸附”,很多游戏里的发球、蓄力、拖拽都是这个模式。

3. 动手写:一个两百行能跑起来的 Python 版本

3.1 环境准备与常量集中定义

用pygame最省事,装一个就行:

pip install pygame

我不建议这个练习用turtle,虽然它零依赖,但刷新机制和事件循环都比较别扭,写出来的手感和真正的游戏差太远,学到的东西迁移性不好。

工程上唯一的“规范”要求是:所有魔法数字都提到文件顶部集中定义。这不是为了好看,是因为打砖块这个项目你一定会反复调参数,散落在代码里的数字改起来会漏。我的习惯是分成三组:屏幕与帧率、球与挡板、砖块布局。

import math import sys import pygame # 屏幕与帧率 W, H = 800, 600 FPS = 60 # 球与挡板 BALL_R = 7 BALL_SPEED = 360.0 # 像素/秒 PADDLE_W, PADDLE_H = 110, 14 PADDLE_SPEED = 620.0 # 像素/秒 MAX_BOUNCE_DEG = 60.0 # 挡板边缘反弹的最大偏角 # 砖块布局 BRICK_ROWS, BRICK_COLS = 5, 10 BRICK_W, BRICK_H = 72, 24 BRICK_GAP = 4 BRICK_TOP = 70 BRICK_LEFT = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) // 2

BRICK_LEFT这个表达式值得说一句。整排砖块的总宽度是列数 × (砖宽 + 间距) - 间距,最后减掉一个间距是因为末尾那块后面不需要留缝。算出来是10 × 76 - 4 = 756,屏幕宽 800,左右各留 22 像素。这个式子如果你让程序自己算,改列数的时候布局自动居中,省心。

3.2 球的运动:用 dt 而不是“每帧加几个像素”

新手最常见的写法是ball_x += 5,意思是每帧往右挪 5 像素。这个写法在你自己机器上跑得好好的,换台机器就变味了——因为帧率不一样,跑 144 帧的显示器上球速是 60 帧的两倍多。

正确做法是把速度定义成“像素每秒”,然后每帧按实际耗时推进:

dt = clock.tick(FPS) / 1000.0 ball_pos[0] += ball_vel[0] * dt ball_pos[1] += ball_vel[1] * dt

clock.tick(FPS)会保证循环不超过目标帧率,同时返回距离上一次调用的毫秒数。除以 1000 换成秒,就是这一步的时间增量。

顺便算一下参数。屏幕高 600,如果球速设 360 像素/秒,竖直方向完整走一趟大约 1.7 秒;横向 800 像素大约 2.2 秒。这个节奏玩起来是“能反应过来但不敢走神”的程度。如果设成 600,球会在 1 秒左右穿完全程,新手基本接不住。我个人的经验区间是 300 到 450 之间,低于 300 会觉得拖沓,高于 500 就开始难受了。

发球的时候给一个初始角度,别用纯垂直,否则球会在同一条竖线上来回弹,玩起来很无聊。用一个 ±30 度以内的随机偏角就很好:

angle = math.radians(random.uniform(-30, 30)) ball_vel[0] = BALL_SPEED * math.sin(angle) ball_vel[1] = -BALL_SPEED * math.cos(angle) # 向上为负

这里math.hypot(vx, vy)应该始终等于BALL_SPEED,这是个很好的自检点:如果某次反弹之后速度变了,说明你的反射公式写错了。

3.3 挡板反弹:整个游戏手感的核心就在这十行

打砖块好不好玩,八成取决于球从挡板弹出去的角度合不合理。如果只是简单地vy = -vy,那球永远以同一个角度来回,玩家几秒钟就腻了,而且没法“控制”球的方向。

业界通用做法是:根据球击中挡板的位置,映射到一个出射角度范围。打中正中间就往正上方走,打中边缘就往斜上方走:

def bounce_off_paddle(ball_pos, ball_vel, paddle): # 归一化偏移量,范围 [-1, 1] offset = (ball_pos[0] - paddle.centerx) / (PADDLE_W / 2) offset = max(-1.0, min(1.0, offset)) # 偏移量映射到角度,向右为正 angle = math.radians(offset * MAX_BOUNCE_DEG) speed = math.hypot(ball_vel[0], ball_vel[1]) ball_vel[0] = speed * math.sin(angle) ball_vel[1] = -speed * math.cos(angle) # 一定向上

这段代码有几个细节值得抠。

第一,为什么要max/min夹一下。因为球有可能在挡板边缘外面一点点发生碰撞判定(挡板也在移动),offset会超过 1,不夹的话角度会超过 60 度,球直接横着飞。

第二,速度用math.hypot重新算,而不是直接复用常量。这样后面你加“每打掉一块砖球速加 2%”的机制时,不用改这段代码,它会自动继承当前速度。

第三,vy强制为负。这一步很关键,它保证了球在任何情况下都是往上走的。少了这一行,如果碰撞时球已经在往下走且位置有点深,反弹后可能还是往下,直接穿板。

MAX_BOUNCE_DEG设多少合适?60 度是我试下来比较平衡的值。设到 75 度,球几乎贴着水平飞,接起来很刺激但容易失控;设到 45 度,可操作空间太小,玩家感觉不到“我在控制方向”。60 度的时候,从最边缘打出去,球横向速度大概是竖直速度的 1.7 倍,能明显打出角度,又不至于完全没法接。

3.4 砖块的碰撞响应:用最近点法算,别偷懒

前面说过 AABB 在角上会失效,这里给一个不长的精确写法。核心思路是:找到矩形上离球心最近的那个点,如果距离小于半径,就说明撞上了;从球心指向最近点的方向就是法线,沿法线做一次反射。

def hit_rect(ball_pos, ball_vel, radius, rect): """返回 True 表示发生碰撞,并就地修改 ball_pos / ball_vel""" # 矩形上距离球心最近的点 cx = min(max(ball_pos[0], rect.left), rect.right) cy = min(max(ball_pos[1], rect.top), rect.bottom) dx = ball_pos[0] - cx dy = ball_pos[1] - cy dist2 = dx * dx + dy * dy if dist2 > radius * radius: return False if dist2 > 1e-9: dist = math.sqrt(dist2) nx, ny = dx / dist, dy / dist # 把球推出去,避免下一帧还在内部 ball_pos[0] += nx * (radius - dist) ball_pos[1] += ny * (radius - dist) # 沿法线反射:v' = v - 2(v·n)n dot = ball_vel[0] * nx + ball_vel[1] * ny if dot < 0: # 只在“正在靠近”时反射 ball_vel[0] -= 2 * dot * nx ball_vel[1] -= 2 * dot * ny else: # 球心恰好落在矩形内部,退化处理:按最小穿透轴顶出去 left = ball_pos[0] - rect.left right = rect.right - ball_pos[0] top = ball_pos[1] - rect.top bottom = rect.bottom - ball_pos[1] m = min(left, right, top, bottom) if m == left: ball_pos[0] = rect.left - radius ball_vel[0] = -abs(ball_vel[0]) elif m == right: ball_pos[0] = rect.right + radius ball_vel[0] = abs(ball_vel[0]) elif m == top: ball_pos[1] = rect.top - radius ball_vel[1] = -abs(ball_vel[1]) else: ball_pos[1] = rect.bottom + radius ball_vel[1] = abs(ball_vel[1]) return True

dot < 0这个判断是“只反射正在靠近的情况”。少了它,球在刚反射完、还没来得及离开的那一帧会被再反射一次,结果就是原地抖动或者突然加速。这个坑我踩过不止一次。

else分支看着很啰嗦,但它是必要的兜底。球速够快、帧率够低的时候,球心真的有可能直接落到砖块内部,这时dx和dy都是 0,上面的除法会炸。这一段就是防这个的。

3.5 完整可运行的骨架

把上面的零件拼起来,就是这百来行。我把它整理成可以直接python main.py跑起来的版本:

import math import random import sys import pygame W, H = 800, 600 FPS = 60 BALL_R = 7 BALL_SPEED = 360.0 PADDLE_W, PADDLE_H = 110, 14 PADDLE_SPEED = 620.0 MAX_BOUNCE_DEG = 60.0 BRICK_ROWS, BRICK_COLS = 5, 10 BRICK_W, BRICK_H = 72, 24 BRICK_GAP = 4 BRICK_TOP = 70 BRICK_LEFT = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) // 2 BG = (18, 18, 24) FG = (235, 235, 245) BRICK_COLORS = [(240, 96, 96), (240, 168, 72), (232, 224, 96), (108, 212, 128), (108, 168, 240)] def make_bricks(): bricks = [] for r in range(BRICK_ROWS): for c in range(BRICK_COLS): x = BRICK_LEFT + c * (BRICK_W + BRICK_GAP) y = BRICK_TOP + r * (BRICK_H + BRICK_GAP) rect = pygame.Rect(x, y, BRICK_W, BRICK_H) bricks.append([rect, BRICK_COLORS[r % len(BRICK_COLORS)], True]) return bricks def hit_rect(ball_pos, ball_vel, radius, rect): cx = min(max(ball_pos[0], rect.left), rect.right) cy = min(max(ball_pos[1], rect.top), rect.bottom) dx, dy = ball_pos[0] - cx, ball_pos[1] - cy dist2 = dx * dx + dy * dy if dist2 > radius * radius: return False if dist2 > 1e-9: dist = math.sqrt(dist2) nx, ny = dx / dist, dy / dist ball_pos[0] += nx * (radius - dist) ball_pos[1] += ny * (radius - dist) dot = ball_vel[0] * nx + ball_vel[1] * ny if dot < 0: ball_vel[0] -= 2 * dot * nx ball_vel[1] -= 2 * dot * ny else: left = ball_pos[0] - rect.left right = rect.right - ball_pos[0] top = ball_pos[1] - rect.top bottom = rect.bottom - ball_pos[1] m = min(left, right, top, bottom) if m == left: ball_pos[0] = rect.left - radius ball_vel[0] = -abs(ball_vel[0]) elif m == right: ball_pos[0] = rect.right + radius ball_vel[0] = abs(ball_vel[0]) elif m == top: ball_pos[1] = rect.top - radius ball_vel[1] = -abs(ball_vel[1]) else: ball_pos[1] = rect.bottom + radius ball_vel[1] = abs(ball_vel[1]) return True def bounce_off_paddle(ball_pos, ball_vel, paddle): offset = (ball_pos[0] - paddle.centerx) / (PADDLE_W / 2) offset = max(-1.0, min(1.0, offset)) angle = math.radians(offset * MAX_BOUNCE_DEG) speed = math.hypot(ball_vel[0], ball_vel[1]) ball_vel[0] = speed * math.sin(angle) ball_vel[1] = -speed * math.cos(angle) ball_pos[1] = paddle.top - BALL_R - 0.5 def main(): pygame.init() screen = pygame.display.set_mode((W, H)) pygame.display.set_caption("Breakout Demo") clock = pygame.time.Clock() font = pygame.font.SysFont("consolas", 22) paddle = pygame.Rect((W - PADDLE_W) // 2, H - 50, PADDLE_W, PADDLE_H) bricks = make_bricks() ball_pos = [W / 2, H / 2] ball_vel = [0.0, 0.0] state = "READY" score, lives = 0, 3 while True: dt = clock.tick(FPS) / 1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: if state == "READY": angle = math.radians(random.uniform(-30, 30)) ball_vel[0] = BALL_SPEED * math.sin(angle) ball_vel[1] = -BALL_SPEED * math.cos(angle) state = "PLAYING" elif state in ("DEAD", "WIN"): paddle.centerx = W // 2 bricks = make_bricks() ball_pos = [W / 2, H / 2] ball_vel = [0.0, 0.0] score, lives = 0, 3 state = "READY" keys = pygame.key.get_pressed() if keys[pygame.K_LEFT]: paddle.x -= PADDLE_SPEED * dt if keys[pygame.K_RIGHT]: paddle.x += PADDLE_SPEED * dt paddle.x = max(0, min(W - PADDLE_W, paddle.x)) if state == "READY": ball_pos[0] = paddle.centerx ball_pos[1] = paddle.top - BALL_R - 0.5 if state == "PLAYING": ball_pos[0] += ball_vel[0] * dt ball_pos[1] += ball_vel[1] * dt if ball_pos[0] - BALL_R < 0: ball_pos[0] = BALL_R ball_vel[0] = abs(ball_vel[0]) if ball_pos[0] + BALL_R > W: ball_pos[0] = W - BALL_R ball_vel[0] = -abs(ball_vel[0]) if ball_pos[1] - BALL_R < 0: ball_pos[1] = BALL_R ball_vel[1] = abs(ball_vel[1]) if ball_vel[1] > 0: ball_rect = pygame.Rect(ball_pos[0] - BALL_R, ball_pos[1] - BALL_R, BALL_R * 2, BALL_R * 2) if ball_rect.colliderect(paddle): bounce_off_paddle(ball_pos, ball_vel, paddle) for item in bricks: if item[2] and hit_rect(ball_pos, ball_vel, BALL_R, item[0]): item[2] = False score += 10 break if ball_pos[1] - BALL_R > H: lives -= 1 if lives <= 0: state = "DEAD" else: state = "READY" ball_pos = [paddle.centerx, paddle.top - BALL_R - 0.5] ball_vel = [0.0, 0.0] if not any(b[2] for b in bricks): state = "WIN" screen.fill(BG) for rect, color, alive in bricks: if alive: pygame.draw.rect(screen, color, rect, border_radius=4) pygame.draw.rect(screen, FG, paddle, border_radius=6) pygame.draw.circle(screen, FG, (int(ball_pos[0]), int(ball_pos[1])), BALL_R) hud = font.render(f"SCORE {score} LIVES {lives}", True, FG) screen.blit(hud, (20, 20)) if state == "READY": tip = font.render("PRESS SPACE TO LAUNCH", True, FG) screen.blit(tip, tip.get_rect(center=(W // 2, H // 2 + 80))) elif state == "DEAD": tip = font.render("GAME OVER - PRESS SPACE", True, FG) screen.blit(tip, tip.get_rect(center=(W // 2, H // 2))) elif state == "WIN": tip = font.render("CLEARED - PRESS SPACE", True, FG) screen.blit(tip, tip.get_rect(center=(W // 2, H // 2))) pygame.display.flip() if __name__ == "__main__": main()

跑起来之后你应该看到:球贴在挡板上跟着走、空格发球、球撞墙和砖块能弹、砖块消完提示通关、掉球扣命。如果哪一条不对,对着第 4 节找。

4. 那些一定会踩的坑:排查速查表

4.1 球穿砖块、穿挡板:都是速度惹的祸

这是最高频的问题。现象是球在高速下直接穿过砖块或者挡板,继续往下走。

原因很直白:你每帧只在一个位置上做一次碰撞检测。假设球速 600 像素/秒,帧率 60,那么一帧的位移是 10 像素。如果砖块厚度是 24 像素,理论上没问题;但一场游戏打久了,球速被加成到 1200,一帧位移变成 20,再加上球本身半径 7,如果碰撞判定的位置刚好卡在两个采样点之间,就可能整个跳过砖块。

解决方案是子步长:把一帧的位移拆成若干小步,每一步都做一次碰撞检测。

speed = math.hypot(ball_vel[0], ball_vel[1]) step_len = BALL_R * 0.8 steps = max(1, int(speed * dt / step_len)) for _ in range(steps): small_dt = dt / steps ball_pos[0] += ball_vel[0] * small_dt ball_pos[1] += ball_vel[1] * small_dt do_collision_checks()

step_len取半径的 0.8 倍是个经验值。理论上确保每步位移不超过砖块的最小厚度就能防穿透,但取更小的值代价也不高——一帧最多拆成十几次检测,现代机器完全扛得住。

顺带一提,挡板穿模还有另一个原因:挡板自己在动。如果玩家快速甩动挡板,挡板一帧能移动十几像素,球从挡板“扫过”的区域经过时,两个位置可能都不重叠。严谨的做法是对挡板做扫掠检测,但实践中简单处理就够用:把挡板碰撞的判定区域在竖直方向往下多留几个像素。

4.2 球卡在砖块缝里疯狂抖动

现象是球在两块砖之间来回弹,发出“噗噗噗”的声音,几秒钟后突然加速飞出屏幕。

这是两个问题叠在一起。第一,球被反射后位置没有被完全推出砖块,下一帧又被判定为碰撞;第二,连续两次反射可能把速度方向改得面目全非。

处理办法是三条一起上。一是碰撞响应里必须把球沿法线推出去(3.4 节代码里那两行ball_pos += n * (radius - dist)就是干这个的)。二是反射前判断dot < 0,只在球确实在靠近时才反射。三是如果同一个物体连续两帧都被判定碰撞,给它加一个短暂的碰撞冷却。

if brick.last_hit_frame == frame_count: return False # 同一帧不重复处理同一块砖

更常见的简化做法是:砖块被击中后直接标记为死亡并从待检测列表里排除,这样它就不可能被重复碰撞。这个办法简单而且完全解决问题,因为打砖块里砖块本来就是一次性的。

4.3 手感调优:那些教程里不会写的数值

代码能跑和好玩之间隔着一整个参数表。下面是我反复调出来的区间,可以直接拿去当起点:

参数推荐值调大的效果调小的效果
球速300~450 px/s反应时间短,紧张感强节奏拖沓,容易走神
挡板宽度90~130 px容错高,变简单操作压力大
挡板速度500~800 px/s跟球轻松,但容易过头追不上侧边球
最大反弹角55~65 度角度控制更明显可控性变差
砖块行数4~7 行一局时间长结束太快
砖块间距3~6 px视觉清爽,缝隙好穿紧凑,视觉压迫

另外还有几个不那么显眼但影响很大的点。

球的初始速度方向一定要带随机偏角,否则每局开头都一模一样。挡板反弹时强制vy向上,这是避免“球从板下钻下去”的最后一道保险。掉球后的重开不要立刻发球,留一个 READY 状态让玩家重新找回位置,否则连掉三条命很快。帧率上限设 60 就够了,设 144 反而会让dt变小、物理步进变碎,有些浮点误差会显现出来。

4.4 常见报错与现象速查

现象最可能的原因排查入口
窗口一闪就关主循环里没有持续处理事件检查pygame.event.get()是否在循环内
球完全不动速度初值为 0,且发球逻辑没被触发打印ball_vel看是否还是[0, 0]
球只往一个方向飞反弹时符号写错,或math.sin/cos用反在反弹处打印vx, vy
砖块消不掉碰撞函数返回值没被接住检查if hit_rect(...)是否被执行
字体显示成方块系统里没装对应字体换成pygame.font.SysFont(None, 22)
画面闪烁用了pygame.display.update()局部刷新改用pygame.display.flip()
帧率暴跌每帧都在创建新对象或加载字体把font提到循环外创建
球粘在挡板上不动判定区域偏移,或vy被反复强制为负检查ball_pos[1]与paddle.top的关系

最后一行那个问题挺有意思。它的成因是反弹后你把球的位置设成paddle.top - BALL_R - 0.5,但下一帧球还没来得及往上走,colliderect又因为半径判定把它算成碰撞,于是反复触发反弹。防御办法就是在挡板碰撞判定前加一个条件:只有ball_vel[1] > 0(球正在往下飞)时才检测挡板。这一条能省掉一半的诡异反弹。

5. 从玩具到作品:还能往哪儿走

5.1 三个方向的扩展,每个都能撑起一篇新内容

方向一:把砖块数据化。现在是5 行 10 列的均匀网格,改成从一个字符串数组读布局,就能做各种形状的关卡。这件事看着简单,实际上引入了“数据与逻辑分离”这个相当重要的工程思想。

LEVEL = [ "..######..", "..#....#..", ".########.", "..#....#..", "..######..", ]

砖块按字符生成,.表示空位。改关卡只需要改这几行字,代码一行不动。这是从“写死”到“可配置”的第一步。

方向二:加道具。砖块被打掉时按概率掉落道具,挡板接住后触发效果。常见的几种:加宽挡板、分裂成三颗球、球穿透砖块、减速、加分。这一步能练到的东西比想象中多——掉落物的独立生命周期、效果计时器、状态叠加,都是真实项目里天天要处理的东西。

方向三:加音效和视觉反馈。命中时短促的一声、消掉一块砖时的小粒子、球速变化时球颜色渐变。这些对“完整度”的提升远超你的预期,成本又极低。这一步做完,你的项目就不再是“练习作业”了,而是能拿出去给人看的东西。

5.2 关于“芯片数量约束”的一点个人体会

回到开头那个故事。我一直觉得那个年代做游戏的人有一种现在比较少见的思维方式:约束是给定的,你要在约束里找到最优解,而不是先设计再砍需求。芯片数量是死的,成本是死的,那就只能把逻辑压到极限。

这件事放到今天写代码上,其实完全同构。你在做打砖块的时候,其实也一直在做取舍:碰撞用 AABB 还是最近点法,物理用固定步长还是变步长,砖块用列表扫描还是空间分区。每一种选择都有代价,你得知道代价是什么,才知道什么时候该付。

我带过的人里,有一种很典型:功能全做出来了,但代码一塌糊涂,问就是“能跑就行”。也有另一种,一上来就想着上引擎、上框架、上架构,结果一个月过去了还没让球动起来。这两种其实都缺同一件事——对约束的感知。前一种不知道自己欠了多少债,后一种不知道自己把成本花在了不该花的地方。

比较务实的路径是:先用最短的时间做出一个能跑的最小版本,哪怕它只有 150 行、碰撞粗糙、没有音效;跑通了再去看哪里最难看,一次只改一处。这个循环转上五轮,你对这个项目的理解会比看十篇教程都深。那些手感数值,那些“为什么这里要夹一下”的细节,都是在改动中长出来的,不是抄来的。

还有个我自己的小习惯,可能对你有用:每写完一个阶段,把代码从头到尾读一遍,遇到看不懂或者需要停顿一下才能想起来的地方,就是需要写注释或者重构的地方。打砖块这种规模的代码特别适合这么练,因为你还记得住全貌,改起来没有心理负担。等到项目大到看不完的时候,这个习惯就已经养成了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询