三卷走完,16.2 三卷总收官:一份规范的完整回放
证明了同一条链在 6 个主题上跑得通。本节收尾:这一套到底交付了什么。交付物清单分成三类——spec-text(给人读的规范文本)、schema(机器校验的静态结构)、runnable-artifact(可跑产物)。本套三卷即全部,所以清单里不留 in-progress / planned,所有状态必须为 done。
16.3.1 交付物清单的静态形态
清单落成 config/st16_deliverables.json:12 项交付物,每项四个必填字段(id / kind / producer / status),producer 指向真正的产物路径。这份清单的收尾判据是「全部 done」——它是终点线,不是留给后续卷的接口:
{
"project": "stardust-inn",
"volume": "风格与品类美术实战",
"series": "游戏美术与技术美术",
"note": "卷三 16.3 交付物清单。kind 三类:spec-text=给人读的规范文本,schema=机器校验的静态结构,runnable-artifact=可跑产物。本套三卷即全部,故本卷不留 in-progress / planned:所有交付物状态必须为 done(这是本清单的收尾判据,不是留给后续卷的接口)。",
"requiredFields": ["id", "kind", "producer", "status"],
"allowedKinds": ["spec-text", "schema", "runnable-artifact"],
"allowedStatus": ["done", "in-progress", "planned"],
"deliverables": [
{ "id": "spec.source", "kind": "schema", "producer": "config/visual_spec.json", "status": "done" },
{ "id": "themes.six", "kind": "schema", "producer": "config/sp16_themes.json", "status": "done" },
{ "id": "acceptance.checklist", "kind": "schema", "producer": "config/st16_acceptance.json", "status": "done" },
{ "id": "acceptance.runner", "kind": "runnable-artifact", "producer": "sim/st16_acceptance.py", "status": "done" },
{ "id": "replay.chain", "kind": "runnable-artifact", "producer": "sim/st16_replay.py", "status": "done" },
{ "id": "bench.sensitivity", "kind": "runnable-artifact", "producer": "sim/st16_bench.py", "status": "done" },
{ "id": "distance.matrix", "kind": "runnable-artifact", "producer": "sim/st01_distance.py", "status": "done" },
{ "id": "lowpoly.budget", "kind": "runnable-artifact", "producer": "sim/st04_budget.py", "status": "done" },
{ "id": "board.closure", "kind": "runnable-artifact", "producer": "web/build_st16_board.mjs", "status": "done" },
{ "id": "checklist.text", "kind": "spec-text", "producer": "content/posts/books/风格与品类美术实战/art-style-16-1-cross-style-acceptance-checklist.md", "status": "done" },
{ "id": "finale.text", "kind": "spec-text", "producer": "content/posts/books/风格与品类美术实战/art-style-16-2-three-volume-finale-spec-replay.md", "status": "done" },
{ "id": "closing.text", "kind": "spec-text", "producer": "content/posts/books/风格与品类美术实战/art-style-16-3-deliverable-and-next.md", "status": "done" }
],
"furtherReading": [
"/graphics-color-management-hdr-pipeline/",
"/game-art-direction-style-bible-2026/",
"/indie-game-pixel-art-visual-style-guide/",
"/game-performance-budget-mobile-pc-2026/"
]
}
16.3.2 S 层:清单完整性校验
sim/st16_deliverables.py 做完整性校验:字段是否齐全、kind/status 是否在允许集合内、id 是否唯一,以及最关键的一条——status != "done" 即 NOT_DONE 违例。违例字符串排序去重后落盘供 P 层对照:
#!/usr/bin/env python3
# file: sim/st16_deliverables.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st16_deliverables.py
"""卷三 16.3 交付物清单完整性校验(S 层)。读 config/st16_deliverables.json,对交付物清单做
完整性校验:字段齐全、kind/status 合法、id 唯一,且——本套三卷即全部——所有交付物状态必须
为 done(NOT_DONE 即违例)。违例字符串排序去重后落 out/st16_deliverables.json 供 P 层对照。
全是字符串与集合运算,逐位一致。"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def check(h: dict) -> list:
req = h["requiredFields"]
kinds, statuses = set(h["allowedKinds"]), set(h["allowedStatus"])
items = h["deliverables"]
ids = [d["id"] for d in items]
viol = []
for d in items:
for f in req:
if not d.get(f):
viol.append(f"MISSING_FIELD:{d.get('id', '?')}:{f}")
if d.get("kind") not in kinds:
viol.append(f"BAD_KIND:{d.get('id', '?')}:{d.get('kind')}")
if d.get("status") not in statuses:
viol.append(f"BAD_STATUS:{d.get('id', '?')}:{d.get('status')}")
if ids.count(d.get("id")) > 1:
viol.append(f"DUPLICATE_ID:{d.get('id')}")
if d.get("status") != "done":
viol.append(f"NOT_DONE:{d.get('id')}:{d.get('status')}")
return sorted(set(viol))
def digest(h: dict, viol: list) -> str:
parts = [f"{d['id']}:{d['kind']}:{d['status']}" for d in h["deliverables"]]
parts += list(h["furtherReading"]) + viol
return hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]
def main() -> int:
h = json.load(open(os.path.join(ROOT, "config/st16_deliverables.json"), encoding="utf-8"))
viol = check(h)
by_kind = {}
for d in h["deliverables"]:
by_kind[d["kind"]] = by_kind.get(d["kind"], 0) + 1
report = {"project": h["project"], "volume": h["volume"], "series": h["series"],
"deliverableCount": len(h["deliverables"]), "byKind": by_kind,
"doneCount": sum(1 for d in h["deliverables"] if d["status"] == "done"),
"furtherReadingCount": len(h["furtherReading"]),
"furtherReading": h["furtherReading"], "violations": viol, "count": len(viol)}
report["digest"] = digest(h, viol)
with open(os.path.join(ROOT, "out/st16_deliverables.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"deliverables={report['deliverableCount']} done={report['doneCount']} "
f"violations={report['count']}")
for k in sorted(by_kind):
print(f" {k:<18} {by_kind[k]}")
for u in h["furtherReading"]:
print(f" READ {u}")
for v in viol:
print(f" VIOLATION {v}")
print(f"digest={report['digest']}")
print("wrote out/st16_deliverables.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st16_deliverables.py:
deliverables=12 done=12 violations=0
runnable-artifact 6
schema 3
spec-text 3
READ /graphics-color-management-hdr-pipeline/
READ /game-art-direction-style-bible-2026/
READ /indie-game-pixel-art-visual-style-guide/
READ /game-performance-budget-mobile-pc-2026/
digest=7ccaf1577f478c9f
wrote out/st16_deliverables.json
12 项全 done,0 违例。同一套规则在 core/src/st16/deliverables.ts 用可擦除 TS 重写,core/test/st16_deliverables.test.ts 读落盘做跨语言对照(node --test 'core/test/st16_*.test.ts' 中本节 3 条全过),违例集合与 Python 逐位一致:
// file: core/src/st16/deliverables.ts
// tier: P
// verify: node --test 'core/test/st16_*.test.ts'
// 卷三 16.3 交付物清单完整性的 P 层内核。与 sim/st16_deliverables.py 同一套规则:字段齐全、
// kind/status 合法、id 唯一,且——本套三卷即全部——所有交付物状态必须为 done。只允许可擦除 TS。
export function checkDeliverables(h: any): string[] {
const req: string[] = h.requiredFields;
const kinds = new Set<string>(h.allowedKinds);
const statuses = new Set<string>(h.allowedStatus);
const items = h.deliverables;
const ids = items.map((d: any) => d.id);
const viol: string[] = [];
for (const d of items) {
for (const f of req) {
if (!d[f]) viol.push(`MISSING_FIELD:${d.id ?? "?"}:${f}`);
}
if (!kinds.has(d.kind)) viol.push(`BAD_KIND:${d.id ?? "?"}:${d.kind}`);
if (!statuses.has(d.status)) viol.push(`BAD_STATUS:${d.id ?? "?"}:${d.status}`);
if (ids.filter((x: string) => x === d.id).length > 1) viol.push(`DUPLICATE_ID:${d.id}`);
if (d.status !== "done") viol.push(`NOT_DONE:${d.id}:${d.status}`);
}
return [...new Set(viol)].sort();
}
16.3.3 W 层:一张可切主题的收官看板
web/build_st16_board.mjs 复用第 13 章的主题切换看板思路,把 sp00/spec.ts、st01/style.ts、st16/acceptance.ts 擦除类型后内联进单文件 HTML:一个按钮在 6 个主题包间循环,色卡、验收清单与 6×6 距离矩阵热力图随之重绘,再列交付物总览。它同时把 Python 侧的清单求值搬进浏览器,用来证明同一张清单在第三种运行时里也一致:
// file: web/build_st16_board.mjs
// tier: W
// verify: node web/build_st16_board.mjs
// 卷三 16.3 W 层:把 core/src/sp00/spec.ts + st01/style.ts + st16/acceptance.ts 擦除类型后
// 内联进单文件 HTML。看板复刻 ch13 的主题切换思路:一个按钮在 6 个主题包间循环,色卡、验收
// 清单与 6×6 距离矩阵热力图随之重绘;再列交付物总览。数据全部来自只读 config 与 Python 落盘。
import { stripTypeScriptTypes } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const read = (p) => JSON.parse(readFileSync(resolve(ROOT, p), "utf8"));
const strip = (p) => stripTypeScriptTypes(readFileSync(resolve(ROOT, p), "utf8"), { mode: "strip" })
.replace(/^import[^;]*;\s*$/gm, "").replace(/^export\s*\{[^}]*\};?\s*$/gm, "").replace(/^export\s+/gm, "");
const js = ["core/src/sp00/spec.ts", "core/src/st01/style.ts", "core/src/st16/acceptance.ts"].map(strip).join("\n");
const spec = read("config/visual_spec.json");
const themes = read("config/sp16_themes.json");
const acc = read("config/st16_acceptance.json");
const deliv = read("out/st16_deliverables.json");
const replay = read("out/st16_replay.json");
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>prism 跨风格验收看板 · ${themes.project}</title>
<style>
:root{color-scheme:dark}
body{margin:0;padding:22px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui,-apple-system,"PingFang SC",sans-serif}
h1{font-size:18px;margin:0 0 4px}.sub{color:#94a3b8;font-size:12px;margin-bottom:18px}
section{margin-bottom:22px}h2{font-size:13px;color:#94a3b8;font-weight:600;text-transform:uppercase;letter-spacing:.08em;margin:0 0 10px}
#pal{display:flex;gap:8px;flex-wrap:wrap}.sw{width:84px;border-radius:6px;overflow:hidden;border:1px solid #2d3a47}
.sw .chip{height:48px}.sw .meta{padding:5px 7px;font:11px/1.4 ui-monospace,monospace;background:#252f3b;white-space:pre-line}
table{border-collapse:collapse;font:12px/1.5 ui-monospace,monospace;min-width:360px}
th,td{text-align:left;padding:3px 8px;border-bottom:1px solid #2d3a47}
.ok{color:#22c55e}.bad{color:#ef4444}canvas{display:block;background:#111827;border:1px solid #2d3a47;border-radius:6px}
.row{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
button{background:#2d3a47;color:#eceff1;border:1px solid #3d4a57;border-radius:6px;padding:6px 12px;cursor:pointer;font:12px system-ui}
</style></head><body>
<h1>prism 跨风格验收看板</h1>
<div class="sub">project ${themes.project} · 6 主题同源可区分 · 一份规范,三处消费、一处生成</div>
<section><h2>当前主题色卡</h2><div class="row"><div id="pal"></div>
<div><button id="theme">切换主题</button>
<div class="sub" id="who" style="margin-top:8px"></div></div></div></section>
<section><h2>跨风格验收清单(8 项)</h2>
<table id="ck"><thead><tr><th>清单项</th><th>取值</th><th>判定</th></tr></thead><tbody></tbody></table></section>
<section><h2>6×6 风格距离矩阵(×10 整数)</h2><canvas id="mx" width="440" height="360"></canvas></section>
<section><h2>交付物总览</h2><div class="sub" id="del"></div></section>
<script type="module">
${js}
const SPEC = ${JSON.stringify(spec)};
const CFG = ${JSON.stringify(themes)};
const ACC = ${JSON.stringify(acc)};
const DELIV = ${JSON.stringify(deliv)};
const ORDER = CFG.themes.map((t) => t.id);
const VECS = {};
for (const t of CFG.themes) VECS[t.id] = styleVector(t.tokens, t.strokePermille, t.detailPermille);
const PAIRS = ORDER.length * (ORDER.length - 1) / 2;
const rgb = (t) => "rgb(" + t.join(",") + ")";
function render(idx) {
const theme = CFG.themes[idx];
const pal = document.getElementById("pal"); pal.innerHTML = "";
for (const [name, c] of Object.entries(theme.tokens)) {
const d = document.createElement("div"); d.className = "sw"; d.dataset.token = name;
const chip = document.createElement("div"); chip.className = "chip"; chip.style.background = rgb(c);
const meta = document.createElement("div"); meta.className = "meta";
meta.textContent = name + "\\n#" + c.map((x) => x.toString(16).padStart(2, "0")).join("");
d.append(chip, meta); pal.append(d);
}
const ctx = { cfg: ACC, spec: SPEC, vecs: VECS, atlas: atlasPermille(SPEC.atlas),
mip: mipChainBytes(SPEC.budget.baseTexture, SPEC.budget.bytesPerPixel), pairs: PAIRS,
nearest: nearestDistance(theme.id, VECS, CFG.weights) };
const tb = document.querySelector("#ck tbody"); tb.innerHTML = "";
const checks = ACC.items.map((it) => { const [v, ok] = evaluate(it.id, theme, ctx); return { id: it.id, value: v, pass: ok }; });
for (const c of checks) {
const tr = document.createElement("tr");
tr.innerHTML = "<td>" + c.id + "</td><td>" + c.value + "</td><td class='" + (c.pass ? "ok" : "bad") + "'>" + (c.pass ? "pass" : "fail") + "</td>";
tb.append(tr);
}
drawMatrix(VECS);
document.getElementById("who").textContent = "当前:" + theme.id + "(第 " + (idx + 1) + "/" + ORDER.length + ")· 最近邻距离 " + ctx.nearest + " > " + CFG.distinguishableFloor10;
globalThis.__prism = { theme: theme.id, themeIndex: idx, order: ORDER, tokenCount: Object.keys(theme.tokens).length,
checks, passCount: checks.filter((c) => c.pass).length, nearest: ctx.nearest, floor10: CFG.distinguishableFloor10,
deliverableCount: DELIV.deliverableCount, doneCount: DELIV.doneCount, byKind: DELIV.byKind,
matrixCanonical: matrixCanonical() };
}
function matrixCanonical() {
const parts = ORDER.map((n) => n + ":" + VECS[n].join(","));
for (let i = 0; i < ORDER.length; i += 1)
for (let j = i + 1; j < ORDER.length; j += 1)
parts.push(ORDER[i] + ">" + ORDER[j] + ":" + dist10(VECS[ORDER[i]], VECS[ORDER[j]], CFG.weights, [0, 1, 2, 3, 4]));
return parts.join(";");
}
function drawMatrix(vecs) {
const cv = document.getElementById("mx"); const c = cv.getContext("2d");
c.clearRect(0, 0, cv.width, cv.height); c.fillStyle = "#111827"; c.fillRect(0, 0, cv.width, cv.height);
const n = ORDER.length, cell = 46, x0 = 70, y0 = 40;
let mx = 1;
const M = ORDER.map((a) => ORDER.map((b) => (a === b ? 0 : dist10(vecs[a], vecs[b], CFG.weights, [0, 1, 2, 3, 4]))));
for (const r of M) for (const d of r) mx = Math.max(mx, d);
for (let i = 0; i < n; i += 1) {
c.fillStyle = "#cbd5e1"; c.font = "10px monospace";
c.fillText(ORDER[i], 6, y0 + i * cell + 26);
c.fillText(ORDER[i].slice(0, 9), x0 + i * cell + 2, y0 - 8);
for (let j = 0; j < n; j += 1) {
const d = M[i][j], t = d / mx;
c.fillStyle = "rgb(" + Math.floor(59 + t * 180) + "," + Math.floor(130 - t * 80) + "," + Math.floor(246 - t * 170) + ")";
c.fillRect(x0 + j * cell, y0 + i * cell, cell - 3, cell - 3);
c.fillStyle = "#0b1220"; c.font = "11px monospace";
c.fillText(String(d), x0 + j * cell + 5, y0 + i * cell + 26);
}
}
}
let idx = 0;
document.getElementById("theme").addEventListener("click", () => { idx = (idx + 1) % ORDER.length; render(idx); });
render(0);
document.title = "prism st16 board ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/st16_board.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/st16_board.html (${Buffer.byteLength(html)} bytes)`);
实跑 node web/build_st16_board.mjs,再用 Playwright 无头 Chromium 断言(13 条全过,此处只摘关键几条):
built web/dist/st16_board.html (21378 bytes)
pass 无 pageerror
pass 6 个主题同序 pixel,lowpoly,handdrawn,voxel,retro-cyber,cozy-clean
pass 8 项验收清单与 Python 逐项一致
pass 最近邻距离与 Python 一致 1337 vs 1337
pass 交付物 12 项全 done 12/12
pass 6×6 矩阵摘要命中跨卷锚点 fa04e9418d9f3c2c
pass canvas 已绘制 158400 px
pass 主题切到 lowpoly
pass 截图非空 90384/90688 bytes
ℹ pass 13
ℹ fail 0
看板里 8 项清单与 out/st16_acceptance.json 逐项一致,矩阵摘要仍命中锚点——同一张清单在 Python、TypeScript、浏览器三种运行时下给出同一个答案,这就是「一份规范,三处消费、一处生成」的收尾证据。
16.3.4 后续与延伸阅读
本套三卷到此即为全部:没有续卷,也没有留 planned 的口子。往下走是把它用起来——色彩管理、风格圣经、像素风视觉指南、性能预算四条延伸阅读见上方清单的 furtherReading 字段,逐条可从站内短文继续深入。三卷的完整文件索引与逐层复现命令见本卷 附录 A:风格参数表、量化规则与数据集索引
。
阅读导航:上一节:16.2 三卷总收官:一份规范的完整回放 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。