《Cocos 游戏开发高级编程》5.3 自定义碰撞与查询

讲清 Cocos 2D 物理的主动查询与自定义碰撞响应:用 PhysicsSystem2D.instance 的 raycast、testPoint、testAABB 做射线与区域查询,用 Contact2DType 的四个回调写自定义响应,并指出 IPhysics2DContact 的字段是 colliderA/colliderB 而非 selfCollider/otherCollider。

5.3 自定义碰撞与查询

前两节讲的都是「被动的物理」:你摆好刚体和碰撞体,引擎自己算、自己回调。但游戏里有大量需求是主动查询——「主角脚下有没有地面」「这个点击落在哪个单位上」「以玩家为中心 200 像素内有哪些敌人」。这些不能靠被动碰撞回调,得靠射线与区域查询。这一节讲三件事:主动查询 API、自定义碰撞响应的正确写法,以及它们背后的几何算法。

5.3.1 射线检测:PhysicsSystem2D.instance.raycast

射线检测的入口在 PhysicsSystem2D.instance 上,签名(engine/cc.d.ts 第 47480 行附近):

raycast(
    p1: math.IVec2Like,
    p2: math.IVec2Like,
    type?: ERaycast2DType,
    mask?: number,
): readonly Readonly<RaycastResult2D>[];

它接收起止两点(不是「起点 + 方向 + 长度」),返回一个结果数组。注意注释里那句关键约束:射线会忽略包含起点的碰撞体——如果起点在某个碰撞体内部,那个碰撞体不会被返回。想做「从角色中心出发检测」,要么把起点抬高一点,要么用 testPoint。

四个 ERaycast2DType 取值的语义(engine/cc.d.ts 第 47281 行):

取值含义何时用
Closest(默认,0)只返回最近的碰撞点子弹命中、视线检测
Any(1)命中任意一个就停只要「有没有」,不要精确结果
AllClosest(2)每个碰撞体各取最近点多目标判定,同物体只算一次
All(3)所有碰撞点全返回精确的穿透分析

一个容易踩的坑:同一个碰撞体若由多个 fixture 组成(比如多边形被分解),AllClosest 和 All 会对同一物体返回多条结果。去重要靠 RaycastResult2D.collider 字段,而不是靠位置。

import { _decorator, Component, PhysicsSystem2D, Vec2, ERaycast2DType } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('GroundProbe')
export class GroundProbe extends Component {
    @property
    probeLength = 8; // 向下探测的像素长度

    /** 角色脚下是否有地面 */
    hasGroundBelow(): boolean {
        const from = new Vec2(this.node.worldPosition.x, this.node.worldPosition.y);
        const to = new Vec2(from.x, from.y - this.probeLength);
        const hits = PhysicsSystem2D.instance.raycast(from, to, ERaycast2DType.Closest);
        return hits.length > 0;
    }
}

RaycastResult2D 的字段(engine/cc.d.ts 第 47327 行):collider(命中的碰撞体)、fixtureIndex、point(世界坐标交点)、normal(法线)、fraction(交点占射线长度的比例,0–1)。fraction 特别有用——它让你不依赖 point 也能按比例算距离。

5.3.2 点查与区域查询

除了射线,还有两个更轻的查询:

import { PhysicsSystem2D, Vec2, Rect } from 'cc';

const ps = PhysicsSystem2D.instance;

// 点查:给定世界坐标点,返回包含它的所有碰撞体
const point = new Vec2(120, 80);
const underPoint = ps.testPoint(point);

// 区域查:给定世界坐标 AABB,返回与之相交的所有碰撞体
const area = new Rect(0, 0, 200, 200);
const inArea = ps.testAABB(area);

两者返回的都是 readonly Collider2D[],不是 RaycastResult2D——没有交点、没有法线,只有「谁在里面」。所以它们适合做粗筛:先用 testAABB 把附近的对象捞出来,再对候选做精确判定。

