9.1 动画资源命名与状态绑定

本节把动画片段名文法定成 <char>_<action>[_<dir>][_v<N>],用片段族把方向与版本变体收成一个可绑定的单位,再校验三类绑定缺陷:悬空绑定、孤儿片段族、方向缺档。11 个片段判出坏名 1、悬空 1、孤儿 1、缺档 1,两侧 digest 逐位一致。

9.1 动画资源命名与状态绑定

卷一 12.2 动画帧率与状态规范 从画面的角度定过动画的帧率与状态表达;这一节换到管线的角度,只回答一个问题:一批动画片段文件,怎么命名、怎么绑到状态机上,才能让机器在打包前就查出「哪个状态绑了个不存在的动画」。

引擎侧的状态机运行时、混合树求值、事件派发属于实现,本节一律外链 《骨骼动画》 与 《渲染引擎》 分卷,只做资产侧的命名文法与绑定完整性校验。这和卷一 5.1 命名规则 是同一套思路——把命名当契约,而不是当习惯。

9.1.1 片段名文法:<char>_<action>[_<dir>][_v<N>]

《星屑旅店》的角色不多(店主、客人、猫),但动作与朝向一乘起来,片段数就上去了。片段名必须能被机器唯一地拆开,所以文法固定成四段,后两段可选:

段取值必选例
<char>innkeeper / guest / cat是innkeeper
<action>idle / walk / work / sit / talk / celebrate是walk
[_<dir>]n / e / s / w否n
[_v<N>]v 加数字否v2

于是 innkeeper_walk_n 是「店主·走·朝北」,guest_sit_v2 是「客人·坐·第二版」。这个文法的关键不在好看,而在可逆:任何工具拿到一个片段名,都能确定地拆出角色、动作、朝向、版本四个字段,不需要查表、不需要人解释。

9.1.2 片段族:把方向与版本收成一个可绑定单位

如果状态直接绑片段,那「走」这一个状态要绑 4 个方向的片段,换个朝向还要改绑定——状态机会被方向细节淹掉。所以绑定单位是片段族:把片段名去掉方向与版本后的基名,innkeeper_walk_n、innkeeper_walk_e、innkeeper_walk_s、innkeeper_walk_w 同属族 innkeeper_walk。

状态绑族、不绑片段。方向在运行时由朝向参数在族内选(或交给 9.2 的混合空间),版本在导入时由变体规则解析。族是规范层与运行时之间的一道缓冲:规范只保证「这个族存在且方向齐全」,怎么在族内插值是实现的事。

9.1.3 D 层:命名与绑定样本

下面这份是 D 层的样本,覆盖三个角色的 11 个片段与 8 个状态。它故意埋了四类问题各一条,供后面两节检出:

{
  "project": "stardust-inn",
  "chapter": 9,
  "note": "9.1 动画资源命名与状态绑定样本:三个角色、11 个片段、8 个状态。片段名文法 <char>_<action>[_<dir>][_v<N>]。样本里故意埋了 1 个坏名(cat_groom_loop)、1 个悬空绑定(Celebrate→innkeeper_celebrate)、1 个孤儿片段族(guest_celebrate)、1 处方向缺档(innkeeper_walk 缺 w)。",
  "grammar": {
    "chars": ["innkeeper", "guest", "cat"],
    "actions": ["idle", "walk", "work", "sit", "talk", "celebrate"],
    "dirs": ["n", "e", "s", "w"]
  },
  "locoChars": ["innkeeper"],
  "locoActions": ["walk"],
  "clips": [
    { "name": "innkeeper_idle", "frames": 48, "loop": true },
    { "name": "innkeeper_walk_n", "frames": 24, "loop": true },
    { "name": "innkeeper_walk_e", "frames": 24, "loop": true },
    { "name": "innkeeper_walk_s", "frames": 24, "loop": true },
    { "name": "innkeeper_work", "frames": 60, "loop": true },
    { "name": "guest_idle", "frames": 48, "loop": true },
    { "name": "guest_sit", "frames": 40, "loop": true },
    { "name": "guest_talk", "frames": 72, "loop": false },
    { "name": "cat_idle", "frames": 36, "loop": true },
    { "name": "guest_celebrate", "frames": 90, "loop": false },
    { "name": "cat_groom_loop", "frames": 30, "loop": true }
  ],
  "states": [
    { "char": "innkeeper", "state": "Idle", "family": "innkeeper_idle" },
    { "char": "innkeeper", "state": "Walk", "family": "innkeeper_walk" },
    { "char": "innkeeper", "state": "Work", "family": "innkeeper_work" },
    { "char": "innkeeper", "state": "Celebrate", "family": "innkeeper_celebrate" },
    { "char": "guest", "state": "Idle", "family": "guest_idle" },
    { "char": "guest", "state": "Sit", "family": "guest_sit" },
    { "char": "guest", "state": "Talk", "family": "guest_talk" },
    { "char": "cat", "state": "Idle", "family": "cat_idle" }
  ]
}

