《Cocos 游戏开发入门》7.3 碰撞响应、触发器与分层掩码

本节把碰撞检测闭环成碰撞响应:推导 AABB 的最小平移向量(MTV),让重叠的物体沿最短方向被推出;讲清穿透与隧道效应的成因及扫掠检测解法;定义 isTrigger「只报事件、不推物体」的语义;再用位运算实现碰撞层与掩码过滤;末段对照 Cocos Creator 3.8 的 Collider2D、RigidBody2D、Contact2DType 与 IPhysics2DContact。

7.3 碰撞响应、触发器与分层掩码

到 7.2 为止,mini2d 已经能高效地回答「谁碰了谁」。但「知道碰了」和「碰了之后发生什么」是两件事。玩家踩到平台要站住、撞到墙要停下、吃到金币要消失——这些都属于碰撞响应;而金币不把玩家弹开,只通知一声,这又需要触发器;再加上「敌人的子弹不该打到自己人」,就需要分层掩码。本节把这三件事收尾,并给出本章的 Cocos 3.8 完整对照。

本节代码仍在 mini2d/src/physics/,纯 TypeScript、零 cc 依赖,可 vitest 真跑。末节的 Cocos 片段每个符号都对照 cc.d.ts 3.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。

把常见配置写成一张表,比读代码更快:

物体layermask(愿意碰的层)含义
玩家PlayerEnemy | Ground | Pickup不碰子弹(自己射的)
敌人EnemyPlayer | Ground | Bullet会被子弹打
地面GroundPlayer | Enemy静态,只接动态物体
金币PickupPlayer只有玩家能吃
子弹BulletEnemy | 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 帧动画与精灵表播放 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画