16.2.1 同一份规范,六个实例
16.1 把规范收成了模板,但模板只证明「章节齐了」,不证明「这套规范真能在多个风格下落地」。本节做装配回放:把同一份 visual_spec.json 的结构(8 个色彩 token、5 对对比度、图集与预算)在 6 个主题下各实例化一次——pixel / lowpoly / handdrawn / voxel / retro-cyber / cozy-clean——然后用同一套 validator 跑六遍。「同源」指结构、判据、下限完全一致;「可区分」指六个实例的色彩与风格参数差异足够大。风格谱系的逐风格展开是卷三的主题(art-style-* 尚未落盘,此处只作书名提及,不给链接);像素画法本身另见 《独立游戏像素美术风格指南》
,本节不重推。
16.2.2 D 层:六主题规范实例
config/sp16_themes.json 是六个实例的静态输入。contrastFloor10、contrastPairs、weights 六实例完全相同,只有 tokens 与两个风格参数不同——这正是「同源但可区分」的字面含义。下面只截取头部与两个主题,完整六主题见落盘文件:
{
"project": "stardust-inn",
"specVersion": 1,
"contrastFloor10": 45,
"contrastPairs": [["ink", "bg"], ["muted", "bg"], ["ink", "surface"], ["accent", "bg"], ["danger", "surface"]],
"weights": [2, 2, 1, 3, 3],
"distinguishableFloor10": 800,
"themes": [
{ "id": "pixel", "strokePermille": 620, "detailPermille": 780, "tokens": { "bg": [26, 28, 44], "surface": [42, 46, 68], "ink": [240, 240, 232], "muted": [150, 160, 190], "accent": [255, 209, 102], "ok": [110, 220, 140], "warn": [255, 150, 70], "danger": [255, 96, 110] } },
{ "id": "cozy-clean", "strokePermille": 150, "detailPermille": 260, "tokens": { "bg": [245, 240, 230], "surface": [228, 220, 206], "ink": [92, 80, 66], "muted": [110, 98, 84], "accent": [168, 84, 52], "ok": [64, 116, 74], "warn": [150, 84, 32], "danger": [170, 52, 48] } }
]
}
16.2.3 S 层:逐实例校验 + 风格距离矩阵
下面这段是 S 层脚本。它对每个主题构造一份 spec 实例并跑同一套 validator(5 对对比度是否都达下限、token 是否齐 8 个),再算 6×6 风格距离矩阵(整数 ×10):
#!/usr/bin/env python3
# file: sim/sp16_six.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp16_six.py
"""16.2 六主题规范装配的 S 层脚本。把 config/sp16_themes.json 的 6 个主题各当成一份完整
spec 实例,用同一套 validator 逐个校验(5 对对比度是否都达 contrastFloor10、token 是否
齐 8 个),6 个实例都必须零违例;再算 6×6 风格距离矩阵(整数 ×10)证明「同源但可区分」。
落 out/sp16_six.json 供 P 层跨语言对照。"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLES = ["accent", "bg", "ink", "surface"]
def srgb_to_linear(c):
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
def contrast10(a, b):
la = 0.2126 * srgb_to_linear(a[0]) + 0.7152 * srgb_to_linear(a[1]) + 0.0722 * srgb_to_linear(a[2])
lb = 0.2126 * srgb_to_linear(b[0]) + 0.7152 * srgb_to_linear(b[1]) + 0.0722 * srgb_to_linear(b[2])
hi, lo = max(la, lb), min(la, lb)
return round(((hi + 0.05) / (lo + 0.05)) * 10)
def luma709(c):
return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000
def median_int(xs):
s = sorted(xs)
n = len(s)
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) // 2
def sat_permille(c):
mx, mn = max(c), min(c)
return 0 if mx == 0 else (mx - mn) * 1000 // mx
def hue_deg(c):
mx, mn = max(c), min(c)
d = mx - mn
if d == 0:
return None
h = (60 * (c[1] - c[2]) / d if mx == c[0]
else 60 * ((c[2] - c[0]) / d + 2) if mx == c[1]
else 60 * ((c[0] - c[1]) / d + 4))
return int(h % 360)
def style_vector(theme):
"""风格向量四维取 bg/surface/ink/accent 四个角色色(整数千分比)。"""
cols = [theme["tokens"][k] for k in ROLES]
hues = [h for h in (hue_deg(c) for c in cols) if h is not None]
span = (max(hues) - min(hues)) if len(hues) >= 2 else 0
return [median_int([luma709(c) for c in cols]) * 1000 // 255,
sum(sat_permille(c) for c in cols) // len(cols), span * 1000 // 360,
int(theme["strokePermille"]), int(theme["detailPermille"])]
def style_distance10(a, b, weights):
"""五维加权 L1 的整数均值再 ×10 取整 → 整数类,逐位一致。"""
return (sum(w * abs(x - y) for w, x, y in zip(weights, a, b)) * 10) // sum(weights)
def validate_instance(theme, cfg):
"""同一套 validator:对任意一份 spec 实例产出违例集合,空集 = 通过。"""
viol = [f"CONTRAST:{theme['id']}:{a}/{b}:{contrast10(theme['tokens'][a], theme['tokens'][b])}"
for a, b in cfg["contrastPairs"]
if contrast10(theme["tokens"][a], theme["tokens"][b]) < cfg["contrastFloor10"]]
if len(theme["tokens"]) != 8:
viol.append(f"TOKENS:{theme['id']}:{len(theme['tokens'])}")
return viol
def main():
cfg = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
floor10, weights = cfg["distinguishableFloor10"], cfg["weights"]
names = [t["id"] for t in cfg["themes"]]
vecs, records, violations = {}, [], []
for t in cfg["themes"]:
vecs[t["id"]] = style_vector(t)
v = validate_instance(t, cfg)
violations += v
mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in cfg["contrastPairs"])
records.append({"id": t["id"], "vector": vecs[t["id"]], "minContrast10": mn,
"violations": v, "pass": len(v) == 0})
matrix = [[0 if a == b else style_distance10(vecs[a], vecs[b], weights) for b in names]
for a in names]
flat = [(names[i], names[j], style_distance10(vecs[names[i]], vecs[names[j]], weights))
for i in range(len(names)) for j in range(i + 1, len(names))]
minp, maxp = min(flat, key=lambda r: r[2]), max(flat, key=lambda r: r[2])
parts = [f"{r['id']}:{','.join(str(x) for x in r['vector'])}" for r in records]
parts += [f"{a}>{b}:{d}" for a, b, d in flat]
report = {"project": cfg["project"], "specVersion": cfg["specVersion"], "order": names,
"themeCount": len(names), "contrastFloor10": cfg["contrastFloor10"],
"distinguishableFloor10": floor10, "themes": records, "matrix": matrix,
"minPair": {"a": minp[0], "b": minp[1], "d": minp[2]},
"maxPair": {"a": maxp[0], "b": maxp[1], "d": maxp[2]},
"belowFloor": [f"{a}/{b}:{d}" for a, b, d in flat if d <= floor10],
"violations": sorted(violations),
"passCount": sum(1 for r in records if r["pass"]),
"digest": hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]}
with open(os.path.join(ROOT, "out/sp16_six.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"themes={report['themeCount']} pass={report['passCount']}/{report['themeCount']} "
f"violations={len(report['violations'])}")
for r in records:
print(f" {r['id']:<12} minContrast10={r['minContrast10']:<4} pass={r['pass']}")
print(f"pairs={len(flat)} min={minp[0]}/{minp[1]}={minp[2]} max={maxp[0]}/{maxp[1]}={maxp[2]} "
f"floor10={floor10} below={len(report['belowFloor'])}")
print(f"digest={report['digest']}")
print("wrote out/sp16_six.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp16_six.py:
themes=6 pass=6/6 violations=0
pixel minContrast10=45 pass=True
lowpoly minContrast10=51 pass=True
handdrawn minContrast10=46 pass=True
voxel minContrast10=48 pass=True
retro-cyber minContrast10=55 pass=True
cozy-clean minContrast10=46 pass=True
pairs=15 min=lowpoly/cozy-clean=1002 max=retro-cyber/cozy-clean=3794 floor10=800 below=0
digest=fa04e9418d9f3c2c
wrote out/sp16_six.json
16.2.4 6×6 风格距离矩阵:同源但可区分
把 15 对距离铺成 6×6 矩阵(对角为 0,单位 ×10,越大越不像):
| pixel | lowpoly | handdrawn | voxel | retro-cyber | cozy-clean | |
|---|---|---|---|---|---|---|
| pixel | 0 | 3052 | 1929 | 1639 | 1337 | 3480 |
| lowpoly | 3052 | 0 | 2669 | 2168 | 3364 | 1002 |
| handdrawn | 1929 | 2669 | 0 | 1468 | 2242 | 1671 |
| voxel | 1639 | 2168 | 1468 | 0 | 1756 | 2038 |
| retro-cyber | 1337 | 3364 | 2242 | 1756 | 0 | 3794 |
| cozy-clean | 3480 | 1002 | 1671 | 2038 | 3794 | 0 |
最接近的一对是 lowpoly/cozy-clean = 1002(都是浅色),最远的是 retro-cyber/cozy-clean = 3794(一个霓虹暗色、一个奶白暖色)。下限 distinguishableFloor10 = 800,15 对全部高于它,belowFloor 为空——这就是「同源但可区分」的量化口径。
16.2.5 P 层:同一套判据的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp16/six.ts,对比度直接复用 sp00/spec.ts 的同源常数,风格向量与距离与 Python 逐条对应:
// file: core/src/sp16/six.ts
// tier: P
// verify: node --test 'core/test/sp16_*.test.ts'
// 16.2 六主题规范装配的 P 层内核。与 sim/sp16_six.py 同一套规则:对比度复用 sp00/spec.ts
// 的同源常数;风格向量四维取 bg/surface/ink/accent;风格距离 ×10 = 加权 L1 均值 ×10 取整。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { contrast10 } from "../sp00/spec.ts";
const CORE_ROLES = ["accent", "bg", "ink", "surface"];
export function luma709(c: number[]): number {
return Math.floor((2126 * c[0] + 7152 * c[1] + 722 * c[2]) / 10000);
}
export function medianInt(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
const n = s.length;
return n % 2 ? s[(n - 1) / 2] : Math.floor((s[n / 2 - 1] + s[n / 2]) / 2);
}
export function satPermille(c: number[]): number {
const mx = Math.max(...c);
const mn = Math.min(...c);
return mx === 0 ? 0 : Math.floor(((mx - mn) * 1000) / mx);
}
export function hueDeg(c: number[]): number | null {
const mx = Math.max(...c);
const mn = Math.min(...c);
const d = mx - mn;
if (d === 0) return null;
let h: number;
if (mx === c[0]) h = (60 * (c[1] - c[2])) / d;
else if (mx === c[1]) h = 60 * ((c[2] - c[0]) / d + 2);
else h = 60 * ((c[0] - c[1]) / d + 4);
return Math.floor(((h % 360) + 360) % 360);
}
export function styleVector(theme: any): number[] {
const cols = CORE_ROLES.map((k) => theme.tokens[k]);
const hues = cols.map(hueDeg).filter((h): h is number => h !== null);
const span = hues.length >= 2 ? Math.max(...hues) - Math.min(...hues) : 0;
const satSum = cols.map(satPermille).reduce((s, v) => s + v, 0);
return [
Math.floor((medianInt(cols.map(luma709)) * 1000) / 255),
Math.floor(satSum / cols.length),
Math.floor((span * 1000) / 360),
theme.strokePermille,
theme.detailPermille,
];
}
export function styleDistance10(a: number[], b: number[], weights: number[]): number {
const num = a.reduce((s, x, i) => s + weights[i] * Math.abs(x - b[i]), 0);
const den = weights.reduce((s, w) => s + w, 0);
return Math.floor((num * 10) / den);
}
/** 同一套 validator:对任意一份 spec 实例产出违例集合,空集即通过。 */
export function validateInstance(theme: any, cfg: any): string[] {
const tid = theme.id;
const viol: string[] = [];
for (const [a, b] of cfg.contrastPairs) {
const c = contrast10(theme.tokens[a], theme.tokens[b]);
if (c < cfg.contrastFloor10) viol.push(`CONTRAST:${tid}:${a}/${b}:${c}`);
}
if (Object.keys(theme.tokens).length !== 8) {
viol.push(`TOKENS:${tid}:${Object.keys(theme.tokens).length}`);
}
return viol;
}
export function buildMatrix(records: { id: string; vector: number[] }[], weights: number[]): number[][] {
return records.map((a) => records.map((b) => (a.id === b.id ? 0 : styleDistance10(a.vector, b.vector, weights))));
}
core/test/sp16_six.test.ts 读 Python 产物做跨语言对照,并构造两条负例:
// file: core/test/sp16_six.test.ts
// tier: P
// verify: node --test 'core/test/sp16_*.test.ts'
// 跨语言对照:读 out/sp16_six.json,断言 TS 侧的风格向量、6×6 矩阵、逐主题校验结论与
// Python 逐位一致,并比对 sha256 指纹。负例:对比度塌陷与两主题太像都必须被判 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { styleVector, styleDistance10, validateInstance, buildMatrix } from "../src/sp16/six.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp16_themes.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp16_six.json"), "utf8"));
const theme = (id: string): any => cfg.themes.find((t: any) => t.id === id);
test("6 主题逐实例校验:TS 与 Python 结论一致且全过", () => {
for (const rec of py.themes as any[]) {
const v = validateInstance(theme(rec.id), cfg);
assert.deepEqual(v, rec.violations, rec.id);
assert.equal(v.length === 0, rec.pass, rec.id);
}
assert.equal(py.passCount, 6);
assert.deepEqual(py.violations, []);
});
test("五维风格向量与 6×6 距离矩阵:整数 ×10 逐位一致", () => {
for (const rec of py.themes as any[]) assert.deepEqual(styleVector(theme(rec.id)), rec.vector, rec.id);
const records = (py.order as string[]).map((id) => ({ id, vector: styleVector(theme(id)) }));
const mine = buildMatrix(records, cfg.weights);
assert.deepEqual(mine, py.matrix);
for (let i = 0; i < mine.length; i += 1) assert.equal(mine[i][i], 0);
});
test("同源可区分:任意两主题距离都高于下限 + sha256 指纹一致", () => {
assert.deepEqual(py.belowFloor, []);
assert.ok(py.minPair.d > cfg.distinguishableFloor10);
const ids = py.order as string[];
const parts = ids.map((id) => `${id}:${styleVector(theme(id)).join(",")}`);
const flat: string[] = [];
for (let i = 0; i < ids.length; i += 1) {
for (let j = i + 1; j < ids.length; j += 1) {
flat.push(`${ids[i]}>${ids[j]}:${styleDistance10(styleVector(theme(ids[i])), styleVector(theme(ids[j])), cfg.weights)}`);
}
}
const digest = createHash("sha256").update([...parts, ...flat].join(";"), "utf8").digest("hex").slice(0, 16);
assert.equal(digest, py.digest);
});
test("负例:对比度塌陷与两主题太像都必须 fail", () => {
const bad = { id: "broken", tokens: { ...theme("pixel").tokens, ink: [26, 28, 44] } };
assert.ok(validateInstance(bad, cfg).some((x) => x.startsWith("CONTRAST:broken:ink/bg:")), "塌陷未报");
const a = styleVector(theme("pixel"));
assert.ok(styleDistance10(a, [a[0], a[1], a[2], a[3] + 1, a[4]], cfg.weights) <= cfg.distinguishableFloor10);
assert.ok(styleDistance10(styleVector(theme("pixel")), styleVector(theme("retro-cyber")), cfg.weights) > cfg.distinguishableFloor10);
});
实跑 node --test 'core/test/sp16_*.test.ts'(此处只摘本节的 4 条;三节合计 14 条):
✔ 6 主题逐实例校验:TS 与 Python 结论一致且全过 (1.28675ms)
✔ 五维风格向量与 6×6 距离矩阵:整数 ×10 逐位一致 (0.309458ms)
✔ 同源可区分:任意两主题距离都高于下限 + sha256 指纹一致 (0.494209ms)
✔ 负例:对比度塌陷与两主题太像都必须 fail (0.096584ms)
ℹ tests 14
ℹ pass 14
ℹ fail 0
16.2.6 边界与取舍
- 真渲染下的六主题观感需要真实渲染器与画师,命令行不可复现,标 X;本节只对色彩与风格参数做整数账。
- 风格向量取四个角色色的整数千分比,风格距离与对比度都是整数类,逐位一致;下限 800 是本机相对量级,绝对数值不可移植,换一批主题必须重跑。
阅读导航:上一节:16.1 Art Bible 成文方法与模板 · 下一节:16.3 交付物与卷二衔接 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。