四类问题:cat_groom_loop 的动作 groom 不在动作集里(坏名);状态 Celebrate 绑的族 innkeeper_celebrate 一个片段都没有(悬空绑定);guest_celebrate 有片段却没有状态绑它(孤儿族);innkeeper_walk 只有 n/e/s 三个方向(方向缺档)。

9.1.4 S 层:四类检查一次建账

下面这段是 S 层脚本,读上面的配置做命名解析、族推导、绑定校验与方向覆盖:

#!/usr/bin/env python3
# file: sim/ta09_binding.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta09_binding.py
"""9.1 动画资源命名与状态绑定的 S 层校验器:读 config/ta09_binding.json。

四类检查:命名文法(不合法的进 ``badNames`` 且**不参与**族推导,免得坏名伪装成孤儿族)、
状态绑定(绑的族无片段即悬空)、孤儿族(有片段却无人绑)、方向覆盖(locoChars × locoActions
的族须凑齐 4 个方向)。绑定行按 ``char:State->family`` 排序取 sha256 前 16 位作 digest——
**字符串类**,跨语言逐位一致。落 ``out/ta09_binding.json`` 供 P 层对照。
"""
import hashlib
import json
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VERSION_RE = re.compile(r"^v[0-9]+$")


def parse_name(name, grammar):
    """解析成 (char, action, dir|None, ver|None);不合法返回 None。"""
    t = name.split("_")
    ver = t.pop() if t and VERSION_RE.match(t[-1]) else None
    direction = t.pop() if t and t[-1] in grammar["dirs"] else None
    if len(t) != 2 or t[0] not in grammar["chars"] or t[1] not in grammar["actions"]:
        return None
    return t[0], t[1], direction, ver