《方块冲刺》里的典型用法:

  • 点击选中单位:testPoint 拿到点下的所有碰撞体,按 tag 或 group 挑出你要的那个。
  • AOE 技能范围:testAABB 用技能包围盒捞候选,再算真实距离。
  • 相机可见性裁剪:用视口矩形 testAABB,得到需要在物理上激活的碰撞体。

一个性能提醒:testAABB 传的 Rect 是世界坐标,不是节点本地坐标。用 UITransform.getBoundingBoxToWorld() 或自己换算,别把本地坐标直接丢进去。

5.3.3 自定义碰撞响应:Contact2DType 四个回调

被动碰撞回调挂在 Collider2D 上,用事件名来自 Contact2DType(engine/cc.d.ts 第 47318 行):

import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('CoinPickup')
export class CoinPickup extends Component {
    @property(Collider2D)
    collider: Collider2D | null = null;

    onEnable() {
        // 四个事件名:BEGIN_CONTACT / END_CONTACT / PRE_SOLVE / POST_SOLVE
        this.collider?.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
        this.collider?.on(Contact2DType.END_CONTACT, this.onEndContact, this);
    }

    onDisable() {
        // 必须成对 off,否则组件销毁后回调仍会触发
        this.collider?.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
        this.collider?.off(Contact2DType.END_CONTACT, this.onEndContact, this);
    }

    private onBeginContact(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
        // 只打标记,重活留到 update(见 5.2.5)
        this.picked = true;
    }

    private onEndContact(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
        this.picked = false;
    }

    picked = false;
}

这里是本节最重要的一条纠正。 网上很多教程(包括一些老版本示例)在回调里写 contact.selfCollider / contact.otherCollider,这两个字段在 Cocos Creator 3.8 的 IPhysics2DContact 上不存在。真实字段是:

// IPhysics2DContact(@cocos/creator-types@3.8.8,engine/cc.d.ts 第 47652 行)
colliderA: Collider2D | null;
colliderB: Collider2D | null;
disabled: boolean;
disabledOnce: boolean;
getWorldManifold(): IPhysics2DWorldManifold;
getManifold(): IPhysics2DManifold;
getImpulse(): IPhysics2DImpulse | null;   // 仅在 POST_SOLVE 中有效
isTouching(): boolean;
setTangentSpeed(value: number): any;
getTangentSpeed(): number;
setFriction(value: number): any;
getFriction(): number;
setRestitution(restitution: number): any;
getRestitution(): number;

所以要用 colliderA / colliderB。注意 colliderA 与回调第一参数 self 的对应关系不保证固定——Box2D 里哪个 fixture 排在前面由内部顺序决定。要判断「是不是我」,请用传入的 self 参数,而不是 contact.colliderA。

四个回调的时机与能力:

事件时机能做什么
BEGIN_CONTACT接触开始打标记、计数、触发逻辑
END_CONTACT接触结束清理标记
PRE_SOLVE求解前,每次子步setFriction / setRestitution / disabledOnce
POST_SOLVE求解后,每次子步getImpulse() 取冲量(伤害计算)

两个只在这两个回调里有效的能力:setFriction / setRestitution 必须在 PRE_SOLVE 里调,getImpulse() 必须在 POST_SOLVE 里调。在 BEGIN_CONTACT 里调它们不会报错,但也不会有任何效果——这是「静默失效」,最难查的一类问题。

5.3.4 传感器、分组与 enabledContactListener

三个让回调「按需触发」的开关:

  1. sensor = true:碰撞体只产生回调、不产生碰撞力。金币、检查点、伤害区用它。
  2. group 过滤:碰撞体有 group,配合项目设置里的碰撞矩阵(PhysicsSystem2D.instance.collisionMatrix)决定哪些分组之间才检测。让「玩家子弹」只和「敌人」检测,物理开销直接降一个量级。
  3. enabledContactListener:刚体上的布尔量,关掉后该刚体不产生接触回调。适合大批量「只需要物理模拟、不需要逻辑回调」的物体。
