7.2 碰撞与关卡推进
7.1 结束时,玩家方块会跑、会跳、有土狼时间,但它还能穿过金币、穿过尖刺、也不会因为跑到底而通关。这一节给世界装上规则:谁和谁能撞、撞上之后发生什么、什么时候算过关。这是卷二第一次同时动到「编辑器配置」与「运行时脚本」两边,也第一次让 L3 工程文件成为主线。
7.2.1 先规划分组与掩码,再写一行代码
新手最常见的做法是「所有东西都挂上碰撞体,然后在回调里用节点名判断」。这条路在 20 个物体时能跑,到 200 个就会崩:物理引擎每帧都在做大量无意义的宽相配对,而你在回调里做字符串比较。
正确顺序是先分组。打开「项目设置」→「物理」→「2D 碰撞矩阵」,建下面这几个组:
| 组名 | 成员 | 需要检测的对象 |
|---|---|---|
PLAYER | 玩家方块 | GROUND、COIN、HAZARD、GOAL |
GROUND | 地面、平台、砖块 | PLAYER |
COIN | 金币、道具 | PLAYER |
HAZARD | 尖刺、锯片 | PLAYER |
GOAL | 终点线(传感器) | PLAYER |
矩阵的勾选规则只有一条:对称。PLAYER 行勾了 COIN,COIN 行也必须勾 PLAYER,否则接触回调只会在一边触发。这条规则听起来显然,但它是 Cocos 2D 物理里最高频的「金币吃不到」原因——单向勾选时物理引擎确实做了检测,但事件分发只走被勾中的那一侧。
这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 在编辑器里打开会看到:项目设置面板的「物理」页下半部分是 2D 分区,左边一列和顶上一行都是组名,交叉的格子是复选框;gravity 一项默认 (0, -10),但在 960×540 的像素空间里这个值太小,我们会改成 (0, -1800)——这是物理单位与设计分辨率绑定的直接后果,引擎默认值是按米制的世界给的。
矩阵在运行时是只读的,调试时可以打印出来核对:
import { _decorator, Component, PhysicsSystem2D, EPhysics2DDrawFlags, Vec2 } from 'cc';
const { ccclass } = _decorator;
@ccclass('PhysicsBootstrap')
export class PhysicsBootstrap extends Component {
onLoad(): void {
const sys2d = PhysicsSystem2D.instance;
sys2d.enable = true;
sys2d.gravity = new Vec2(0, -1800);
// 打开碰撞体线框:Shape 画形状,Aabb 画包围盒,发布前务必关掉
sys2d.debugDrawFlags = EPhysics2DDrawFlags.Shape | EPhysics2DDrawFlags.Aabb;
// collisionMatrix 是只读的,只能在编辑器里改,这里读出来只为核对
console.log('PLAYER row:', sys2d.collisionMatrix['PLAYER']);
}
}
EPhysics2DDrawFlags 是位掩码,用 | 组合。它把每个碰撞体画成线框,是排查「金币吃不到」「站在地上却一直下落」这类问题最快的工具——比在回调里打日志快一个数量级。注意它是发布前必须关掉的调试开关。
7.2.2 接触回调:把「谁撞了谁」翻译成业务事件
Cocos 的接触回调签名是 (selfCollider, otherCollider, contact),其中 contact 是 IPhysics2DContact | null。一个节点上可能有多个碰撞体(比如玩家身上的「身体」与「头顶踩踏判定」),所以要用 Collider2D.tag 区分:
// assets/scripts/game/collision-router.ts
import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
const { ccclass, property, disallowMultiple } = _decorator;
/** 接触回调的路由:维护接触计数,并把接触翻译成业务事件。 */
@ccclass('CollisionRouter')
@disallowMultiple
export class CollisionRouter extends Component {
@property({ tooltip: '当前与之接触的碰撞体数量,>0 即视为落地' })
contactCount = 0;
onEnable(): void {
const cols = this.getComponents(Collider2D);
for (const c of cols) {
c.on(Contact2DType.BEGIN_CONTACT, this.onBegin, this);
c.on(Contact2DType.END_CONTACT, this.onEnd, this);
}
}
private onBegin(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null): void {
// 传感器(金币、终点线)只收信息,不产生碰撞响应
if (other.sensor) return;
this.contactCount += 1;
}
private onEnd(): void {
this.contactCount = Math.max(0, this.contactCount - 1);
}
get grounded(): boolean { return this.contactCount > 0; }
}
关键点是 contactCount 而不是布尔 grounded。7.1 用布尔标志时,站在两块砖的接缝上会出现「离开 A 砖的瞬间 B 砖还没进入」的一帧假落地,玩家在那 16 毫秒里按跳就会失败。计数法把这个竞态抹平了:只要还有任何一块砖接触着,contactCount 就大于 0。
另外两个细节。@disallowMultiple 保证一个节点上不会挂两份路由(挂两份会导致计数翻倍,永远落地)。other.sensor 的判断把金币与终点线排除在「落地」之外——它们是传感器碰撞体,只收集接触信息、不产生碰撞响应,玩家不应该「站在金币上」。
7.2.3 金币:传感器碰撞体与拾取
金币是一个挂了 CircleCollider2D(勾选 Sensor)的节点,碰到玩家后自毁并计数。为了能单测,计数逻辑抽成纯 TS:
// assets/scripts/core/level-progress.ts
export interface LevelResult {
coins: number;
coinTotal: number;
reachedGoal: boolean;
}
export class LevelProgress {
private collected = 0;
private readonly coinTotal: number;
private readonly goalX: number;
constructor(coinTotal: number, goalX: number) {
this.coinTotal = coinTotal;
this.goalX = goalX;
}
pickCoin(): void { this.collected += 1; }
/** scrolledX 是关卡已经向左滚动的距离。 */
isGoalReached(scrolledX: number): boolean { return scrolledX >= this.goalX; }
get ratio(): number {
return this.coinTotal <= 0 ? 0 : this.collected / this.coinTotal;
}
snapshot(scrolledX: number): LevelResult {
return { coins: this.collected, coinTotal: this.coinTotal, reachedGoal: this.isGoalReached(scrolledX) };
}
}
这份逻辑不 import 任何 cc,可以在 Node 里直接断言「吃 3 个金币后 ratio 是 0.6」。Cocos 侧的 CoinPickup 只做两件事:接触时调 progress.pickCoin(),然后销毁自己。
7.2.4 尖刺与死亡:一次接触就要命
危险物的处理与金币相反:一次接触立即结算,不做计数。同时要注意「重开」的实现方式——重新加载场景是最干净的:
// assets/scripts/game/hazard.ts
import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact, director, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('Hazard')
export class Hazard extends Component {
@property({ tooltip: '死亡后重新加载的场景名' })
sceneName = 'main';
onEnable(): void {
const col = this.getComponent(Collider2D);
if (col) col.on(Contact2DType.BEGIN_CONTACT, this.onHit, this);
}
private onHit(_self: Collider2D, other: Collider2D, _contact: IPhysics2DContact | null): void {
if (other.node.name !== 'Player') return;
// 首次死亡不消耗复活次数,直接重开
director.loadScene(this.sceneName);
}
}
director.loadScene 会卸载当前场景并加载新场景,所有节点重建、所有接触状态清零——比手写「把玩家挪回起点、清空金币、重置进度」可靠得多。代价是有一帧的加载停顿,7.3 会讨论用「重开不换场景」来消除这个停顿。
注意这里用 other.node.name !== 'Player' 做了一次节点名过滤。它只在「玩家节点名固定」时成立,是本书里少数允许用名字判断的地方;更稳的做法是比较 other.group(分组编号)。名字判断写在示例里,是因为它一眼能看懂意图,正式项目请换成分组比较。
7.2.5 关卡推进:让世界向左滚
7.1 定下的「玩家不动、世界动」在这里落地。LevelRoot 每帧向左平移 speed × dt,进度就是已滚动距离与 goalX 的比值:
// assets/scripts/game/level-scroller.ts
import { _decorator, Component, Vec3, Label } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('LevelScroller')
export class LevelScroller extends Component {
@property({ tooltip: '滚动速度(像素/秒)' })
speed = 320;
@property({ tooltip: '终点线相对起点的距离(像素)' })
goalX = 4800;
@property({ type: Label })
progressLabel: Label | null = null;
private scrolled = 0;
update(dt: number): void {
if (this.scrolled >= this.goalX) return;
const step = this.speed * dt;
this.scrolled += step;
this.node.setPosition(this.node.position.x - step, this.node.position.y, 0);
if (this.progressLabel) {
this.progressLabel.string = `${Math.min(100, Math.round((this.scrolled / this.goalX) * 100))}%`;
}
}
get scrolledX(): number { return this.scrolled; }
get finished(): boolean { return this.scrolled >= this.goalX; }
}
把 scrolled 累加而不是每帧读 node.position.x,是为了避免浮点累积误差与「节点被别处挪动」互相干扰:进度只应该有一个真相来源。node.setPosition 那行用 this.node.position.x 读取当前值再减,属于视觉同步,不参与逻辑。
7.2.6 关卡场景的节点树
这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 把 assets/scenes/main.scene 在编辑器里打开,你会看到这样的层级:
Canvas
├── LevelRoot (LevelScroller 组件)
│ ├── Player (Player.prefab 实例, CollisionRouter)
│ ├── Ground (BoxCollider2D, GROUND 组)
│ ├── Coins (若干 Coin 实例, CircleCollider2D + Sensor)
│ ├── Hazards (若干 Spike 实例, BoxCollider2D, HAZARD 组)
│ └── GoalLine (BoxCollider2D + Sensor, GOAL 组)
└── HUD
└── ProgressLabel (Label, 绑到 LevelScroller.progressLabel)
序列化文件里,LevelRoot 节点对应的对象大致是这样。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 编辑器打开 main.scene 后展开层级管理器,看到的节点名与下面的 _name 字段一一对应:
{
"__type__": "cc.Node",
"_name": "LevelRoot",
"_parent": { "__id__": 1 },
"_children": [ { "__id__": 3 }, { "__id__": 4 }, { "__id__": 5 } ],
"_components": [ { "__id__": 6 }, { "__id__": 7 } ],
"_lpos": { "__type__": "cc.Vec3", "x": 480, "y": 270, "z": 0 },
"_layer": 33554432
}
_components 数组里两个元素分别是 cc.UITransform 与脚本组件 LevelScroller(序列化时 __type__ 就是 @ccclass 注册的名字)。把 HUD/ProgressLabel 拖进 LevelScroller 的 progressLabel 槽后,序列化结果里会出现 "progressLabel": { "__id__": N } 这样的引用——引用被序列化成 __id__ 而不是路径,这也是改完 prefab 后必须重新拖一次引用的原因。
7.2.7 收口:本章多了什么
做完这一章,工程里多了这些文件与配置项:
| 类型 | 新增 |
|---|---|
| 脚本 | game/input-bridge.ts、game/player-controller.ts、game/collision-router.ts、game/hazard.ts、game/level-scroller.ts |
| 纯逻辑 | core/input-intent.ts、core/jump-gate.ts、core/level-progress.ts |
| 预制体 | prefabs/Player.prefab |
| 工程设置 | 2D 碰撞分组与矩阵、gravity = (0, -1800) |
| 可配置项 | jumpVelocity、jumpCut、coyoteTime、bufferTime、speed、goalX |
这里有一个隐患:speed、goalX、coinTotal 现在散落在各个组件的属性面板上,改一关的难度要挨个节点点进去。下一章把这一整组数字搬进 assets/configs/levels.json,让关卡变成数据而不是场景里的手工摆放。
阅读导航:上一节:7.1 输入与角色控制 · 下一节:7.3 手感调优与参数化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。