16.2 双案例回放:《星屑旅店》6 主题的规范装配

同一份 visual_spec.json 结构在 6 个主题下实例化六次,跑同一套校验器必须六次全过。本节用一份真跑的 S 层脚本与 P 层 TS 内核逐个校验六份实例的对比度下限,再给出 6×6 风格距离矩阵(整数 ×10),证明六个主题「同源但可区分」。

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,越大越不像):

pixellowpolyhanddrawnvoxelretro-cybercozy-clean
pixel030521929163913373480
lowpoly305202669216833641002
handdrawn192926690146822421671
voxel163921681468017562038
retro-cyber133733642242175603794
cozy-clean348010021671203837940

最接近的一对是 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 交付物与卷二衔接 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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