《Cocos 游戏开发实战》7.2 碰撞与关卡推进

上一节让方块会跳,但它仍能穿过金币与尖刺。本节给《方块冲刺》装上碰撞规则:先在项目设置里规划 PLAYER/GROUND/COIN/HAZARD 分组与碰撞矩阵,再用 Contact2DType 接触回调把「谁撞了谁」路由成业务事件,接着实现金币拾取、尖刺致死与 goalX 进度推进,并给出 main.scene 的节点树。

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 手感调优与参数化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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