def main() -> int:
    spec = json.load(open(os.path.join(ROOT, "config/ta09_binding.json"), encoding="utf-8"))
    grammar, clips, states = spec["grammar"], spec["clips"], spec["states"]
    bad_names, families = [], {}
    for c in clips:
        parsed = parse_name(c["name"], grammar)
        if parsed is None:
            bad_names.append(c["name"])
        else:
            families.setdefault(f"{parsed[0]}_{parsed[1]}", []).append(parsed[2])
    bound = {s["family"] for s in states}
    dangling = sorted(b for b in bound if b not in families)
    orphan = sorted(f for f in families if f not in bound)
    gaps = []
    for ch in spec["locoChars"]:
        for act in spec["locoActions"]:
            have = {d for d in families.get(f"{ch}_{act}", []) if d}
            gaps += [f"{ch}_{act}_{d}" for d in grammar["dirs"] if d not in have]
    lines = sorted(f"{s['char']}:{s['state']}->{s['family']}" for s in states)
    digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
    rep = {"project": spec["project"], "clipCount": len(clips), "stateCount": len(states),
           "familyCount": len(families), "badNames": sorted(bad_names),
           "danglingFamilies": dangling, "orphanFamilies": orphan, "directionGaps": gaps,
           "bindings": lines, "digest": digest}
    with open(os.path.join(ROOT, "out/ta09_binding.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={len(clips)} states={len(states)} families={len(families)} badNames={len(bad_names)}")
    print(f"dangling={len(dangling)} orphan={len(orphan)} gaps={len(gaps)}")
    for tag, items in (("badName", sorted(bad_names)), ("dangling", dangling),
                       ("orphan", orphan), ("gap", gaps)):
        for it in items:
            print(f"  {tag:8s} {it}")
    print(f"digest={digest}")
    print("wrote out/ta09_binding.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta09_binding.py:

clips=11 states=8 families=8 badNames=1
dangling=1 orphan=1 gaps=1
  badName  cat_groom_loop
  dangling innkeeper_celebrate
  orphan   guest_celebrate
  gap      innkeeper_walk_w
digest=f85ba49c131ce7e9
wrote out/ta09_binding.json

11 个片段里 1 个名字不合法,于是只有 10 个参与族推导,得到 8 个族。四类问题各一条,条数与埋点完全对上——坏名不进族这一点很重要:如果坏名也参与族推导,cat_groom_loop 会变成一个谁也绑不到的孤儿族,把真正的孤儿 guest_celebrate 淹没。

9.1.5 P 层:同一套文法的 TS 内核

下面这段是 P 层 TS 内核,与上面的 Python 逐条对应——同一个文法、同一套族推导、同一套排序与 digest 口径:

// file: core/src/ta09/binding.ts
// tier: P
// verify: node --test 'core/test/ta09_*.test.ts'
// 9.1 命名与状态绑定的 P 层内核,与 sim/ta09_binding.py 同一套文法、族推导与 digest 口径。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";

export const VERSION_RE = /^v[0-9]+$/;
export type Parsed = { char: string; action: string; dir: string | null; ver: string | null };

export function parseName(name: string, g: any): Parsed | null {
  const t = name.split("_");
  const ver = t.length && VERSION_RE.test(t[t.length - 1]) ? (t.pop() as string) : null;
  const dir = t.length && g.dirs.includes(t[t.length - 1]) ? (t.pop() as string) : null;
  if (t.length !== 2 || !g.chars.includes(t[0]) || !g.actions.includes(t[1])) return null;
  return { char: t[0], action: t[1], dir, ver };
}

export function familyOf(p: Parsed): string {
  return `${p.char}_${p.action}`;
}

export function analyze(spec: any) {
  const g = spec.grammar;
  const badNames: string[] = [];
  const families = new Map<string, (string | null)[]>();
  for (const c of spec.clips as { name: string }[]) {
    const p = parseName(c.name, g);
    if (p === null) {
      badNames.push(c.name);
      continue;
    }
    const fam = familyOf(p);
    if (!families.has(fam)) families.set(fam, []);
    (families.get(fam) as (string | null)[]).push(p.dir);
  }
  const bound = new Set<string>((spec.states as { family: string }[]).map((s) => s.family));
  const dangling = [...bound].filter((b) => !families.has(b)).sort();
  const orphan = [...families.keys()].filter((f) => !bound.has(f)).sort();
  const gaps: string[] = [];
  for (const ch of spec.locoChars as string[]) {
    for (const act of spec.locoActions as string[]) {
      const have = new Set((families.get(`${ch}_${act}`) || []).filter((d) => d !== null));
      for (const d of g.dirs) if (!have.has(d)) gaps.push(`${ch}_${act}_${d}`);
    }
  }
  const bindings = (spec.states as { char: string; state: string; family: string }[])
    .map((s) => `${s.char}:${s.state}->${s.family}`)
    .sort();
  const digest = createHash("sha256").update(bindings.join("\n"), "utf8").digest("hex").slice(0, 16);
  return {
    clipCount: spec.clips.length, stateCount: spec.states.length, familyCount: families.size,
    badNames: badNames.slice().sort(), danglingFamilies: dangling, orphanFamilies: orphan,
    directionGaps: gaps, bindings, digest,
  };
}

P 层测试读的是 Python 落盘的 out/ta09_binding.json,做跨语言对照;另有三条负例守文法与悬空绑定:

// file: core/test/ta09_binding.test.ts
// tier: P
// verify: node --test 'core/test/ta09_*.test.ts'
// 跨语言对照:读 sim/ta09_binding.py 落盘的 out/ta09_binding.json,断言 TS 侧结果与之一致
// (字符串类,逐位一致)。负例:坏名必须被拒且不进片段族;悬空绑定必须报出。
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, familyOf, parseName } from "../src/ta09/binding.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/ta09_binding.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta09_binding.json"), "utf8"));

