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 里结算 |
| 事件分发 | 自写 BattleBus | EventTarget / Node.emit |
| 防止重入 | 先摘队列再遍历 | 回调里只记录、不直接改状态 |
小结一下:结算管线的价值不在「多写了几行」,而在把顺序的定义权从「碰巧的调用顺序」收回到一处。下一节我们把技能系统也纳入这条思路——用「效果」原子组合出技能,让策划改数值不用改代码。
阅读导航:上一节:目录 · 下一节:14.2 技能与效果系统:数据驱动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。