Pygame平台穿透?2招搞定高速移动碰撞检测
2025-03-28 21:38:14
Pygame 高速移动穿透平台?一文搞定碰撞检测
搞 Pygame 开发,特别是平台跳跃游戏的时候,估计不少人都碰到过这么个头疼事:角色速度一快,duang~ 一下就穿过了平台,跟个幽灵似的。尤其是在做那种可以按“下”键快速下落穿过特定平台的功能时(想想《任天堂明星大乱斗》里那种从上层平台落到下层平台的操作),这个问题就更容易冒出来。
用户提供的代码就遇到了这个窘境:当角色从高处平台高速下落时,明明应该在某个中间的平台停住,结果却直接穿了过去。
来看下原始的代码片段:
# 移动函数 (部分)
def move(self, dt, platforms):
# ... 省略水平移动代码 ...
# 垂直移动
self.velocity_y += GRAVITY * dt
# 先移动位置
self.rect.centery += round(self.velocity_y * dt)
# 地面碰撞 (示例简化)
if self.rect.bottom >= WINDOW_HEIGHT:
self.rect.bottom = WINDOW_HEIGHT
self.velocity_y = 0
# 平台碰撞检测 - 问题所在
for platform in platforms:
# 在移动之后才检测碰撞
if self.rect.colliderect(platform):
# 如果速度向下 并且 通过了 jump_check (下面会分析这个函数)
if self.velocity_y >= 0 and self.jump_check(platforms):
# 将角色置于平台上方
self.rect.bottom = platform.top + 1
self.velocity_y = 0
# 跳跃检查函数
def jump_check(self, platforms):
for platform in platforms:
# 检查是否与平台碰撞 & 底部是否离平台顶部很近 & 速度是否向下
if self.rect.colliderect(platform) and abs(self.rect.bottom - platform.top) < 10 and self.velocity_y >= 0:
return True
return False
问题出在哪儿?咱一步步分析。
为什么会穿透? - “隧道效应”
这其实是游戏物理中一个挺经典的问题,通常叫做“隧道效应” (Tunneling)。想象一下游戏是按“帧”来更新的,每一帧之间都有个时间差 dt
。
- 计算位移: 你的代码先根据当前速度
velocity_y
和时间差dt
计算出这一帧角色应该移动的垂直距离delta_y = round(self.velocity_y * dt)
。 - 直接移动: 然后,
self.rect.centery += delta_y
,角色瞬间移动到了新的位置。 - 碰撞检测: 移动之后,代码才开始检查
self.rect.colliderect(platform)
。
麻烦就在这里:如果 velocity_y
特别大,导致 delta_y
也非常大,角色可能在一帧之内,直接从平台上方“跳”到了平台下方,中间完全没有和平台发生重叠的那个瞬间!
因为你的碰撞检测是在角色已经移动到最终位置后才做的,这时如果角色已经“穿透”了平台,colliderect
就根本不会返回 True
,碰撞处理逻辑自然也不会执行。角色就这么“嗖”地一下穿过去了。
至于 jump_check
函数,它的目的是判断角色是不是“稳稳地”落在平台上(底部离平台顶部非常近),并且是从上往下落。这个检查本身没问题,但它是在 colliderect
返回 True
之后才执行的。如果因为“隧道效应”导致 colliderect
返回 False
,jump_check
根本没机会执行。所以,它解决不了穿透问题。
解决方案
要解决这个问题,核心思路就是:不能让角色一次性“跳”过整个平台。 我们需要在移动的过程中更精细地检查碰撞。下面提供两种比较靠谱的方法。
方案一:细分移动步长 (Sub-stepping)
这是个直观且常用的方法。与其一次性移动 delta_y
这么长的距离,不如把它分成好几小步来移动。每移动一小步,就检查一次碰撞。一旦碰到平台,立刻停下,不再继续移动剩下的距离。
原理:
把大的位移拆成多个微小的位移。在每个微小位移后都进行碰撞检测。这样,即使整体速度很快,每次移动的距离也很短,大大降低了单次移动直接跨过整个平台的概率。
代码实现:
我们需要修改 move
函数,特别是处理垂直移动的部分。
import math # 需要导入 math 模块
# --- 假设 Player 类里有这些属性 ---
# self.rect: Pygame Rect 对象
# self.velocity_x: 水平速度
# self.velocity_y: 垂直速度
# SPEED: 包含 'player' 速度倍率的字典
# GRAVITY: 重力加速度
# WINDOW_HEIGHT: 窗口高度
class Player:
# ... (省略 __init__ 等其他方法) ...
def move(self, dt, platforms):
# --- 水平移动处理 (也应该细分,这里为简洁省略,原理类似) ---
delta_x = round(self.velocity_x * SPEED['player'] * dt)
# (为了演示完整性,假设也细分水平移动)
step_x = math.copysign(1, delta_x) # 每次移动1个像素或-1个像素
for _ in range(abs(delta_x)):
self.rect.centerx += step_x
# 水平碰撞检测 (如果需要的话,例如撞墙)
# if check_horizontal_collision(self, obstacles):
# self.rect.centerx -= step_x # 回退一步
# self.velocity_x = 0 # 停止水平移动
# break
# (以上为简化的水平移动细分示例)
# --- 垂直移动处理 (核心部分) ---
self.velocity_y += GRAVITY * dt
delta_y = round(self.velocity_y * dt)
# 确定每一步移动的距离 (通常是1个像素) 和方向
step_y = 1 if delta_y > 0 else -1 # 也可以写成 math.copysign(1, delta_y) 当 delta_y != 0 时
if delta_y == 0: # 如果没有垂直位移,则不需要检查
# 仍然需要检查一次,看是否正好落在平台上 (处理静止在平台上的情况)
self.check_vertical_collisions(platforms, 0) # 传入 delta_y 为 0
else:
# 循环移动,每次移动 step_y 距离
for _ in range(abs(delta_y)):
self.rect.centery += step_y
# 每次移动后立即检查碰撞
collided_platform = self.check_vertical_collisions(platforms, step_y)
if collided_platform:
# 如果发生了碰撞,处理碰撞并停止垂直移动
# 注意:check_vertical_collisions 函数内部应该处理好位置修正
break # 已经撞到平台,不需要再继续本帧的垂直移动
# 检查是否触底 (可以在 check_vertical_collisions 里统一处理,或者在这里补充)
if self.rect.bottom > WINDOW_HEIGHT:
self.rect.bottom = WINDOW_HEIGHT
self.velocity_y = 0
def check_vertical_collisions(self, platforms, step_y):
"""
检查垂直碰撞并处理。
step_y: 当前这一小步的移动方向和距离 (+1 或 -1 或 0)
返回: 发生碰撞的平台对象,或者 None
"""
for platform in platforms:
if self.rect.colliderect(platform):
# --- 从下方撞到平台顶部 (向上移动时) ---
if step_y < 0: # 正在向上移动
# 如果头的顶部 碰到了 平台的底部
if self.rect.top <= platform.bottom and self.rect.top > platform.top: # 确保是从下方接触
self.rect.top = platform.bottom # 把角色顶部贴到平台底部
self.velocity_y = 0 # 速度归零 (或者给一个小的反弹力)
return platform # 返回碰撞的平台
# --- 从上方落到平台顶部 (向下移动或静止时) ---
elif step_y >= 0: # 正在向下移动 或 尝试静止在平台上 (delta_y == 0)
# 原来的 jump_check 逻辑可以整合到这里
# 检查条件:底部接触平台顶部,且接触位置合理(防止侧面穿入)
# abs(self.rect.bottom - platform.top) < 10 这个检查可以保留,允许一点容错
# 或者更精确地检查:确保移动前玩家在平台上方,移动后在下方或正好接触
if self.rect.bottom >= platform.top and self.rect.centery < platform.top: # 确保是从上方落下
# 把角色底部精确放到平台顶部
self.rect.bottom = platform.top + 1 # +1 防止下一帧仍然判定为碰撞
self.velocity_y = 0
return platform # 返回碰撞的平台
# --- 地面碰撞 ---
if self.rect.bottom > WINDOW_HEIGHT:
self.rect.bottom = WINDOW_HEIGHT
self.velocity_y = 0
# 可以模拟一个“地面平台”返回,如果需要统一处理的话
# return ground_object
return None # 没有发生碰撞
# jump_check 函数现在可以不用了,逻辑整合进了 check_vertical_collisions
# def jump_check(self, platforms): ...
说明:
- 水平垂直分离: 最好将水平移动和垂直移动分开处理并细分。这样可以更精确地处理撞墙和落地。
- 步长选择:
step_y
通常设为 1 或 -1,表示每次移动 1 像素。这是最精确但也可能最耗性能的做法(如果delta_y
非常大)。如果性能是瓶颈,可以考虑稍大的步长(比如角色高度的 1/4),但这会牺牲一点精度。 - 碰撞响应:
check_vertical_collisions
函数现在不仅检测碰撞,还负责处理碰撞后的位置修正和速度归零。注意向上碰撞和向下碰撞的处理方式不同。将rect.bottom = platform.top + 1
而不是platform.top
是为了避免下一帧因为浮点数误差等原因再次判定为接触,虽然设为platform.top
理论上也可以,看具体情况调整。 - 整合
jump_check
: 原jump_check
的核心逻辑(判断是否是有效的“着陆”)被整合到了check_vertical_collisions
中关于向下碰撞的部分。abs(self.rect.bottom - platform.top) < 10
这种容错现在可能不再严格需要,因为细分步长能保证接触时位置更精确,但保留它可以增加一点鲁棒性,防止因奇怪的边缘情况卡住。主要判断条件变成玩家移动后底部在平台顶部或以下,并且玩家中心(或移动前的底部)在平台顶部之上。 - 性能考量: 如果角色速度非常快,比如
delta_y
好几百,那么for _ in range(abs(delta_y))
循环次数会很多,每次循环内部还要遍历所有平台进行碰撞检测,可能会影响性能。
进阶技巧:
- 动态步长: 可以根据速度动态调整步长,速度慢时步长小,速度快时适当增大步长,或者设定一个最大循环次数上限,防止极端情况卡死。
- 空间划分: 如果平台数量非常多,每次都遍历所有平台效率很低。可以考虑使用空间划分数据结构(如 Quadtree)来快速筛选出角色附近的平台,只对这些平台进行精细碰撞检测。
方案二:连续碰撞检测 (Simplified CCD / Sweeping)
这种方法更接近物理引擎的思路。它不关心角色在帧开始和结束时的位置,而是关心角色在这一帧内扫过的路径。检查这条路径是否与任何平台相交。
原理:
计算角色在这一帧打算移动的路径(一个从起始rect
延伸到目标rect
的“扫掠区域”或一条线段)。然后检查这个路径/区域是否与任何平台的 rect
相交。如果相交,找到最早的碰撞点,并将角色移动到该点。
对于简单的轴对齐矩形(AABB)和主要关注垂直碰撞的平台游戏,可以简化这个过程:
- 计算出角色这一帧的垂直移动距离
delta_y
。 - 对每个平台,检查:
- 角色的水平范围 (
self.rect.left
到self.rect.right
) 是否与平台的水平范围 (platform.left
到platform.right
) 有重叠。 - 角色的垂直移动区间 (从
self.rect.bottom
到self.rect.bottom + delta_y
) 是否与平台的顶部platform.top
相交。
- 角色的水平范围 (
- 如果两者都满足,说明路径上发生了碰撞。计算出精确的碰撞时间
t
(0到1之间的一个比例) 或碰撞时的y
坐标。 - 在所有可能碰撞的平台中,选择那个碰撞时间最早(即
t
最小,或碰撞y
坐标最接近起始位置)的平台。 - 将角色移动到刚好接触到这个最早碰撞平台的位置,并调整速度。
代码实现:
这个实现稍微复杂一点,因为它需要判断“路径”相交。
import math
class Player:
# ... (省略 __init__ 等其他方法) ...
def move(self, dt, platforms):
# --- 水平移动 (可以沿用方案一的细分,或简化处理) ---
# ... 省略,假设水平移动已处理 ...
# --- 垂直移动 (使用 CCD 思路) ---
self.velocity_y += GRAVITY * dt
delta_y = self.velocity_y * dt # 使用未取整的精确值进行计算
# 如果几乎没有垂直移动,做一次简单的重叠检查可能就够了
if abs(delta_y) < 0.01:
# 检查是否正好与平台接触
self.resolve_static_collision(platforms)
# 检查地面
if self.rect.bottom > WINDOW_HEIGHT:
self.rect.bottom = WINDOW_HEIGHT
self.velocity_y = 0
return # 不需要复杂的 CCD
# 记录移动前的底部位置
start_y_bottom = self.rect.bottom
# 计算移动后的预定底部位置
target_y_bottom = start_y_bottom + delta_y
# 查找最近的碰撞平台和碰撞 '时间' (比例)
earliest_collision_time = 1.0 # 初始化为 1.0 (表示完整移动)
collided_platform = None
collision_y = target_y_bottom # 默认移动到目标位置
for platform in platforms:
# 1. 检查水平方向是否可能碰撞 (粗筛)
if self.rect.right > platform.left and self.rect.left < platform.right:
# 2. 检查垂直路径是否与平台顶部相交
# 情况 A: 向下移动 (delta_y > 0)
if delta_y > 0 and start_y_bottom <= platform.top and target_y_bottom >= platform.top:
# 计算碰撞需要的时间比例 t (如果从 start_y_bottom 移动到 target_y_bottom 用时 1.0)
# (platform.top - start_y_bottom) 是需要移动的距离
# delta_y 是总距离
collision_time = (platform.top - start_y_bottom) / delta_y
# 确保 t 在合理范围内 (0 <= t < 1.0)
# 因为 target_y_bottom >= platform.top, 所以分子 >= 0
# 因为 start_y_bottom <= platform.top, 所以分子 <= delta_y
# 所以 0 <= collision_time <= 1.0 总是成立 (除非 delta_y 是0,但前面已处理)
if collision_time < earliest_collision_time:
earliest_collision_time = collision_time
collided_platform = platform
# 精确的碰撞y坐标应该是平台顶部 (减去一个极小量或正好在上面,根据需要)
# collision_y = platform.top # (更精确,下面统一用 earliest_collision_time 计算)
# 情况 B: 向上移动 (delta_y < 0),假设角色头顶碰到平台底部
elif delta_y < 0:
start_y_top = self.rect.top
target_y_top = start_y_top + delta_y # 注意 delta_y 是负数
if start_y_top >= platform.bottom and target_y_top <= platform.bottom:
# 从 start_y_top 移动到 platform.bottom 的距离是 platform.bottom - start_y_top (负数)
# 总移动距离是 delta_y (负数)
collision_time = (platform.bottom - start_y_top) / delta_y # 两个负数相除得正数
if 0 <= collision_time < earliest_collision_time:
earliest_collision_time = collision_time
collided_platform = platform
# (碰撞位置的 top 应该是 platform.bottom,下面统一计算)
# --- 处理地面碰撞 ---
# 计算触底需要的时间
if delta_y > 0 and target_y_bottom >= WINDOW_HEIGHT:
ground_collision_time = (WINDOW_HEIGHT - start_y_bottom) / delta_y
if 0 <= ground_collision_time < earliest_collision_time:
earliest_collision_time = ground_collision_time
collided_platform = None # 表示撞到地面
# --- 根据最早碰撞时间移动角色 ---
final_delta_y = delta_y * earliest_collision_time
self.rect.centery += round(final_delta_y) # 移动到碰撞点
# 如果发生了碰撞 (earliest_collision_time < 1.0), 则修正位置并停止速度
if earliest_collision_time < 1.0:
# 由于取整和浮点误差,最好在移动后强制修正精确位置
if collided_platform:
if delta_y > 0: # 向下碰撞
self.rect.bottom = collided_platform.top + 1 # 确保在平台上方
elif delta_y < 0: # 向上碰撞
self.rect.top = collided_platform.bottom # 确保在平台下方
else: # 撞到地面
self.rect.bottom = WINDOW_HEIGHT
self.velocity_y = 0 # 停止垂直速度
def resolve_static_collision(self, platforms):
"""处理角色静止或微小移动时与平台的重叠"""
# 这个函数可以简化检查,主要处理已重叠的情况
for platform in platforms:
if self.rect.colliderect(platform):
# 假设主要是解决稍微陷入平台的问题
if abs(self.rect.bottom - platform.top) < 10 and self.velocity_y >= 0:
self.rect.bottom = platform.top + 1
# 是否要在这里设置 velocity_y = 0 取决于游戏逻辑
# 如果允许站在平台上,通常速度设为0
# self.velocity_y = 0
return # 处理完一个就行
说明:
- 基于路径: 这种方法的核心是判断“从 A 到 B”的路径是否穿过了障碍物,而不是检查 B 点本身是否在障碍物内。
- 碰撞时间
t
:earliest_collision_time
(范围 0 到 1) 表示在完整位移delta_y
中,实际能够移动的比例。如果是 1.0,表示没有碰撞;如果是 0.5,表示移动了一半距离就撞上了。 - 精度: 理论上比细分步长更精确,因为它直接计算碰撞点。但也更容易因为浮点数精度问题导致微小的穿透或卡顿,需要仔细调整碰撞后的位置修正。
round()
的使用时机也需要注意。 - 复杂性: 代码实现相对复杂,需要处理好各种边界情况(比如正好在边缘、速度为零等)。
- 水平移动: 同样,完善的 CCD 也应包括水平方向的路径检测。
进阶技巧:
- Sweep Test: 对于矩形,更完善的 CCD 是执行 "Swept AABB" 检测,它考虑了物体自身的体积在移动路径上扫过的空间,而不仅仅是一条线段。这能更精确处理边角碰撞。Pygame 本身不直接提供这个功能,需要自己实现或借助外部库。
- 分离轴定理 (SAT): 对于更复杂的形状(非 AABB)或旋转的物体,SAT 是常用的精确碰撞检测算法,可以用于 CCD。
总结一下
高速移动穿透平台的问题,根源在于游戏循环的离散时间和“先移动后检测”的逻辑。
- 细分步长 (Sub-stepping) 是一个相对简单、直观且效果不错的解决方案,适合大多数平台游戏。它通过将大步拆成小步来避免“跳过”平台。
- 连续碰撞检测 (CCD / Sweeping) 在原理上更精确,直接检查移动路径。实现起来稍复杂,需要处理好路径相交计算和浮点精度问题。
选择哪种方案取决于你的具体需求、游戏复杂度和性能要求。对于大部分 Pygame 平台游戏,细分步长 往往是更容易上手且足够稳健的选择。别忘了,水平移动同样存在穿墙的可能,也应该应用类似的逻辑来处理!