7.3 碰撞响应、触发器与分层掩码
到 7.2 为止,mini2d 已经能高效地回答「谁碰了谁」。但「知道碰了」和「碰了之后发生什么」是两件事。玩家踩到平台要站住、撞到墙要停下、吃到金币要消失——这些都属于碰撞响应;而金币不把玩家弹开,只通知一声,这又需要触发器;再加上「敌人的子弹不该打到自己人」,就需要分层掩码。本节把这三件事收尾,并给出本章的 Cocos 3.8 完整对照。
本节代码仍在
mini2d/src/physics/,纯 TypeScript、零cc依赖,可 vitest 真跑。末节的 Cocos 片段每个符号都对照cc.d.ts3.8.8 核实过。
7.3.1 从「相交」到「分开」:最小平移向量
碰撞响应最简单也最稳的做法,是位置修正:检测到重叠后,把物体沿一个方向推出去,直到刚好不重叠。问题是方向有无数种,我们想要移动距离最短的那一种——这就是最小平移向量(Minimum Translation Vector,MTV)。
对两个 AABB,MTV 的推导异常简洁。记两中心距离为 d,两半宽之和为 h,那么在这个轴上的重叠深度就是 overlap = h - |d|。x、y 两个轴各算一个 overlap,取较小的那个轴来推出,移动距离就最短。
// mini2d/src/physics/mtv.ts
import type { AABB } from './shapes';
export interface Vec2 {
x: number;
y: number;
}
export interface MTV {
/** 把 a 推离 b 的单位方向。 */
readonly normal: Vec2;
/** 需要推出的距离(重叠深度)。 */
readonly depth: number;
}
/** 若 a 与 b 重叠,返回把 a 推离 b 的最小平移向量;否则返回 null。 */
export function resolveAABB(a: AABB, b: AABB): MTV | null {
const dx = b.center.x - a.center.x;
const ox = a.half.x + b.half.x - Math.abs(dx);
if (ox <= 0) return null;
const dy = b.center.y - a.center.y;
const oy = a.half.y + b.half.y - Math.abs(dy);
if (oy <= 0) return null;
if (ox < oy) {
// x 轴穿透更浅:沿 x 推出。dx > 0 表示 b 在 a 右侧,a 应向左退。
return { normal: { x: dx < 0 ? 1 : -1, y: 0 }, depth: ox };
}
return { normal: { x: 0, y: dy < 0 ? 1 : -1 }, depth: oy };
}
符号方向是这段代码最容易写错的地方,值得单独记一句:normal 是「把 a 推出去」的方向,不是 a 指向 b 的方向。当 dx > 0(b 在 a 右侧)时,a 要被推向左,故 normal.x = -1。如果下游用法是「推 b 而不是推 a」,把 normal 取反即可,不要在函数里加参数——保持函数无状态,调用方决定推谁。
应用 MTV 就是把中心沿 normal 平移 depth:
export function applyMTV(a: AABB, mtv: MTV): AABB {
return {
center: {
x: a.center.x + mtv.normal.x * mtv.depth,
y: a.center.y + mtv.normal.y * mtv.depth,
},
half: a.half,
};
}
7.3.2 谁被推动:质量与「可动性」
位置修正有个副作用:如果两边都推,玩家撞墙时墙也会被推走。真实物理引擎按质量分配位移——重的物体动得少。mini2d 用一个更朴素的规则:静态物体永远不动,动态物体承担全部位移。
export interface Body {
readonly id: number;
aabb: AABB;
velocity: Vec2;
layer: number;
mask: number;
isStatic: boolean;
isTrigger: boolean;
mass: number;
}
/** 按质量与静态性分配 MTV 位移,返回两个物体各自的新 AABB。 */
export function distributeMTV(
a: Body,
b: Body,
mtv: MTV,
): { a: AABB; b: AABB } {
if (a.isStatic && b.isStatic) {
return { a: a.aabb, b: b.aabb }; // 两个都不动,交给上层决定
}
if (a.isStatic) {
return { a: a.aabb, b: applyMTV(b.aabb, { normal: negate(mtv.normal), depth: mtv.depth }) };
}
if (b.isStatic) {
return { a: applyMTV(a.aabb, mtv), b: b.aabb };
}
// 都动态:按质量反比分配
const total = a.mass + b.mass;
const aShare = b.mass / total;
const bShare = a.mass / total;
return {
a: applyMTV(a.aabb, { normal: mtv.normal, depth: mtv.depth * aShare }),
b: applyMTV(b.aabb, { normal: negate(mtv.normal), depth: mtv.depth * bShare }),
};
}
function negate(v: Vec2): Vec2 {
return { x: -v.x, y: -v.y };
}
《方块冲刺》里玩家和敌人质量相近,静态平台承担 0 位移,效果就是玩家被稳稳顶在平台上方。若两个都是动态(比如敌人互相挤压),按质量反比分配能避免「轻的被弹飞、重的纹丝不动」的怪异手感。
7.3.3 穿透与隧道效应
位置修正只在「这一帧结束时确实重叠」时生效。但如果物体太快,它可能在一帧内从障碍物的左侧直接跳到右侧,中间从未被采样到重叠——检测函数永远返回 false,物体就穿墙而过。这叫隧道效应(tunneling)。
《方块冲刺》里最典型的场景是冲刺道具:玩家水平速度 1200 px/s,60 帧下每帧移动 20 px;一堵 16 px 厚的墙会在两帧之间被整个跳过。
解法之一是扫掠 AABB(swept AABB):不再问「现在重叠吗」,而是问「在这一帧的位移路径上,会撞上吗」。把物体 b 按 a 的半宽半高膨胀(Minkowski 差),再把 a 收缩成一个点,问题就变成「点沿射线运动,何时进入矩形」:
/** 返回 [0,1] 内的首次碰撞时间;无碰撞返回 null。 */
export function sweptAABB(a: AABB, vel: Vec2, b: AABB, dt: number): number | null {
const ex = b.half.x + a.half.x;
const ey = b.half.y + a.half.y;
const dx = b.center.x - a.center.x;
const dy = b.center.y - a.center.y;
const vx = vel.x * dt;
const vy = vel.y * dt;
let tEnter = -Infinity;
let tExit = Infinity;
if (vx !== 0) {
const t1 = (-ex - dx) / vx;
const t2 = (ex - dx) / vx;
tEnter = Math.max(tEnter, Math.min(t1, t2));
tExit = Math.min(tExit, Math.max(t1, t2));
} else if (Math.abs(dx) > ex) {
return null; // x 方向不动且已分离
}
if (vy !== 0) {
const t1 = (-ey - dy) / vy;
const t2 = (ey - dy) / vy;
tEnter = Math.max(tEnter, Math.min(t1, t2));
tExit = Math.min(tExit, Math.max(t1, t2));
} else if (Math.abs(dy) > ey) {
return null;
}
if (tEnter > tExit || tEnter > 1 || tExit < 0) return null;
return Math.max(tEnter, 0);
}
返回的时间在 [0,1] 之间,表示「这一帧的第几成处撞上」。拿到它就能把物体推到碰撞点、并让速度反弹。代价是每次检测都要除法和多组比较,比 7.1 的 AABB 贵得多,所以只对高速物体启用:这就是 Cocos 里 RigidBody2D.bullet 开关的语义。
7.3.4 触发器:只报事件,不推物体
金币、检查点、伤害区域这些东西需要「知道玩家进来了」,但不该把玩家物理地推开。这类碰撞体叫触发器(trigger)。实现上它只差一个布尔字段:
// 在 7.2 的 Body 基础上补上 velocity / isTrigger / mass
export interface Body {
readonly id: number;
aabb: AABB;
velocity: Vec2;
layer: number;
mask: number;
isStatic: boolean;
isTrigger: boolean;
mass: number;
}
export interface Contact {
readonly a: Body;
readonly b: Body;
readonly phase: 'begin' | 'stay' | 'end';
}
响应的分派逻辑因此分成两支:
export function respond(a: Body, b: Body, mtv: MTV | null): Contact | null {
if (mtv === null) return null;
if (a.isTrigger || b.isTrigger) {
// 触发器不参与位置修正,只产生事件
return { a, b, phase: 'begin' };
}
const moved = distributeMTV(a, b, mtv);
a.aabb = moved.a;
b.aabb = moved.b;
return null; // 物理响应不产生事件(需要时另行上报)
}
关键点是触发器必须早于位置修正返回。如果先推了再判断,金币会把玩家挤开,手感立刻变怪。事件本身还需要「进入 / 停留 / 离开」三态管理:begin 只在从不相交变相交的那一帧发一次,stay 每帧发,end 在分开的那一帧发。实现上给每对接触维护一个 Set<number>(键用 a.id * BIG + b.id),比对上一帧的结果即可。
7.3.5 碰撞层与掩码:位运算
最后一个问题:「谁能和谁碰」。用两层布尔数组会很浪费,标准做法是位掩码——每个物体有两个数:layer(我属于哪一层,通常只有一位)与 mask(我愿意和哪些层碰,可以有多个位)。
export const LAYER = {
Player: 1 << 0, // 1
Enemy: 1 << 1, // 2
Ground: 1 << 2, // 4
Pickup: 1 << 3, // 8
Bullet: 1 << 4, // 16
} as const;
export function canCollide(a: Body, b: Body): boolean {
return (a.mask & b.layer) !== 0 && (b.mask & a.layer) !== 0;
}
canCollide 要求双向同意:a 愿意碰 b 的层,且 b 也愿意碰 a 的层,才算数。这个对称性避免了「单向碰撞」这种违反直觉的状态——如果 a 能推 b,b 就一定能感觉到 a。
把常见配置写成一张表,比读代码更快:
| 物体 | layer | mask(愿意碰的层) | 含义 |
|---|---|---|---|
| 玩家 | Player | Enemy | Ground | Pickup | 不碰子弹(自己射的) |
| 敌人 | Enemy | Player | Ground | Bullet | 会被子弹打 |
| 地面 | Ground | Player | Enemy | 静态,只接动态物体 |
| 金币 | Pickup | Player | 只有玩家能吃 |
| 子弹 | Bullet | Enemy | Ground | 打敌人,撞地面消失 |
在宽相查询到候选对之后、窄相之前,先跑一次 canCollide,能省下大量注定无用的精确检测——这也是 7.2 分层思想的延续:先按廉价条件过滤,再按昂贵条件计算。
// 接在 7.2 的 Broadphase.collect 之后
export function filterAndResolve(candidates: Array<[Body, Body]>): Contact[] {
const contacts: Contact[] = [];
for (const [a, b] of candidates) {
if (!canCollide(a, b)) continue;
const c = respond(a, b, resolveAABB(a.aabb, b.aabb));
if (c) contacts.push(c);
}
return contacts;
}
7.3.6 与 Cocos Creator 3.8 对照
Cocos 3.8 把上面四件事分别落在四个 API 上,逐个对照。
碰撞体与响应。 Collider2D 家族(BoxCollider2D / CircleCollider2D / PolygonCollider2D)负责形状,RigidBody2D 负责质量与运动。刚体类型用 ERigidBody2DType 表示:Static(不动)、Kinematic(按速度动,不受力)、Dynamic(受重力与冲量)。这正好对应我们「静态不动、动态承担位移」的二分。
import { _decorator, Component, RigidBody2D, ERigidBody2DType } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PlayerBody')
export class PlayerBody extends Component {
@property(RigidBody2D)
body: RigidBody2D | null = null;
onLoad(): void {
const rb = this.body;
if (!rb) return;
rb.type = ERigidBody2DType.Dynamic;
rb.gravityScale = 1;
rb.fixedRotation = true; // 2D 横版角色不翻滚
rb.bullet = true; // 抗隧道,等价于我们的 sweptAABB 开关
}
}
触发器。 Cocos 3.8 里没有 isTrigger 这个名字,等价语义是 Collider2D.sensor:文档原话是「传感器类型的碰撞体会产生碰撞回调,但是不会发生物理碰撞效果」——与 7.3.4 的定义完全一致。把 sensor 设为 true 即成为触发器。
接触回调。 触发与碰撞事件通过 Collider2D 的事件接口订阅,事件名是 Contact2DType 的成员:BEGIN_CONTACT(开始接触)、END_CONTACT(结束接触)、PRE_SOLVE、POST_SOLVE(求解前/后,可拿到冲量)。回调签名是 (self, other, contact),contact 类型为 IPhysics2DContact,暴露 colliderA / colliderB 与 getWorldManifold()。
import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('Pickup')
export class Pickup extends Component {
@property
score = 1;
private collider: Collider2D | null = null;
onEnable(): void {
this.collider = this.getComponent(Collider2D);
this.collider?.on(Contact2DType.BEGIN_CONTACT, this.onBegin, this);
}
onDisable(): void {
this.collider?.off(Contact2DType.BEGIN_CONTACT, this.onBegin, this);
}
private onBegin(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null): void {
// 触发器只在这一刻报一次事件,不推物体
console.log('吃到金币', self.node.name, other.node.name, this.score, contact !== null);
}
}
分层掩码。 Cocos 用 Collider2D.group 设置分组,分组之间的碰撞关系由项目设置里的**碰撞矩阵(collision matrix)**决定,运行时可通过 PhysicsSystem2D.instance.collisionMatrix 读取。与 mini2d 的差异在于:mini2d 的 mask 挂在每个物体上、可以逐实例调整;Cocos 的矩阵是全局的、按分组配置——前者更灵活,后者更省内存,也更不容易在几百个物体间配错。
把全章收拢成一句话:mini2d 的每一步你都能看见、能单测、能改;Cocos 把同样的每一步封装进引擎,换来的是不用自己维护的性能与跨端一致性。第 18 章会把两套实现并排对照。下一章换一个话题:物体怎么动起来才好看——帧动画与补间缓动。
阅读导航:上一节:7.2 空间划分与宽相 · 下一节:8.1 帧动画与精灵表播放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。