9.3 动画事件与逻辑一致性
9.1 管片段怎么命名、9.2 管状态怎么过渡,这一节管动画时间轴上的事件标记:在第 6 帧触发左脚脚步声、第 30 帧放一个火花特效、第 90 帧通知状态机退出。这些标记是美术资产的一部分,但它们的消费者是逻辑代码——标记写错了,程序不会崩,只会在某个瞬间少响一声、多放一个特效。
事件怎么被派发、特效怎么被实例化属于实现,本节外链 《渲染引擎》 与 《GPU 粒子与特效》 ,只做资产侧的一致性校验:事件的帧号、类型、参数能不能被一张白名单解释,以及同一片段内的事件自身是否自洽。
9.3.1 事件标记:帧号、类型、参数
一个事件标记是三样东西:frame(第几帧)、type(哪一类事件)、arg(参数,语义由类型决定)。类型取自一张白名单——footstep / sfx / vfx / hitbox / stateExit。参数的含义随类型走:footstep 的 arg 是 left / right,vfx 的 arg 是特效 id,stateExit 的 arg 是目标状态名。
关键在于:参数里凡是引用了别的东西(特效 id、状态名),就是一条引用,必须能被校验。这和卷二 4.1 依赖图 里「槽位指向贴图 id 是一条隐式边」完全同构——事件参数指向特效 id,也是资产图里的一条边。
9.3.2 六条一致性校验
把「事件标记能不能被逻辑正确消费」拆成六条可判定的检查,每条一个规则码:
| 规则码 | 判据 | 后果 |
|---|---|---|
E_EVENT_TYPE | 类型在 eventTypes 白名单内 | 逻辑没有对应处理器 |
E_EVENT_ORDER | 同片段事件按帧号非降序 | 派发顺序与时间轴不符 |
E_EVENT_FRAME | 帧号在 [0, frames] 内 | 落在片段外,永不触发或越界 |
E_EVENT_DUP | 同片段 (帧号, 类型) 不重复 | 同帧重复触发(如两声脚步) |
E_EVENT_VFX_REF | vfx 的 arg 在 knownVfx 里 | 引用了不存在的特效 |
E_EVENT_STATE_REF | stateExit 的 arg 在 knownStates 里 | 退出到一个不存在的状态 |
E_EVENT_FRAME 有个容易写错的边界:帧号等于 frames 是合法的(非循环片段的末帧正是退出点),只有大于 frames 才越界。这一条会在测试里用一对正负例钉死。
9.3.3 D 层:事件样本
下面这份是 D 层样本,4 个片段、8 个事件标记,故意埋了四类违例各一条:
{
"project": "stardust-inn",
"chapter": 9,
"note": "9.3 动画事件与逻辑一致性样本:4 个片段上的 8 个事件标记 + 事件类型白名单 + 效果引用表 + 状态引用表。故意埋 4 类违例各 1 条:帧越界、同帧同类型重复、事件未按帧排序、vfx 引用不存在。",
"eventTypes": ["footstep", "sfx", "vfx", "hitbox", "stateExit"],
"knownVfx": ["vfx_dust", "vfx_sparkle"],
"knownStates": ["Idle", "Walk", "Work"],
"clips": [
{ "name": "innkeeper_walk_n", "frames": 24, "loop": true, "events": [
{ "frame": 6, "type": "footstep", "arg": "left" },
{ "frame": 18, "type": "footstep", "arg": "right" },
{ "frame": 6, "type": "footstep", "arg": "left" } ] },
{ "name": "innkeeper_work", "frames": 60, "loop": true, "events": [
{ "frame": 30, "type": "vfx", "arg": "vfx_sparkle" },
{ "frame": 30, "type": "sfx", "arg": "sfx_hammer" } ] },
{ "name": "guest_celebrate", "frames": 90, "loop": false, "events": [
{ "frame": 90, "type": "stateExit", "arg": "Idle" },
{ "frame": 95, "type": "vfx", "arg": "vfx_dust" } ] },
{ "name": "cat_idle", "frames": 36, "loop": true, "events": [
{ "frame": 12, "type": "vfx", "arg": "vfx_missing" } ] }
]
}
四类违例:innkeeper_walk_n 的事件是 6, 18, 6——第三个事件把帧号倒回去了,同时 (6, footstep) 与第一个重复;guest_celebrate 的 frame=95 超过了 frames=90;cat_idle 的 vfx_missing 不在 knownVfx 里。
9.3.4 S 层:六条校验一次建账
下面这段是 S 层脚本,逐片段跑六条校验并按规则码计数:
#!/usr/bin/env python3
# file: sim/ta09_events.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta09_events.py
"""9.3 动画事件与逻辑一致性的 S 层校验器:读 config/ta09_events.json。
六条**资产侧**校验(都不涉及引擎怎么派发事件):事件类型在白名单内(``E_EVENT_TYPE``)、同
片段事件按帧号非降序(``E_EVENT_ORDER``)、帧号在 ``[0, frames]``(``E_EVENT_FRAME``)、同
片段 ``(帧号, 类型)`` 不重复(``E_EVENT_DUP``)、``vfx`` 事件的 arg 在 ``knownVfx`` 里
(``E_EVENT_VFX_REF``)、``stateExit`` 的 arg 在 ``knownStates`` 里(``E_EVENT_STATE_REF``)。
违例行按 ``clip:frame:type:code`` 排序取 sha256 前 16 位作 digest——**字符串类**,跨语言逐位
一致。落 ``out/ta09_events.json`` 供 P 层对照。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def check_clip(clip, types, vfx, states):
"""对单个片段的事件列表做六条校验,返回违例 dict 列表。"""
out, events = [], clip["events"]
for i, e in enumerate(events):
frame, typ, codes = e["frame"], e["type"], []
if i > 0 and frame < events[i - 1]["frame"]:
codes.append("E_EVENT_ORDER")
if typ not in types:
codes.append("E_EVENT_TYPE")
if not (0 <= frame <= clip["frames"]):
codes.append("E_EVENT_FRAME")
if any(p["frame"] == frame and p["type"] == typ for p in events[:i]):
codes.append("E_EVENT_DUP")
if typ == "vfx" and e["arg"] not in vfx:
codes.append("E_EVENT_VFX_REF")
if typ == "stateExit" and e["arg"] not in states:
codes.append("E_EVENT_STATE_REF")
out += [{"clip": clip["name"], "frame": frame, "type": typ, "code": c} for c in codes]
return out
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/ta09_events.json"), encoding="utf-8"))
types, vfx, states = set(spec["eventTypes"]), set(spec["knownVfx"]), set(spec["knownStates"])
violations = []
for clip in spec["clips"]:
violations += check_clip(clip, types, vfx, states)
violations.sort(key=lambda v: (v["clip"], v["frame"], v["code"]))
by_code = {}
for v in violations:
by_code[v["code"]] = by_code.get(v["code"], 0) + 1
affected = sorted({v["clip"] for v in violations})
lines = sorted(f"{v['clip']}:{v['frame']}:{v['type']}:{v['code']}" for v in violations)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
rep = {"project": spec["project"], "clipCount": len(spec["clips"]),
"eventCount": sum(len(c["events"]) for c in spec["clips"]),
"violationCount": len(violations), "byCode": by_code, "affectedClips": affected,
"violations": violations, "digest": digest}
with open(os.path.join(ROOT, "out/ta09_events.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"clips={rep['clipCount']} events={rep['eventCount']} violations={len(violations)}")
for c in sorted(by_code):
print(f" {c:20s} {by_code[c]}")
for v in violations:
print(f" {v['clip']:18s} frame={v['frame']:<3d} {v['type']:9s} {v['code']}")
print(f"affectedClips={affected}")
print(f"digest={digest}")
print("wrote out/ta09_events.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta09_events.py:
clips=4 events=8 violations=4
E_EVENT_DUP 1
E_EVENT_FRAME 1
E_EVENT_ORDER 1
E_EVENT_VFX_REF 1
cat_idle frame=12 vfx E_EVENT_VFX_REF
guest_celebrate frame=95 vfx E_EVENT_FRAME
innkeeper_walk_n frame=6 footstep E_EVENT_DUP
innkeeper_walk_n frame=6 footstep E_EVENT_ORDER
affectedClips=['cat_idle', 'guest_celebrate', 'innkeeper_walk_n']
digest=5bff8aa19af2b2c1
wrote out/ta09_events.json
8 个事件上判出 4 条违例,四类各一条,分布在 3 个片段上。innkeeper_work 是唯一干净的片段——它有两个同帧事件(frame=30 的 vfx 与 sfx),但类型不同,所以不算重复;这正是 E_EVENT_DUP 按 (帧号, 类型) 而非只按帧号判的原因:同一帧放一个特效加一个音效是正常的。
9.3.5 P 层:同一套六条校验的 TS 内核
下面这段是 P 层 TS 内核,六条判据与上面的 Python 逐条对应:
// file: core/src/ta09/events.ts
// tier: P
// verify: node --test 'core/test/ta09_*.test.ts'
// 9.3 动画事件与逻辑一致性的 P 层内核,与 sim/ta09_events.py 同一套六条校验、排序与 digest
// 口径。输出全是枚举字符串(规则码、类型名),故两侧**逐位一致**。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export type Event = { frame: number; type: string; arg: string };
export type Clip = { name: string; frames: number; loop: boolean; events: Event[] };
export type Violation = { clip: string; frame: number; type: string; code: string };
export function checkClip(clip: Clip, types: Set<string>, vfx: Set<string>, states: Set<string>): Violation[] {
const out: Violation[] = [];
clip.events.forEach((e, i) => {
const push = (code: string): void => { out.push({ clip: clip.name, frame: e.frame, type: e.type, code }); };
if (i > 0 && e.frame < clip.events[i - 1].frame) push("E_EVENT_ORDER");
if (!types.has(e.type)) push("E_EVENT_TYPE");
if (!(0 <= e.frame && e.frame <= clip.frames)) push("E_EVENT_FRAME");
if (clip.events.slice(0, i).some((p) => p.frame === e.frame && p.type === e.type)) push("E_EVENT_DUP");
if (e.type === "vfx" && !vfx.has(e.arg)) push("E_EVENT_VFX_REF");
if (e.type === "stateExit" && !states.has(e.arg)) push("E_EVENT_STATE_REF");
});
return out;
}
const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
export function analyze(spec: any) {
const types = new Set<string>(spec.eventTypes);
const vfx = new Set<string>(spec.knownVfx);
const states = new Set<string>(spec.knownStates);
const clips: Clip[] = spec.clips;
const violations: Violation[] = [];
for (const clip of clips) violations.push(...checkClip(clip, types, vfx, states));
violations.sort((a, b) => cmp(a.clip, b.clip) || a.frame - b.frame || cmp(a.code, b.code));
const byCode: Record<string, number> = {};
for (const v of violations) byCode[v.code] = (byCode[v.code] || 0) + 1;
const affectedClips = [...new Set(violations.map((v) => v.clip))].sort();
const lines = violations.map((v) => `${v.clip}:${v.frame}:${v.type}:${v.code}`).sort();
const digest = createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
return {
clipCount: clips.length, eventCount: clips.reduce((n, c) => n + c.events.length, 0),
violationCount: violations.length, byCode, affectedClips, violations, digest,
};
}
测试读 Python 落盘的 out/ta09_events.json,并用一对正负例钉死「帧号等于 frames 合法」这条边界:
// file: core/test/ta09_events.test.ts
// tier: P
// verify: node --test 'core/test/ta09_*.test.ts'
// 跨语言对照:读 sim/ta09_events.py 落盘的 out/ta09_events.json,断言 TS 侧的事件校验结果与
// 之一致(枚举字符串类,逐位一致)。负例:帧越界、同帧同类型重复、未排序、vfx 引用不存在
// 四类违例都必须被报出,一条都不能漏。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { analyze, checkClip } from "../src/ta09/events.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/ta09_events.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta09_events.json"), "utf8"));
test("跨语言:TS 违例清单 == Python(逐位一致)", () => {
const r = analyze(spec);
assert.equal(r.eventCount, py.eventCount);
assert.equal(r.violationCount, py.violationCount);
assert.deepEqual(r.byCode, py.byCode);
assert.deepEqual(r.affectedClips, py.affectedClips);
assert.deepEqual(r.violations, py.violations);
assert.equal(r.digest, py.digest);
console.log(`events digest = ${py.digest}`);
});
test("计数:8 个事件上判出 4 类违例各 1 条", () => {
const r = analyze(spec);
assert.equal(r.eventCount, 8);
assert.equal(r.violationCount, 4);
assert.deepEqual(r.byCode, {
E_EVENT_DUP: 1, E_EVENT_FRAME: 1, E_EVENT_ORDER: 1, E_EVENT_VFX_REF: 1,
});
assert.deepEqual(r.affectedClips, ["cat_idle", "guest_celebrate", "innkeeper_walk_n"]);
});
test("负例:帧号等于 frames 合法(末帧退出),大于 frames 才越界", () => {
const types = new Set(["stateExit"]);
const ok = checkClip(
{ name: "c", frames: 90, loop: false, events: [{ frame: 90, type: "stateExit", arg: "Idle" }] },
types, new Set(), new Set(["Idle"]),
);
assert.deepEqual(ok, []);
const bad = checkClip(
{ name: "c", frames: 90, loop: false, events: [{ frame: 91, type: "stateExit", arg: "Idle" }] },
types, new Set(), new Set(["Idle"]),
);
assert.deepEqual(bad.map((v) => v.code), ["E_EVENT_FRAME"]);
});
test("负例:vfx 引用不存在的效果、stateExit 引用未知状态都必须报出", () => {
const bad = checkClip(
{ name: "c", frames: 40, loop: true, events: [
{ frame: 5, type: "vfx", arg: "vfx_nope" },
{ frame: 8, type: "stateExit", arg: "Ghost" },
] },
new Set(["vfx", "stateExit"]), new Set(["vfx_dust"]), new Set(["Idle"]),
);
assert.deepEqual(bad.map((v) => v.code).sort(), ["E_EVENT_STATE_REF", "E_EVENT_VFX_REF"]);
});
实跑 node --test 'core/test/ta09_events.test.ts':
events digest = 5bff8aa19af2b2c1
✔ 跨语言:TS 违例清单 == Python(逐位一致)
✔ 计数:8 个事件上判出 4 类违例各 1 条
✔ 负例:帧号等于 frames 合法(末帧退出),大于 frames 才越界
✔ 负例:vfx 引用不存在的效果、stateExit 引用未知状态都必须报出
ℹ tests 4
ℹ pass 4
ℹ fail 0
两侧 digest 都是 5bff8aa19af2b2c1——违例行由片段名、帧号、类型名、规则码拼成,全是字符串与整数,没有浮点,所以是逐位一致。
9.3.6 边界:事件派发本身不可复现
本节判的全是标记数据:帧号在不在范围、类型认不认识、参数引用的 id 存不存在。这些是纯数据,能逐位复现。但「引擎在第 6 帧到底有没有派发这条事件、特效有没有在正确的骨骼上实例化、状态退出有没有真的切过去」——需真实环境,命令行不可复现:那取决于具体引擎的事件调度与特效系统,本仓库既没有引擎运行时,也没有渲染器。
所以事件校验的定位很明确:它保证标记可被正确解释,不保证标记一定被正确执行。这和 9.1 的「族只保证方向齐全」、9.2 的「规范只承诺权重确定」是同一条纪律——规范能管住的东西,管到底;管不住的,标清楚边界。事件参数里的 vfx 引用,到第 12 章会并入 12.1 规则引擎
的引用完整性检查,和材质槽、清单引用共用同一张引用图。
阅读导航:上一节:9.2 混合空间与过渡规范 · 下一节:10.1 安全区与刘海屏适配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。