test("跨语言:TS 校验结果 == Python(逐位一致)", () => {
  const r = analyze(spec);
  assert.equal(r.clipCount, py.clipCount);
  assert.equal(r.familyCount, py.familyCount);
  assert.deepEqual(r.badNames, py.badNames);
  assert.deepEqual(r.danglingFamilies, py.danglingFamilies);
  assert.deepEqual(r.orphanFamilies, py.orphanFamilies);
  assert.deepEqual(r.directionGaps, py.directionGaps);
  assert.deepEqual(r.bindings, py.bindings);
  assert.equal(r.digest, py.digest);
  console.log(`binding digest = ${py.digest}`);
});

test("片段族推导:方向与版本都要被剥掉", () => {
  const g = spec.grammar;
  assert.equal(familyOf(parseName("innkeeper_walk_n", g)!), "innkeeper_walk");
  assert.equal(familyOf(parseName("guest_sit_v2", g)!), "guest_sit");
  assert.equal(parseName("innkeeper_walk_w", g)!.dir, "w");
  assert.equal(parseName("guest_sit_v2", g)!.ver, "v2");
});

test("负例:坏名必须被拒,且不进片段族", () => {
  const g = spec.grammar;
  assert.equal(parseName("cat_groom_loop", g), null);
  assert.equal(parseName("Guest_idle", g), null);
  assert.equal(parseName("innkeeper_dance", g), null);
  const r = analyze(spec);
  assert.deepEqual(r.badNames, ["cat_groom_loop"]);
  assert.equal(r.familyCount, 8);
});

test("问题账:悬空 1 / 孤儿 1 / 方向缺档 1", () => {
  const r = analyze(spec);
  assert.deepEqual(r.danglingFamilies, ["innkeeper_celebrate"]);
  assert.deepEqual(r.orphanFamilies, ["guest_celebrate"]);
  assert.deepEqual(r.directionGaps, ["innkeeper_walk_w"]);
});

实跑 node --test 'core/test/ta09_binding.test.ts':

binding digest = f85ba49c131ce7e9
✔ 跨语言:TS 校验结果 == Python(逐位一致)
✔ 片段族推导:方向与版本都要被剥掉
✔ 负例:坏名必须被拒,且不进片段族
✔ 问题账:悬空 1 / 孤儿 1 / 方向缺档 1
ℹ tests 4
ℹ pass 4
ℹ fail 0

两侧的绑定 digest 都是 f85ba49c131ce7e9——绑定行全是字符串(角色名、状态名、族名),没有浮点,所以是逐位一致。这与卷一的色彩派生量不同:那类量只写「容差内一致,实测最大偏差 4.9e-13」(见卷一 4.2 OKLab·OKLCH )。

9.1.6 悬空绑定与孤儿族为什么必须一起查

单看悬空绑定(状态绑了不存在的族),会漏掉一半问题:改名把一个族从 innkeeper_celebrate 改成 guest_celebrate,状态还绑着旧名 → 悬空;而新族没人绑 → 孤儿。两个检查是一枚硬币的两面,只有同时查,报错才指得出「应该绑哪个」。这也和卷二 4.2 环检测与悬空引用 里「引用完整性要双向查」是同一条纪律。

方向缺档则是最容易被忽略的一类:innkeeper_walk 只有 n/e/s 三个方向时,画面在朝西走的那一刻会静默回退到某个默认片段——不报错、不崩,只是看起来不对。把它变成一条会失败的检查,等于把「美术漏导了一个方向」从美术的记性里挪到门禁里。这条检查到第 12 章 12.1 规则引擎 会和命名、尺寸、引用、预算一起并进同一套规则引擎。

阅读导航:上一节:8.3 划界:蒙皮数学外链 · 下一节:9.2 混合空间与过渡规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则