16.3 交付物与后续

三卷的交付物收尾:12 项分三类(spec-text / schema / runnable-artifact),本套三卷即全部,故状态必须全部 done。S 层校验完整性、P 层与 Python 逐位一致,W 层用一张可切主题的单文件看板把清单、矩阵与交付物总览画出来;再给出四条延伸阅读。

三卷走完,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 三卷总收官:一份规范的完整回放 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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