《Cocos 游戏开发入门》14.1 结算管线与帧对齐

《方块冲刺》里伤害、治疗、状态变更散落各处即时生效,谁先执行谁就赢,顺序一抖动就出双杀与负血量。本节为 mini2d 写一条结算管线:把变更入队、按优先级与序列号确定性排序、同一帧内统一 flush,再用事件冒泡串起受击、死亡与掉落;末节对照 Cocos Creator 3.8 的 Node.emit 与 executionOrder。

14.1 结算管线与帧对齐

前面 13 章我们写完了状态机、行为树、寻路与关卡生成,mini2d 已经能跑出一局完整的《方块冲刺》。但战斗结算一直是最随意的部分:碰撞回调里直接扣血,道具拾取里直接回血,踩到陷阱里直接把玩家置为死亡。代码能跑,测试也过,直到玩家在论坛贴出「两个炸弹同时炸,我满血被判死」的录像。这一节把结算从「随手改」收敛成一条管线:先入队、再排序、最后在同一帧内统一结算。末节给出 Cocos Creator 3.8 的对照。

本节把《方块冲刺》的战斗逻辑推进到「确定性」这一步:同一份输入,在任何机器上跑出的结算顺序与结果都一致。这既是修 bug,也是为第 16 章的确定性回放打地基。

14.1.1 即时结算的翻车现场

先看反面教材。很多小游戏的伤害处理就是就地改字段:

// 反面教材:谁先被调用,谁先改世界
function hit(target: Actor, dmg: number): void {
  target.hp -= dmg;
  if (target.hp <= 0) {
    kill(target);
  }
}

// 同一帧里两个来源都调用了 hit
hit(player, 3);
hit(player, 5);

这段代码看着没问题,但它把「结算顺序」交给了调用顺序。真实项目里,调用顺序取决于碰撞回调的遍历顺序、道具拾取的触发时机、AI 更新与物理步进的先后——任何一个环节变动,结算结果就可能不同。典型的三种翻车:

现象根因表现
双杀判负两个致死伤害都先各自判定死亡玩家与怪物同归于尽,却只结算一次奖励
负血量先扣血再判死,中间没有夹断hp 掉到 -40,血条渲染越界
治疗浪费治疗排在死亡之后玩家先死,之后回血无效,界面却显示「已回复」

问题的根子在于:伤害、治疗、状态变更都是「对世界的写操作」,而写操作的顺序是有语义的。把它们拆散在各个系统里就地执行,等于放弃了顺序的定义权。管线的第一件事,就是把所有写操作延迟到一处、排队、统一执行。

14.1.2 把「想做」与「做」分开

管线模型只有两个阶段:收集与结算。收集阶段谁都可以提交「我想改世界」的意图,但没人真的改;结算阶段由管线统一执行这些意图。意图用一个可辨识联合(discriminated union)表达:

export type OpKind = 'damage' | 'heal' | 'status' | 'loot';

export interface Op {
  readonly kind: OpKind;
  readonly source: number;
  readonly target: number;
  readonly amount: number;
  readonly priority: number;
  readonly seq: number;
}

export function makeOp(kind: OpKind, source: number, target: number, amount: number, priority: number, seq: number): Op {
  return { kind, source, target, amount, priority, seq };
}

seq 是提交时分配的单调递增序号,它保证了「同一优先级内的先后」也完全确定。注意这里没有把「扣血」写进 Op,Op 只是意图:真正的扣血发生在结算阶段,由管线根据当时的血量决定是否夹断、是否触发死亡。这样即便同一帧提交了 100 个伤害,世界也只在结算的那一刻被改写一次。

14.1.3 优先级与序列号:确定性排序

结算前对队列排序,排序键是「优先级升序,同优先级按序号升序」:

export function sortOps(ops: readonly Op[]): Op[] {
  return ops.slice().sort((a, b) => {
    if (a.priority !== b.priority) return a.priority - b.priority;
    return a.seq - b.seq;
  });
}

两级键缺一不可。优先级表达语义上的阶段划分:状态结算(减速到期、无敌结束)必须早于伤害,否则「本帧刚失效的无敌」会错误地挡下伤害;伤害必须早于死亡,否则死亡判定读不到最新血量。序号则解决同优先级内的平局——JS 的 Array.prototype.sort 在 V8 里是稳定排序,但不要依赖引擎的稳定性,显式用 seq 把顺序钉死,换引擎、换打包工具都不会变。

