上一节把「能力」写成了带产出物的矩阵,这一节把能力落到边界上。站内 《美术制作与视觉规范》 第一卷讲的是美术内部的生产链路与职责矩阵;本节讲的是跨职能的往返,也就是链路与链路之间的那几条边。渲染原理专题 里的接口是 API 级的,这里的接口是人和交付物级的。
接口不是「多沟通」,是可断言的契约
「TA 要跟美术、程序、策划多沟通」这句话没法验收,因为它既没说清谁给谁什么,也没说清缺了会怎样。把这句话写成数据,一条接口至少要回答六个问题:from / to(谁发起、谁接收,缺了责任悬空)、deliverable(跨边界交付的是什么,缺了只能靠口头同步)、frequency(多久触发一次,缺了排期表算不出沟通负荷)、grade(缺了是阻断还是告警)、owner(谁执行这条门禁)、failureMode(不写失败模式会出什么事)。缺任何一项,接口都会退化成口头同步。真实协作往返成本需真实团队环境,命令行不可复现(X)。
D 层:接口矩阵配置
下面这段是 D 层接口矩阵,8 条接口逐条给出上述六个字段,另加一次往返的沟通成本(人天 × 100)。它是 S 层负荷账与 P 层校验器的共同输入;数值是建模输入,不是任何团队的生产实测。读它要注意:发起方与接收方是成对的,不是单向的——同一个职能既要有入边也要有出边,只收不发说明它只会接需求,只发不收说明它的产出没人接。这条约束不写下来,矩阵里就会悄悄出现「单向接口」和「自环接口」。
{
"version": 1,
"project": "stardust-inn",
"note": "TA 与美术 / 程序 / 策划的接口矩阵。每行一条可断言的接口契约:from/to = 发起方与接收方(art 美术 / program 程序 / design 策划 / ta 技术美术);deliverable = 跨边界交付物;frequency = 触发频率;grade = 门禁分级(blocking 阻断 / warn 告警 / info 提示);owner = 门禁执行责任人;roundTripPersonDayX100 = 一次往返的沟通成本(人天 × 100)。真实协作往返成本需真实团队环境,命令行不可复现(X)。全整数。",
"actors": ["art", "program", "design", "ta"],
"frequencies": ["per-asset", "per-sprint", "per-milestone"],
"grades": ["blocking", "warn", "info"],
"interfaces": [
{ "id": "if-ta-art-spec", "from": "ta", "to": "art", "topic": "命名与尺寸规范下发", "deliverable": "命名规则 + 尺寸表", "frequency": "per-milestone", "grade": "blocking", "owner": "ta", "roundTripPersonDayX100": 200, "failureMode": "画师凭经验命名,导入期批量失败" },
{ "id": "if-art-ta-exemption", "from": "art", "to": "ta", "topic": "规范豁免申请", "deliverable": "豁免申请单", "frequency": "per-sprint", "grade": "warn", "owner": "ta", "roundTripPersonDayX100": 100, "failureMode": "口头豁免无留痕,回归时被当作缺陷" },
{ "id": "if-ta-program-import", "from": "ta", "to": "program", "topic": "导入设置与 .meta 语义", "deliverable": "导入设置表", "frequency": "per-milestone", "grade": "blocking", "owner": "program", "roundTripPersonDayX100": 300, "failureMode": "改导入设置未同步,资源整批重导" },
{ "id": "if-program-ta-budget", "from": "program", "to": "ta", "topic": "运行时预算回传", "deliverable": "帧预算与 Draw Call 上限", "frequency": "per-sprint", "grade": "warn", "owner": "ta", "roundTripPersonDayX100": 200, "failureMode": "上限变更未回传,预算账过期" },
{ "id": "if-ta-design-theme", "from": "ta", "to": "design", "topic": "主题包参数可行性", "deliverable": "6 主题参数对照表", "frequency": "per-milestone", "grade": "warn", "owner": "design", "roundTripPersonDayX100": 250, "failureMode": "策划要求的主题超出美术预算" },
{ "id": "if-design-ta-assetlist", "from": "design", "to": "ta", "topic": "资产需求清单", "deliverable": "玩法资产清单 + 优先级", "frequency": "per-sprint", "grade": "blocking", "owner": "design", "roundTripPersonDayX100": 180, "failureMode": "清单缺优先级,排期失序" },
{ "id": "if-ta-art-overbudget", "from": "ta", "to": "art", "topic": "超支资产返修单", "deliverable": "超支归因表 + 裁剪顺序", "frequency": "per-asset", "grade": "warn", "owner": "ta", "roundTripPersonDayX100": 220, "failureMode": "只说超标,不给裁剪顺序" },
{ "id": "if-design-art-brief", "from": "design", "to": "art", "topic": "视觉需求书", "deliverable": "结构化 art brief", "frequency": "per-sprint", "grade": "info", "owner": "design", "roundTripPersonDayX100": 120, "failureMode": "形容词未落成可测参数" }
]
}
S 层:接口负荷账
同一份矩阵,S 层读它算三类账:度数账(每个职能的入边 / 出边)、分级账(blocking / warn / info 各多少)、往返成本账(按触发频率汇总)。本机跑 /opt/homebrew/bin/python3 sim/ta01_interface.py,真实输出:interfaceCount=8;度数 art{in:3,out:1} / program{in:1,out:1} / design{in:1,out:2} / ta{in:3,out:4};分级 blocking=3 / warn=4 / info=1;往返成本 per-milestone=750 / per-sprint=600 / per-asset=220,合计 totalRoundTripPersonDayX100=1570。TA 的出边最多(4 条),美术的入边最多(3 条)——TA 是矩阵里最忙的发起点,美术是最主要的接收方。真实协作成本需真实团队环境,命令行不可复现(X)。
#!/usr/bin/env python3
# file: sim/ta01_interface.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta01_interface.py
"""TA 与美术 / 程序 / 策划的接口负荷账:读 config/ta01_interface.json,全整数无浮点。
① 度数账(每个职能的入边 / 出边,只收不发或只发不收都说明契约不对称);② 分级账
(blocking / warn / info 各多少条);③ 往返成本账(按触发频率汇总 roundTripPersonDayX100)。
产出 out/ 下 json + csv + svg(矩阵点阵),另落 canonical 摘要与 sha256 前 16 位供 P 层对照。
真实协作往返成本需真实团队环境,命令行不可复现(X)。"""
import csv
import hashlib
import json
import os
def analyze(cfg):
actors, grades, rows = cfg["actors"], cfg["grades"], cfg["interfaces"]
deg = {a: {"in": 0, "out": 0} for a in actors}
gc = {g: 0 for g in grades}
freq = {f: {"count": 0, "roundTripPersonDayX100": 0} for f in cfg["frequencies"]}
for r in rows:
deg[r["from"]]["out"] += 1
deg[r["to"]]["in"] += 1
gc[r["grade"]] += 1
freq[r["frequency"]]["count"] += 1
freq[r["frequency"]]["roundTripPersonDayX100"] += r["roundTripPersonDayX100"]
canon = json.dumps({"degree": {a: deg[a] for a in actors},
"grades": {g: gc[g] for g in grades}},
separators=(",", ":"), ensure_ascii=False)
return {"version": cfg["version"], "project": cfg["project"], "interfaceCount": len(rows),
"degree": deg, "gradeCounts": gc, "blockingCount": gc["blocking"],
"byFrequency": freq,
"totalRoundTripPersonDayX100": sum(v["roundTripPersonDayX100"] for v in freq.values()),
"canonical": canon,
"digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
def write_svg(rep, cfg, path):
"""接口矩阵点阵:行 = 发起方,列 = 接收方,格内数字 = 该方向的接口条数。"""
actors, rows = cfg["actors"], cfg["interfaces"]
grid = {(a, b): 0 for a in actors for b in actors}
for r in rows:
grid[(r["from"], r["to"])] += 1
cell, pad, left, mono = 46, 12, 74, 'font-family="monospace"'
w, h = pad * 2 + left + cell * len(actors), pad * 2 + 20 + cell * len(actors) + 24
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}">']
for j, b in enumerate(actors):
parts.append(f'<text x="{pad + left + j * cell + 12}" y="{pad + 12}" '
f'fill="#94a3b8" {mono} font-size="11">{b}</text>')
for i, a in enumerate(actors):
y = pad + 20 + i * cell
parts.append(f'<text x="{pad}" y="{y + 28}" fill="#94a3b8" {mono} '
f'font-size="11">{a}</text>')
for j, b in enumerate(actors):
x, n = pad + left + j * cell, grid[(a, b)]
fill = "#2d3a47" if a == b else "#252f3b" if n == 0 else \
"#f59e0b" if n >= 2 else "#93c5fd"
parts.append(f'<rect x="{x}" y="{y}" width="{cell - 4}" height="{cell - 4}" '
f'rx="3" fill="{fill}"/><text x="{x + 16}" y="{y + 28}" '
f'fill="{"#1f2933" if n else "#64748b"}" {mono} font-size="13">'
f'{n}</text>')
parts.append(f'<text x="{pad}" y="{h - 6}" fill="#94a3b8" {mono} font-size="10">'
f'interfaces={rep["interfaceCount"]} blocking={rep["blockingCount"]}'
f'</text></svg>')
open(path, "w", encoding="utf-8").write("".join(parts) + "\n")
def main():
cfg = json.load(open("config/ta01_interface.json", encoding="utf-8"))
rep = analyze(cfg)
os.makedirs("out", exist_ok=True)
json.dump(rep, open("out/ta01_interface.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
cols = ("id", "from", "to", "frequency", "grade", "owner", "roundTripPersonDayX100")
with open("out/ta01_interface.csv", "w", encoding="utf-8", newline="") as f:
wr = csv.writer(f)
wr.writerow(cols)
for r in cfg["interfaces"]:
wr.writerow([r[c] for c in cols])
write_svg(rep, cfg, "out/ta01_interface.svg")
print(json.dumps({"interfaceCount": rep["interfaceCount"], "degree": rep["degree"],
"gradeCounts": rep["gradeCounts"], "byFrequency": rep["byFrequency"],
"totalRoundTripPersonDayX100": rep["totalRoundTripPersonDayX100"],
"digest": rep["digest"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
P 层:接口矩阵校验器
下面这段是 P 层 TypeScript 内核 core/src/ta01/interface_matrix.ts。它把「接口」变成可断言的判据:行级检查写成一张谓词表,再加两条全矩阵约束——没有自环、每个职能入出边都非零。
// file: core/src/ta01/interface_matrix.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// 接口矩阵校验器:把「TA 与美术 / 程序 / 策划的接口」做成**可断言**的结构,而不是散文。
// 矩阵本身在 config/ta01_interface.json(单一真源),本文件只放判据:① 没有自环;
// ② 每个职能既有入边也有出边(只收不发 / 只发不收都说明契约不对称);③ blocking / warn
// 级必须写明失败模式。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Actor = "art" | "program" | "design" | "ta";
export const ACTORS: Actor[] = ["art", "program", "design", "ta"];
export const FREQUENCIES: string[] = ["per-asset", "per-sprint", "per-milestone"];
export const GRADES: string[] = ["blocking", "warn", "info"];
export type InterfaceRow = {
id: string; from: Actor; to: Actor; topic: string; deliverable: string;
frequency: string; grade: string; owner: Actor;
roundTripPersonDayX100: number; failureMode: string;
};
export type Degree = Record<string, { in: number; out: number }>;
export type Tally = Record<string, number>;
/** 行级判据表:一条接口要过这些检查,任一不过即记一条违规。 */
const ROW_CHECKS: [string, (r: InterfaceRow) => boolean][] = [
["缺 topic 或 deliverable", (r) => !r.topic || !r.deliverable],
["frequency 非法", (r) => !FREQUENCIES.includes(r.frequency)],
["grade 非法", (r) => !GRADES.includes(r.grade)],
["owner 不是职能", (r) => !ACTORS.includes(r.owner)],
["往返成本必须是非负整数", (r) => !Number.isInteger(r.roundTripPersonDayX100)
|| r.roundTripPersonDayX100 < 0],
["自环不是接口", (r) => r.from === r.to],
["blocking/warn 级必须写明失败模式", (r) =>
(r.grade === "blocking" || r.grade === "warn") && !r.failureMode],
];
/** 返回违规清单;空数组表示矩阵可放行。 */
export function validate(rows: InterfaceRow[]): string[] {
const errs: string[] = [];
const ids = rows.map((r) => r.id);
if (new Set(ids).size !== ids.length) errs.push("接口 id 重复");
for (const r of rows) {
if (!ACTORS.includes(r.from)) errs.push(`${r.id}: from ${r.from} 不是职能`);
if (!ACTORS.includes(r.to)) errs.push(`${r.id}: to ${r.to} 不是职能`);
for (const [msg, bad] of ROW_CHECKS) if (bad(r)) errs.push(`${r.id}: ${msg}`);
}
const deg = degree(rows, ACTORS);
for (const a of ACTORS) {
if (deg[a].in === 0) errs.push(`${a}: 没有入边(只发不收)`);
if (deg[a].out === 0) errs.push(`${a}: 没有出边(只收不发)`);
}
return errs;
}
export function degree(rows: InterfaceRow[], actors: Actor[]): Degree {
const out: Degree = {};
for (const a of actors) out[a] = { in: 0, out: 0 };
for (const r of rows) { out[r.from].out += 1; out[r.to].in += 1; }
return out;
}
export function gradeCounts(rows: InterfaceRow[], grades: string[]): Tally {
const out: Tally = {};
for (const g of grades) out[g] = 0;
for (const r of rows) out[r.grade] += 1;
return out;
}
export function byFrequency(rows: InterfaceRow[], freqs: string[]) {
const out: Record<string, { count: number; roundTripPersonDayX100: number }> = {};
for (const f of freqs) out[f] = { count: 0, roundTripPersonDayX100: 0 };
for (const r of rows) {
out[r.frequency].count += 1;
out[r.frequency].roundTripPersonDayX100 += r.roundTripPersonDayX100;
}
return out;
}
/** canonical 摘要:度数键序与分级键序都与 Python 侧一致。 */
export function canonical(
deg: Degree, gc: Tally, actors: Actor[], grades: string[],
): string {
const d: Degree = {};
const g: Tally = {};
for (const a of actors) d[a] = { in: deg[a].in, out: deg[a].out };
for (const k of grades) g[k] = gc[k];
return JSON.stringify({ degree: d, grades: g });
}
负例:只看 from / to 的校验器会放行什么
一个接口校验器最危险的失败模式是该拦的没拦:自环接口(ta → ta)和单向接口(没人给 program 发东西)在只看 from / to 非空的朴素校验器下全部通过,矩阵却已经失去了意义。
// file: core/test/ta01_interface.test.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// 接口矩阵断言:真实矩阵必须零违规;再构造两条负例(自环 / 只收不发)证明判据真的会拦。
// 跨语言对照:TS 侧独立复算度数、分级、频率汇总,与 Python(sim/ta01_interface.py)
// 落盘的 out/ta01_interface.json 逐位比对 + digest 对照。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
ACTORS,
GRADES,
validate,
degree,
gradeCounts,
byFrequency,
canonical,
} from "../src/ta01/interface_matrix.ts";
import type { InterfaceRow } from "../src/ta01/interface_matrix.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta01_interface.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta01_interface.json"), "utf8"));
const rows = cfg.interfaces as InterfaceRow[];
const freqs = cfg.frequencies as string[];
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
test("真实接口矩阵零违规", () => {
assert.deepEqual(validate(rows), []);
});
test("负例:朴素校验器放行自环与单向接口,真校验器必须拦下", () => {
// 朴素校验器:只看 from / to 非空
const naive = (rs: InterfaceRow[]): boolean => rs.every((r) => !!r.from && !!r.to);
const bad: InterfaceRow[] = [
{ id: "b1", from: "ta", to: "ta", topic: "t", deliverable: "d", frequency: "per-asset", grade: "blocking", owner: "ta", roundTripPersonDayX100: 10, failureMode: "f" },
{ id: "b2", from: "ta", to: "art", topic: "t", deliverable: "d", frequency: "per-asset", grade: "blocking", owner: "ta", roundTripPersonDayX100: 10, failureMode: "f" },
];
assert.equal(naive(bad), true); // 朴素校验器确实放行
const errs = validate(bad);
assert.ok(errs.some((e) => e.includes("自环")), `应报自环,实得 ${JSON.stringify(errs)}`);
assert.ok(errs.some((e) => e.includes("program") && e.includes("入边")), "应报 program 无入边");
assert.ok(errs.some((e) => e.includes("design") && e.includes("入边")), "应报 design 无入边");
});
test("负例:blocking 级接口不写失败模式必须被拦", () => {
const bad: InterfaceRow[] = [{ id: "b3", from: "art", to: "ta", topic: "t", deliverable: "d",
frequency: "per-sprint", grade: "blocking", owner: "ta", roundTripPersonDayX100: 1, failureMode: "" }];
assert.ok(validate(bad).some((e) => e.includes("失败模式")));
});
test("跨语言:度数账逐位一致", () => {
const mine = degree(rows, ACTORS);
assert.deepEqual(mine, py.degree);
for (const a of ACTORS) {
assert.ok(mine[a].in >= 1 && mine[a].out >= 1, `${a} 度数不对称`);
}
});
test("跨语言:分级计数与频率汇总逐位一致", () => {
assert.deepEqual(gradeCounts(rows, GRADES), py.gradeCounts);
assert.deepEqual(byFrequency(rows, freqs), py.byFrequency);
const total = Object.values(byFrequency(rows, freqs))
.reduce((a, v) => a + v.roundTripPersonDayX100, 0);
assert.equal(total, py.totalRoundTripPersonDayX100);
assert.equal(rows.length, py.interfaceCount);
});
test("跨语言:canonical 摘要与 digest 一致", () => {
const canon = canonical(degree(rows, ACTORS), gradeCounts(rows, GRADES), ACTORS, GRADES);
assert.equal(canon, py.canonical);
assert.equal(sha16(canon), py.digest);
});
在本机跑 node --test 'core/test/ta01_*.test.ts',真实输出(同机相对量级,耗时不可移植):
✔ 真实接口矩阵零违规 (0.888917ms)
✔ 负例:朴素校验器放行自环与单向接口,真校验器必须拦下 (0.199ms)
✔ 负例:blocking 级接口不写失败模式必须被拦 (0.077625ms)
✔ 跨语言:度数账逐位一致 (0.150208ms)
✔ 跨语言:分级计数与频率汇总逐位一致 (0.134583ms)
✔ 跨语言:canonical 摘要与 digest 一致 (0.337667ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
三条负例各守一个方向:自环与单向接口 守住「矩阵必须有结构」,blocking 不写失败模式 守住「阻断级接口必须能被解释」,真实矩阵零违规 则是最基本的一条——判据本身不能冤枉真数据。
第 1 章到此立完了能力模型:三类工作、一份能力矩阵、一张接口矩阵,共同点是都可断言。下一章把这三样接到真实资产上:DCC → 导出 → 导入 → 运行时四阶段里,TA 的产物在哪个环节落地,以及为什么真实 DCC 这一步在本机不可复现。
阅读导航:上一节:1.2 能力矩阵与产出物 · 下一节:2.1 资产管线四阶段:DCC → 导出 → 导入 → 运行时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。