14.3 跨品类复用与移植
讲的是资产怎么跨品类搬;本节把「搬」的对象换成规范本身:同一份 config/visual_spec.json 的结构,在 6 个主题包下各实例化一次。这是本卷的中心实证,也是全书第二次实证——卷一 16.2 规范装配
与 卷二 16.2 同一条管线
已从规范侧与管线侧各跑过一次,本节从实例化侧再跑一次。
6 个主题包与卷一 ch16、卷二 ch16 完全同名同序:pixel / lowpoly / handdrawn / voxel / retro-cyber / cozy-clean。像素画法本身另见 《独立游戏像素美术风格指南》
,本节不重推技法。
15.1 规范实例化:6 主题包
「实例化」不是「复制六份规范」,而是同一份结构填六组值。结构——命名规则、预算、对比度对与下限、图集——六实例逐字相同,这叫作「同源」;值——8 个色彩 token、描边与细节两个风格参数——六实例各不相同,这叫作「可区分」。一份完整 spec 实例由三处来源拼成,全部只读:
| 来源 | 贡献 | 六实例是否相同 |
|---|---|---|
config/visual_spec.json | budget / atlas / contrastPairs / contrastFloor10 | 相同 |
config/st15_instantiation.json | 命名规则段(visual_spec.json 不含命名段,故本章补一份) | 相同 |
config/sp16_themes.json | 6 组 tokens 与 strokePermille / detailPermille | 不同 |
命名段只有一条正则与一份类型白名单,取自本章配置:
{
"pattern": "^(chr|env|ui|fx|ico)_[a-z0-9]+(?:_[a-z0-9]+)*_v[0-9]{3}$",
"types": ["chr", "env", "ui", "fx", "ico"],
"samples": ["chr_innkeeper_idle_v001", "env_lobby_tile_grass_v012", "ui_button_primary_v003",
"fx_coin_sparkle_v007", "ico_token_danger_v002", "chr_guest_avatar_v021"]
}
S 层:同一套 validator 跑 6 遍
下面这段是 S 层脚本 sim/st15_instantiate.py。它把三处来源拼成 6 个实例,再跑同一套五道闸门——naming(6 个样例名全部匹配且类型前缀合法)、budget(三角面上下限、骨骼数、材质槽、贴图边长 2 的幂、每像素字节数)、tokens(8 个角色色齐备且都是 0–255 整数三元组)、contrast(5 对对比度达 contrastFloor10)、style(两个风格参数在声明范围内):
#!/usr/bin/env python3
# file: sim/st15_instantiate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st15_instantiate.py
"""卷三 15.1 规范实例化(S 层):同一份规范结构在 6 个主题下各实例化一次,跑同一套 validator。
结构来源(全部只读):config/visual_spec.json(budget / atlas / contrastPairs /
contrastFloor10)、config/sp16_themes.json(6 主题 tokens + 两个风格参数)、
config/st15_instantiation.json(命名段——visual_spec.json 不含命名段)。
五道闸门 naming / budget / tokens / contrast / style 的判据与阈值六实例完全一致(同源),
只有 tokens 与两个风格参数不同(可区分)。6 个实例都必须零违例。
落 out/st15_instantiate.json 供 P 层跨语言对照。
"""
import hashlib
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLE_ORDER = ["bg", "surface", "ink", "muted", "accent", "ok", "warn", "danger"]
TH, DIV, OFF, SCALE, GAMMA = 0.04045, 12.92, 0.055, 1.055, 2.4
LR, LG, LB = 0.2126, 0.7152, 0.0722
def lin(c):
s = c / 255
return s / DIV if s <= TH else ((s + OFF) / SCALE) ** GAMMA
def contrast10(a, b):
"""WCAG 对比度 ×10 取整 → 整数类,逐位一致(+0.5 地板除 ≡ TS 的 Math.round)。"""
f = lambda c: LR * lin(c[0]) + LG * lin(c[1]) + LB * lin(c[2])
hi, lo = sorted((f(a), f(b)), reverse=True)
return int(((hi + 0.05) / (lo + 0.05)) * 10 + 0.5)
def pow2(n):
return n >= 1 and (n & (n - 1)) == 0
def bad_budget(b):
return [n for n, c in (("trisLow>trisMid", b["trisLow"] > b["trisMid"]),
("bonesLow<=0", b["bonesLow"] <= 0),
("slots<1", b["materialSlotsLow"] < 1),
("baseTexture", not pow2(b["baseTexture"])),
("bpp", b["bytesPerPixel"] not in (1, 2, 4, 8))) if c]
def bad_tokens(tk):
ok = lambda v: isinstance(v, list) and len(v) == 3 and all(isinstance(c, int) and 0 <= c <= 255 for c in v)
bad = [f"rgb:{k}" for k in ROLE_ORDER if not ok(tk.get(k))]
if set(tk) != set(ROLE_ORDER):
bad.append(f"role-set:{len(tk)}")
return bad
def instance(theme, spec, naming):
"""一份完整 spec 实例:命名 + 预算 + 色彩 token + 对比度对 + 图集 + 两个风格参数。"""
return {"id": theme["id"], "naming": naming["naming"], "budget": spec["budget"],
"tokens": theme["tokens"], "contrastPairs": spec["contrastPairs"],
"contrastFloor10": spec["contrastFloor10"], "atlas": spec["atlas"],
"strokePermille": theme["strokePermille"], "detailPermille": theme["detailPermille"]}
def gates(inst, naming):
"""同一套 validator:五道闸门,返回 [(闸门名, 通过, 细节)]。"""
nm = inst["naming"]
rx = re.compile(nm["pattern"])
ok = [s for s in nm["samples"] if rx.match(s) and s.split("_")[0] in nm["types"]]
bb, bt = bad_budget(inst["budget"]), bad_tokens(inst["tokens"])
tk = inst["tokens"]
mn = min(contrast10(tk[a], tk[b]) for a, b in inst["contrastPairs"])
rng = naming["styleRange"]
style_ok = (rng["strokePermille"][0] <= inst["strokePermille"] <= rng["strokePermille"][1]
and rng["detailPermille"][0] <= inst["detailPermille"] <= rng["detailPermille"][1])
return [("naming", len(ok) == len(nm["samples"]), f"{len(ok)}/{len(nm['samples'])}"),
("budget", not bb, "5/5" if not bb else ",".join(bb)),
("tokens", not bt, "8/8" if not bt else ",".join(bt)),
("contrast", mn >= inst["contrastFloor10"], f"minContrast10={mn}"),
("style", style_ok, f"stroke={inst['strokePermille']} detail={inst['detailPermille']}")]
def main():
load = lambda p: json.load(open(os.path.join(ROOT, "config", p), encoding="utf-8"))
spec, cfg, naming = load("visual_spec.json"), load("sp16_themes.json"), load("st15_instantiation.json")
recs, parts = [], []
for t in cfg["themes"]:
inst = instance(t, spec, naming)
g = gates(inst, naming)
bits = "".join("1" if p else "0" for _, p, _ in g)
rgb = ",".join(str(v) for r in ROLE_ORDER for v in inst["tokens"][r])
recs.append({"id": inst["id"], "bits": bits, "pass": bits == "1" * len(g),
"gates": [{"name": n, "pass": p, "detail": d} for n, p, d in g],
"tokens": inst["tokens"], "strokePermille": inst["strokePermille"],
"detailPermille": inst["detailPermille"],
"fingerprint": hashlib.sha256(f"{inst['id']}|{bits}|{rgb}".encode()).hexdigest()[:8]})
parts.append(f"{inst['id']}:{bits}:{rgb}")
canon = ";".join(parts)
rep = {"project": cfg["project"], "specVersion": cfg["specVersion"], "gateCount": 5,
"order": [t["id"] for t in cfg["themes"]], "roleOrder": ROLE_ORDER, "instances": recs,
"passCount": sum(1 for r in recs if r["pass"]),
"violations": [f"{r['id']}:{x['name']}" for r in recs for x in r["gates"] if not x["pass"]],
"canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
with open(os.path.join(ROOT, "out/st15_instantiate.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"themes={len(recs)} gates/theme=5 pass={rep['passCount']}/{len(recs)} "
f"violations={len(rep['violations'])}")
for r in recs:
mn = next(x["detail"] for x in r["gates"] if x["name"] == "contrast")
print(f" {r['id']:<12} gates={r['bits']} {mn:<18} stroke={r['strokePermille']:<4} "
f"detail={r['detailPermille']:<4} pass={r['pass']}")
print(f"digest={rep['digest']}")
print("wrote out/st15_instantiate.json")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/st15_instantiate.py:
themes=6 gates/theme=5 pass=6/6 violations=0
pixel gates=11111 minContrast10=45 stroke=620 detail=780 pass=True
lowpoly gates=11111 minContrast10=51 stroke=120 detail=210 pass=True
handdrawn gates=11111 minContrast10=46 stroke=480 detail=520 pass=True
voxel gates=11111 minContrast10=48 stroke=300 detail=640 pass=True
retro-cyber gates=11111 minContrast10=55 stroke=540 detail=700 pass=True
cozy-clean gates=11111 minContrast10=46 stroke=150 detail=260 pass=True
digest=9fae256b87a90705
wrote out/st15_instantiate.json
六行的 gates=11111 是五道闸门全过。判据与阈值六实例完全一致,结论也完全一致——六次全过,零违例;变的只有 minContrast10(45 / 51 / 46 / 48 / 55 / 46)与两个风格参数。这正是「同一套判据套住六套主题」。
P 层:同一套判据的 TS 内核
下面这段是 P 层 TS 内核 core/src/st15/instantiate.ts,五道闸门与 Python 逐条对应,对比度复用 sp00/spec.ts 的同源常数:
// file: core/src/st15/instantiate.ts
// tier: P
// verify: node --test 'core/test/st15_*.test.ts'
// 15.1 规范实例化的 TS 内核:与 sim/st15_instantiate.py 同一套五道闸门
// (naming / budget / tokens / contrast / style)。对比度复用 sp00/spec.ts 的同源常数,
// canonical 摘要串与 Python 逐字同式,故两语言 digest 可对拍。全整数,逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { contrast10 } from "../sp00/spec.ts";
export const ROLE_ORDER: string[] = ["bg", "surface", "ink", "muted", "accent", "ok", "warn", "danger"];
const isPow2 = (n: number): boolean => n >= 1 && (n & (n - 1)) === 0;
export type Gate = { name: string; pass: boolean; detail: string };
export function gateNaming(naming: any): string[] {
const rx = new RegExp(naming.pattern);
return naming.samples.filter((s: string) => !rx.test(s) || !naming.types.includes(s.split("_")[0]));
}
export function gateBudget(budget: any): string[] {
const bad: string[] = [];
if (budget.trisLow > budget.trisMid) bad.push("trisLow>trisMid");
if (budget.bonesLow <= 0) bad.push("bonesLow<=0");
if (budget.materialSlotsLow < 1) bad.push("materialSlotsLow<1");
if (!isPow2(budget.baseTexture)) bad.push("baseTexture-not-pow2");
if (![1, 2, 4, 8].includes(budget.bytesPerPixel)) bad.push("bpp-not-allowed");
return bad;
}
export function gateTokens(tokens: any): string[] {
const bad: string[] = [];
const keys = Object.keys(tokens);
if (keys.length !== ROLE_ORDER.length || ROLE_ORDER.some((k) => !keys.includes(k))) bad.push(`role-set:${keys.length}`);
for (const k of ROLE_ORDER) {
const v = tokens[k];
if (!Array.isArray(v) || v.length !== 3 || !v.every((c: number) => Number.isInteger(c) && c >= 0 && c <= 255)) {
bad.push(`rgb:${k}`);
}
}
return bad;
}
/** 同一套 validator:五道闸门,五道全过即零违例。 */
export function validateInstance(inst: any, naming: any): Gate[] {
const badN = gateNaming(inst.naming);
const badB = gateBudget(inst.budget);
const badT = gateTokens(inst.tokens);
let mn = 1e9;
for (const [a, b] of inst.contrastPairs) mn = Math.min(mn, contrast10(inst.tokens[a], inst.tokens[b]));
const [loS, hiS] = naming.styleRange.strokePermille;
const [loD, hiD] = naming.styleRange.detailPermille;
const styleOk = inst.strokePermille >= loS && inst.strokePermille <= hiS
&& inst.detailPermille >= loD && inst.detailPermille <= hiD;
return [
{ name: "naming", pass: badN.length === 0, detail: `${inst.naming.samples.length - badN.length}/${inst.naming.samples.length}` },
{ name: "budget", pass: badB.length === 0, detail: badB.length ? badB.join(",") : "5/5" },
{ name: "tokens", pass: badT.length === 0, detail: badT.length ? badT.join(",") : "8/8" },
{ name: "contrast", pass: mn >= inst.contrastFloor10, detail: `minContrast10=${mn}` },
{ name: "style", pass: styleOk, detail: `stroke=${inst.strokePermille} detail=${inst.detailPermille}` },
];
}
/** 一份完整 spec 实例:命名 + 预算 + 色彩 token + 对比度对 + 图集 + 两个风格参数。 */
export function instantiate(theme: any, spec: any, naming: any): any {
return { id: theme.id, naming: naming.naming, budget: spec.budget, tokens: theme.tokens,
contrastPairs: spec.contrastPairs, contrastFloor10: spec.contrastFloor10, atlas: spec.atlas,
strokePermille: theme.strokePermille, detailPermille: theme.detailPermille };
}
/** canonical 摘要串:与 sim/st15_instantiate.py 逐字同式(`;` 连接,无空白)。 */
export function canonical(records: { id: string; bits: string; tokens: any }[]): string {
return records
.map((r) => `${r.id}:${r.bits}:${ROLE_ORDER.map((k) => r.tokens[k].join(",")).join(",")}`)
.join(";");
}
core/test/st15_instantiate.test.ts 读 Python 落盘的 out/st15_instantiate.json 做跨语言对照,并构造四条负例。本机实跑 node --test 'core/test/st15_*.test.ts':
✔ 6 主题逐实例五道闸门:TS 与 Python 结论一致且全过 (1.498542ms)
✔ canonical 摘要 + sha256 指纹:跨语言逐位一致 (0.433208ms)
✔ 同源:五道判据的结构(阈值/下限/图集/预算)六实例完全相同 (0.080167ms)
✔ 负例:命名/预算/token/对比度四类违例都必须 fail (0.194709ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
前两条断言 TS 与 Python 的五道闸门、bits、canonical 摘要与 sha256 指纹逐位一致;第三条断言「同源」——六实例的 budget / contrastPairs / contrastFloor10 / atlas 逐字相同;第四条是负例:命名越界、预算越界、token 越界、对比度塌陷四类违例都必须被拦下。
自检表
| 检查项 | 合格标准 |
|---|---|
| 结构同源 | 五个结构字段六实例逐字相同,且能指认各自来源 |
| 六次全过 | 6 行实跑输出全 gates=11111,violations=0 |
| 跨语言对拍 | canonical 摘要与 sha256 指纹 TS / Python 逐位一致 |
| 有负例 | 命名 / 预算 / token / 对比度四类违例各有一条负例断言 |
实例化证明「同一套判据能套住六套主题」,但套得住不等于分得开——六套主题会不会长得太像?下一节把这个问题变成一个可以断言的整数。
阅读导航:上一节:14.3 跨品类复用与移植 · 下一节:15.2 同源可区分:风格距离矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。