一张固定的优先级表比散落的魔数好维护:

优先级阶段典型操作
0环境状态持续时间推进、减速/加速到期
10治疗回复生命、护盾充能
20伤害扣血、破盾、吸血
30死亡标记死亡、触发遗言、结算击杀数
40掉落生成拾取物、发放金币

表里有个反直觉的决定:治疗排在伤害之前。这来自《方块冲刺》的玩法约定——同一帧的「回血」与「掉血」同时到达时,先回血再扣血,玩家的存活判定以结算后的最终值为准。这个约定本身可以改,但必须只在一处定义,而不是每个系统各自拍脑袋。

14.1.4 帧对齐:单帧内一次性 flush

管线本体很短,关键是 flush 只被主循环调用一次:

export interface World {
  hp: Map<number, number>;
  dead: Set<number>;
  onDeath(actor: number, killer: number): void;
}

export class SettlementPipeline {
  private queue: Op[] = [];
  private seq = 0;

  enqueue(kind: OpKind, source: number, target: number, amount: number, priority: number): void {
    this.queue.push(makeOp(kind, source, target, amount, priority, this.seq++));
  }

  /** 每帧调用一次:排序 -> 依次结算 -> 清空队列 */
  flush(world: World): void {
    if (this.queue.length === 0) return;
    const ops = sortOps(this.queue);
    this.queue.length = 0;
    for (const op of ops) {
      this.apply(world, op);
    }
  }

  private apply(world: World, op: Op): void {
    const cur = world.hp.get(op.target);
    if (cur === undefined || world.dead.has(op.target)) return;
    if (op.kind === 'damage') {
      const next = Math.max(0, cur - op.amount);
      world.hp.set(op.target, next);
      if (next === 0) {
        world.dead.add(op.target);
        world.onDeath(op.target, op.source);
      }
    } else if (op.kind === 'heal') {
      world.hp.set(op.target, Math.min(100, cur + op.amount));
    }
  }
}

三个细节值得停下来看。第一,flush 开头 this.queue.length = 0 先把队列摘走再遍历,而不是边遍历边 shift——后者在 onDeath 回调里再次 enqueue 时会引发重入,把新操作插进本次结算,破坏「一帧一次」的边界。第二,apply 里 Math.max(0, ...) 与 dead.has 检查是幂等护栏:即便排序有误,也不会出现负血量或对尸体二次结算。第三,被 dead 挡下的操作静默丢弃而不是报错——死亡之后同帧的伤害本就不该再生效。

主循环里的接法就是把 flush 放在物理步进之后、渲染之前:

export function frame(pipeline: SettlementPipeline, world: World, dt: number): void {
  stepPhysics(world, dt);   // 物理可能 enqueue 碰撞伤害
  updateAI(world, dt);      // AI 可能 enqueue 技能效果
  pipeline.flush(world);    // 同一帧内统一结算
  render(world);            // 渲染读到的一定是结算后的最终状态
}

flush 的位置不能随便挪。放在 stepPhysics 之前,本帧新产生的碰撞伤害要等下一帧,玩家会看到「撞上去了血条却晚一拍掉」;放在 render 之后,渲染的就是旧数值,血条滞后一帧。正确位置永远是「所有会 enqueue 的系统之后、所有会读数值的系统之前」。

确定性是可以被测试锁住的。用 vitest 写一个「打乱提交顺序,结果必须一致」的用例,就能在重构时立刻发现顺序回归:

import { describe, expect, it } from 'vitest';

describe('SettlementPipeline', () => {
  it('提交顺序不影响结算结果', () => {
    const run = (order: number[]): number[] => {
      const world: World = { hp: new Map([[1, 10]]), dead: new Set(), onDeath: () => {} };
      const p = new SettlementPipeline();
      for (const i of order) {
        p.enqueue('damage', 99, 1, 3, 20);
      }
      p.flush(world);
      return [world.hp.get(1) ?? -1];
    };
    // 三个 3 点伤害,无论按什么顺序提交,最终都是 1
    expect(run([0, 1, 2])).toEqual(run([2, 0, 1]));
    expect(run([1, 2, 0])).toEqual([1]);
  });
});

这类测试不需要引擎、不需要渲染,纯逻辑跑在 Node 里毫秒级完成——这正是把结算从渲染里剥离出来的回报。