import { RigidBody2D, Collider2D } from 'cc';

// 一个只触发逻辑、不参与碰撞力的金币:开传感器
function setupCoin(collider: Collider2D): void {
  collider.sensor = true;
}

// 一个只需要被物理推动、但不需要回调的碎石:关掉接触监听
function setupDebris(body: RigidBody2D): void {
  body.enabledContactListener = false;
}

sensor 是每碰撞体的,enabledContactListener 是每刚体的,粒度不同,别搞混。

5.3.5 L1:射线与 AABB 的相交算法

射线检测的核心是 slab 算法(射线与 AABB 求交),纯几何、无引擎依赖,可以直接单测:

// mini2d/physics/ray.ts —— 纯 TS,无 cc 依赖
export interface AABB { minX: number; minY: number; maxX: number; maxY: number; }

/** 返回射线与 AABB 的最近命中比例 t(0–1),未命中返回 null */
export function rayAabb(
    ox: number, oy: number,   // 射线起点
    dx: number, dy: number,   // 射线方向(已含长度,即终点 - 起点)
    box: AABB,
): number | null {
    const invX = dx !== 0 ? 1 / dx : Infinity;
    const invY = dy !== 0 ? 1 / dy : Infinity;

    let tMin = 0;
    let tMax = 1;

    // X 轴 slab
    let t1 = (box.minX - ox) * invX;
    let t2 = (box.maxX - ox) * invX;
    if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; }
    tMin = Math.max(tMin, t1);
    tMax = Math.min(tMax, t2);

    // Y 轴 slab
    t1 = (box.minY - oy) * invY;
    t2 = (box.maxY - oy) * invY;
    if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; }
    tMin = Math.max(tMin, t1);
    tMax = Math.min(tMax, t2);

    return tMin <= tMax ? tMin : null;
}

测试验证「正命中」与「擦身而过」两种边界:

import { describe, it, expect } from 'vitest';
import { rayAabb } from '../mini2d/physics/ray';

describe('rayAabb', () => {
    const box = { minX: 10, minY: -5, maxX: 20, maxY: 5 };

    it('水平射线正命中', () => {
        const t = rayAabb(0, 0, 30, 0, box);
        expect(t).toBeCloseTo(1 / 3, 5); // 命中点 x=10
    });

    it('高于盒子则未命中', () => {
        expect(rayAabb(0, 100, 30, 0, box)).toBeNull();
    });
});

理解这段代码,你就理解了 RaycastResult2D.fraction 是什么——它就是这里的 tMin。引擎返回的 point 不过是用 起点 + t * 方向 反算出来的世界坐标。

5.3.6 工程决策

给《方块冲刺》的落地建议:

  1. 主动查询要限量。 射线和区域查询都不便宜,别在 update 里对每个敌人各发一条射线。合并成一次 testAABB + 本地精筛。
  2. 回调里只打标记。 5.2.5 讲过——BEGIN_CONTACT / POST_SOLVE 是每子步触发的,里面做重活会让物理耗时虚高。
  3. 用分组代替代码里的 if。 能用碰撞矩阵排除的检测,不要靠回调里判断 group 再 return——前者是引擎级剔除,后者已经付了检测成本。
  4. POST_SOLVE + getImpulse() 是伤害系统的正确落点。 想按「撞击力度」算伤害,这是引擎给的原生数据;不要在 BEGIN_CONTACT 里用相对速度手算。
  5. 回调成对 on / off。 在 onEnable 里 on、onDisable 里 off,是唯一不会泄漏的写法。

物理这一章到这里结束。下一章转向另一个重子系统——动画:从 AnimationClip 的求值过程讲起,看引擎每帧到底在哪些曲线、哪些骨骼上花了时间。

阅读导航:上一节:5.2 物理调试与性能 · 下一节:6.1 动画系统内部与求值 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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