15.1 规范实例化:6 主题包

同一份规范结构在 6 个主题下各实例化一次:命名段、预算、8 个色彩 token、5 对对比度、图集与两个风格参数拼成完整 spec。同一套五道闸门的 validator 跑 6 遍,六次全过,实跑结果逐行给出,并与 TS 侧做跨语言 digest 对拍。

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.jsonbudget / atlas / contrastPairs / contrastFloor10相同
config/st15_instantiation.json命名规则段(visual_spec.json 不含命名段,故本章补一份)相同
config/sp16_themes.json6 组 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 同源可区分:风格距离矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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