14.1.5 事件冒泡:受击 → 死亡 → 掉落

结算过程中会产生连锁反应:一次致死伤害不仅要扣血,还要触发死亡、死亡又要触发掉落。把这些塞进 apply 会让它越来越臃肿。更好的做法是让管线只负责改数值,把「发生了什么」以事件形式抛出去,由订阅者决定后续:

export type BattleEvent =
  | { readonly type: 'hit'; readonly actor: number; readonly damage: number }
  | { readonly type: 'death'; readonly actor: number; readonly killer: number }
  | { readonly type: 'loot'; readonly actor: number; readonly table: string };

export type BattleListener = (ev: BattleEvent) => void;

export class BattleBus {
  private listeners: BattleListener[] = [];

  on(fn: BattleListener): void {
    this.listeners.push(fn);
  }

  emit(ev: BattleEvent): void {
    for (const fn of this.listeners.slice()) {
      fn(ev);
    }
  }
}

冒泡链本身用几个订阅者串起来,逻辑清晰且可组合:

export function wireBubble(bus: BattleBus, pipeline: SettlementPipeline, loot: LootService): void {
  bus.on((ev) => {
    if (ev.type === 'hit') {
      // 受击:播放受击音效、弹伤害数字(纯表现,不改数值)
    }
    if (ev.type === 'death') {
      // 死亡:把掉落意图重新入队,下一帧结算
      pipeline.enqueue('loot', ev.killer, ev.actor, 1, 40);
      bus.emit({ type: 'loot', actor: ev.actor, table: loot.pickTable(ev.actor) });
    }
  });
}

注意 death 触发的掉落是下一帧才结算的——因为当前帧的队列已经摘走了。这个「延迟一帧」不是缺陷而是特性:它保证了单帧结算的边界干净,也让「死亡当帧是否还能拾取」这类问题有了明确答案(不能)。如果确实需要同帧完成,把掉落直接写进 apply 的死亡分支,但要在代码里显式注明,别让读者以为它跟其他 op 一样排队。

14.1.6 Cocos 对照:Node.emit 与执行顺序

在 Cocos Creator 3.8 里,事件分发有现成的 EventTarget(cc.d.ts 中 export const EventTarget: new (...args: any[]) => ...),节点级的 Node 也自带 on / off / emit / targetOff。我们的 BattleBus 可以直接换成 EventTarget:

import { EventTarget, Node, _decorator, Component } from 'cc';
const { ccclass, executionOrder } = _decorator;

@ccclass('BattleBus')
export class BattleBus extends Component {
  private bus = new EventTarget();

  onLoad(): void {
    this.bus.on('death', this.onDeath, this);
  }

  emitHit(actor: Node, damage: number): void {
    // Node.emit 会同步通知该节点上的所有监听者
    actor.emit('hit', damage);
    this.bus.emit('death', actor);
  }

  private onDeath(actor: Node): void {
    // 触发掉落、更新 UI 等
  }
}

这里要理解 Cocos 的两个「顺序」概念,别把它们搞混。第一是同一帧内多个组件的 update 顺序:默认按组件挂载顺序,但可用 @executionOrder(n)(_decorator.executionOrder,数值越小越先执行)显式指定。这正是我们「优先级表」的等价物——把结算组件设为较大的 executionOrder,就能保证它读到的输入已经全部就绪。第二是事件监听器的调用顺序:EventTarget.emit 按注册顺序同步调用,且 emit 里嵌套 emit 会立刻递归,等价于我们担心的「重入」。所以 Cocos 侧同样建议把「改数值」和「抛事件」分开,别在 emit 的回调里直接改状态。

关注点mini2d 结算管线Cocos Creator 3.8
意图收集SettlementPipeline.enqueue组件内自建队列 / EventTarget
确定性排序priority + seq 双键排序@executionOrder + 手动排序
统一结算flush(world) 每帧一次在指定 executionOrder 的 update 里结算
事件分发自写 BattleBusEventTarget / Node.emit
防止重入先摘队列再遍历回调里只记录、不直接改状态

小结一下:结算管线的价值不在「多写了几行」,而在把顺序的定义权从「碰巧的调用顺序」收回到一处。下一节我们把技能系统也纳入这条思路——用「效果」原子组合出技能,让策划改数值不用改代码。

阅读导航:上一节:目录 · 下一节:14.2 技能与效果系统:数据驱动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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