13.3 主题切换与变体管理

6 个主题包共用一套结构(8 个 token 名、5 对对比度、同一图集与预算),切换只换取值不换规则:逐变体重跑门禁,6 个主题全部 0 失败对,最紧一对 pixel 的 danger/surface 恰好 45 等于下限;切换成本 8/8,每 token 在 6 变体下都有 6 个互异取值。W 层主题切换器切 retro-cyber ↔ cozy-clean,断言调色板真变。

13.3 主题切换与变体管理

13.1 霓虹调色板与色彩分级 定下 retro-cyber 的 8 个角色色与霓虹下限,13.2 发光与溢出规范 定下光晕参数与图集 padding。本节收口:把主题做成可切换的变体,并证明切换是安全的。

变体管理:换取值不换结构

sp16_themes.json 里有 6 个主题包——pixel / lowpoly / handdrawn / voxel / retro-cyber / cozy-clean。它们共用同一套结构:8 个 token 名(bg / surface / ink / muted / accent / ok / warn / danger)、5 对对比度对、同一套图集与预算。切换主题 = 换一组取值,不换任何结构,门禁规则集(5 对 + 1 个下限)在 6 个变体上完全不变,只是取值全不同、必须逐变体重跑。下面这段是 S 层脚本,读 config/st13_variants.json 与 sp16_themes.json,算变体管理的两笔整数账:逐变体门禁重跑(每主题 5 对对比度 ×10、失败对、最紧一对)与切换成本(两主题间取值不同的 token 数、每 token 的互异取值数)。

#!/usr/bin/env python3
# file: sim/st13_variants.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st13_variants.py
"""13.3 主题切换与变体管理(S 层,复古 / 赛博)。

读 config/st13_variants.json 与 sp16_themes.json,算两笔整数账:① 逐变体门禁重跑——6 个主题包共用
同一套结构(8 token 名 / 5 对对比度 / 同图集与预算),切主题 = 换一组取值,门禁必须逐对重跑(WCAG ×10,
floor10 = 45);② 切换成本——两主题间取值不同的 token 数,tokenDistinct 给出每 token 在 6 变体下的互异
取值数。换取值不换规则,规则集在 6 变体上不变。全部整数地板除,落 out/st13_variants.json。
"""
import hashlib
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)


def contrast10(a, b):
    """WCAG 对比度 ×10 取整 → 整数类,逐位一致(与卷一 ch4 同源)。"""
    def lin(c):
        s = c / 255
        return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4

    def lum(rgb):
        return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2])

    la, lb = lum(a), lum(b)
    return round((max(la, lb) + 0.05) / (min(la, lb) + 0.05) * 10)


def audit(tokens, cfg):
    """跑 5 对对比度 → (rows, fails, minContrast10, tightestPair)。"""
    rows = []
    for a, b in cfg["contrastPairs"]:
        c = contrast10(tokens[a], tokens[b])
        rows.append({"pair": f"{a}/{b}", "contrast10": c, "ok": c >= cfg["contrastFloor10"]})
    fails = [r["pair"] for r in rows if not r["ok"]]
    tight = min(rows, key=lambda r: r["contrast10"])
    return rows, fails, tight["contrast10"], tight["pair"]


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/st13_variants.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    by_id = {t["id"]: t for t in themes["themes"]}
    order = cfg["order"]
    per_theme = []
    for tid in cfg["themes"]:
        rows, fails, mn, tight = audit(by_id[tid]["tokens"], cfg)
        per_theme.append({"theme": tid, "rows": rows, "fails": fails, "failCount": len(fails),
                          "minContrast10": mn, "tightestPair": tight})
    token_distinct = [len({tuple(by_id[t]["tokens"][n]) for t in cfg["themes"]}) for n in order]
    fa, fb = by_id[cfg["switchFrom"]]["tokens"], by_id[cfg["switchTo"]]["tokens"]
    changed = [n for n in order if fa[n] != fb[n]]
    rule_signature = [f"{a}/{b}" for a, b in cfg["contrastPairs"]]
    canon = json.dumps({
        "failingByTheme": [[p["theme"], p["failCount"]] for p in per_theme],
        "failPairsByTheme": [[p["theme"], p["fails"]] for p in per_theme],
        "minByTheme": [[p["theme"], p["minContrast10"]] for p in per_theme],
        "tokenDistinct": token_distinct, "changed": changed, "changedCount": len(changed),
        "ruleSignature": rule_signature}, separators=(",", ":"))
    report = {"project": cfg["project"], "themes": cfg["themes"],
              "contrastFloor10": cfg["contrastFloor10"], "contrastPairs": cfg["contrastPairs"],
              "perTheme": per_theme, "switchFrom": cfg["switchFrom"], "switchTo": cfg["switchTo"],
              "tokenDistinct": [{"token": order[i], "distinct": token_distinct[i]} for i in range(len(order))],
              "changed": changed, "changedCount": len(changed), "ruleSignature": rule_signature,
              "canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
    out = os.path.join(ROOT, "out"); os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "st13_variants.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for p in per_theme:
        print(f"{p['theme']:<11} fails={p['failCount']} min={p['minContrast10']}@{p['tightestPair']}")
    print(f"tokenDistinct={token_distinct}")
    print(f"switch {cfg['switchFrom']} -> {cfg['switchTo']} changed={len(changed)} {changed}")
    print(f"digest={report['digest']}"); print("wrote out/st13_variants.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/st13_variants.py:

pixel       fails=0 min=45@danger/surface
lowpoly     fails=0 min=51@danger/surface
handdrawn   fails=0 min=46@accent/bg
voxel       fails=0 min=48@muted/bg
retro-cyber fails=0 min=55@danger/surface
cozy-clean  fails=0 min=46@accent/bg
tokenDistinct=[6, 6, 6, 6, 6, 6, 6, 6]
switch retro-cyber -> cozy-clean changed=8 ['bg', 'surface', 'ink', 'muted', 'accent', 'ok', 'warn', 'danger']
digest=d212cb54224ef9a2
wrote out/st13_variants.json

读出来:6 个主题全部 0 失败对。最紧的一对各不相同——retro-cyber 是 danger/surface 的 55(下限 45,余量 10),pixel 也卡在 danger/surface 但只有 45,恰好等于下限、零余量。tokenDistinct 全为 6,说明每个 token 名在 6 个变体下都取了 6 个互不相同的值——没有任何两个主题包在某个 token 上撞值。切换成本 8/8:retro-cyber → cozy-clean 的 8 个 token 取值全变。

逐变体门禁重跑

变体管理最容易被忽略的一条:门禁不是跑一次就完事,每个变体都要重跑。因为 pixel 的 danger/surface 恰好压在 45 上,任何一次取值微调都可能把它推下去。下面这段是 P 层 TypeScript 内核 core/src/st13/theme.ts,与 S 脚本逐条对应:切换差异、每 token 互异取值数、逐变体门禁重跑,对比度复用 sp00/spec.ts 的同源 contrast10。

// file: core/src/st13/theme.ts
// tier: P
// verify: node --test 'core/test/st13_*.test.ts'
// 13.3 主题切换与变体管理的 TS 内核:切换差异、每 token 的互异取值数、逐变体门禁重跑。
// 对比度复用 sp00/spec.ts 的 contrast10(同源常数)。只允许可擦除 TS 语法。

import { contrast10 } from "../sp00/spec.ts";

export type RGB = number[];
export type TokenMap = Record<string, RGB>;

/** 切换成本:两个主题之间取值不同的 token 名(按 order 顺序)。 */
export function diffTokens(a: TokenMap, b: TokenMap, order: string[]): string[] {
  return order.filter((n) => a[n].join(",") !== b[n].join(","));
}

/** 每个 token 名在各变体下的互异取值数。 */
export function tokenDistinct(tokensByTheme: TokenMap[], order: string[]): number[] {
  return order.map((n) => new Set(tokensByTheme.map((t) => t[n].join(","))).size);
}

/** 逐变体门禁:每对对比度 ×10 与是否达标。 */
export function themeAudit(
  tokens: TokenMap,
  contrastPairs: string[][],
  floor10: number,
): { rows: { pair: string; contrast10: number; ok: boolean }[]; fails: string[] } {
  const rows = contrastPairs.map(([a, b]) => {
    const c = contrast10(tokens[a], tokens[b]);
    return { pair: `${a}/${b}`, contrast10: c, ok: c >= floor10 };
  });
  return { rows, fails: rows.filter((r) => !r.ok).map((r) => r.pair) };
}

/** 最紧的一对(对比度最小),并列时取先出现者。 */
export function tightest(rows: { pair: string; contrast10: number }[]): { pair: string; contrast10: number } {
  let best = rows[0];
  for (const r of rows) if (r.contrast10 < best.contrast10) best = r;
  return best;
}

跑 node --test 'core/test/st13_*.test.ts'(本节 st13_theme.test.ts 5 条):

✔ 逐变体门禁重跑:6 个主题的失败对数与最紧一对,与 Python 逐位一致 (0.864417ms)
✔ 每 token 互异取值数:与 Python 逐位一致 (0.127792ms)
✔ 切换成本:两主题之间取值不同的 token,与 Python 逐位一致 (0.099125ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.415208ms)
✔ 负例:门禁规则集在所有变体上不变(换取值不换规则) (0.095333ms)
ℹ pass 5
ℹ fail 0

W 层:单文件主题切换器

把变体管理做成能真点的看板:单文件 HTML 内联擦除类型后的 TS 内核,页面渲染 8 格色卡 + 5 行对比度表,一个按钮在 retro-cyber 与 cozy-clean 之间切换。下面两段是 W 层:build_st13_theme.mjs 产出单文件 HTML,verify_st13_theme.mjs 用无头 Chromium 断言切换真的发生。

// file: web/build_st13_theme.mjs
// tier: W
// verify: node web/build_st13_theme.mjs
// 卷三 13.3 W 层:把 core/src/sp00/spec.ts + st13/neon.ts + st13/theme.ts 擦除类型后内联进
// 单文件 HTML。页面是主题切换器:retro-cyber ↔ cozy-clean,换取值不换结构,切完门禁逐对重跑。
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 spec = read("config/visual_spec.json"), cfg = read("config/st13_variants.json");
const glow = read("config/st13_glow.json"), themes = read("config/sp16_themes.json");
const strip = (p) => stripTypeScriptTypes(readFileSync(resolve(ROOT, p), "utf8"), { mode: "strip" })
  .replace(/^import[^;]*;\s*$/gm, "").replace(/^export\s+/gm, "");
const js = ["core/src/sp00/spec.ts", "core/src/st13/neon.ts", "core/src/st13/theme.ts"].map(strip).join("\n");
const TOKENS = JSON.stringify(Object.fromEntries(themes.themes.map((t) => [t.id, t.tokens])));
const CSS = `body{margin:0;padding:20px;background:#0c0e18;color:#e6e8ee;font:14px/1.6 system-ui,"PingFang SC",sans-serif}
h1{font-size:17px;margin:0 0 2px}.sub{color:#8a93ad;font-size:12px;margin-bottom:14px}
#pal{display:flex;gap:8px;flex-wrap:wrap}.sw{width:90px;border-radius:6px;overflow:hidden;border:1px solid #262a3c}.sw .chip{height:50px}
.sw .meta{padding:5px 7px;font:10px/1.5 ui-monospace,monospace;background:#161a28;white-space:pre-line}
table{border-collapse:collapse;font:12px/1.5 ui-monospace,monospace}th,td{text-align:left;padding:4px 10px;border-bottom:1px solid #262a3c}td.n{text-align:right}.ok{color:#3ce8a4}
.bad{color:#ff5c8a}canvas{display:block;border:1px solid #262a3c;border-radius:6px;background:#0c0e18;margin-top:10px}button{background:#1c2236;color:#e6e8ee;border:1px solid #333a52;border-radius:6px;padding:7px 14px;cursor:pointer;font:12px system-ui}
.row{display:flex;gap:26px;flex-wrap:wrap;align-items:flex-start}#cost{color:#8a93ad;font-size:12px;margin-top:8px}`;
const BODY = `<h1>prism 主题切换器</h1>
<div class="sub">project ${spec.project} · 切 retro-cyber ↔ cozy-clean · 换取值不换结构,门禁逐对重跑</div>
<h2>调色板 token</h2><div id="pal"></div>
<h2>对比度(×10 整数,floor ${cfg.contrastFloor10})</h2>
<div class="row"><table id="ct"><thead><tr><th>组合</th><th class="n">cr10</th><th>判定</th></tr></thead><tbody></tbody></table>
<div><button id="theme">切换主题:cozy-clean</button><div id="cost"></div><canvas id="sign" width="420" height="130"></canvas></div></div>`;
const SCRIPT = `<script type="module">
${js}
const CFG = ${JSON.stringify(cfg)}, GLOW = ${JSON.stringify(glow)}, THEMES = ${TOKENS}, ORDER = CFG.order;
let cur = CFG.switchFrom;
function render(tid) {
  const tokens = THEMES[tid], pal = document.getElementById("pal"), swatches = []; pal.innerHTML = "";
  for (const name of ORDER) {
    const rgb = tokens[name], d = document.createElement("div"), chip = document.createElement("div"), meta = document.createElement("div");
    d.className = "sw"; chip.className = "chip"; chip.style.background = "rgb(" + rgb.join(",") + ")";
    meta.className = "meta"; meta.textContent = name + "\\n#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
    d.append(chip, meta); pal.append(d); swatches.push(rgb.join(","));
  }
  const aud = themeAudit(tokens, CFG.contrastPairs, CFG.contrastFloor10), tb = document.querySelector("#ct tbody"); tb.innerHTML = "";
  for (const r of aud.rows) {
    const tr = document.createElement("tr");
    tr.innerHTML = "<td>" + r.pair + "</td><td class=\\"n\\">" + r.contrast10 + "</td><td class=\\"" + (r.ok ? "ok" : "bad") + "\\">" + (r.ok ? "pass" : "fail") + "</td>";
    tb.append(tr);
  }
  const cv = document.getElementById("sign"), ctx = cv.getContext("2d"), accent = tokens.accent;
  ctx.fillStyle = "rgb(" + tokens.bg.join(",") + ")"; ctx.fillRect(0, 0, cv.width, cv.height);
  for (const l of GLOW.layers) {
    ctx.fillStyle = "rgba(" + accent.join(",") + "," + Math.min(1, l.intensityPermille / 1000) + ")";
    ctx.fillRect(150 - l.radius, 56 - l.radius, 120 + 2 * l.radius, 18 + 2 * l.radius);
  }
  ctx.fillStyle = "rgb(" + accent.join(",") + ")"; ctx.fillRect(150, 56, 120, 18);
  const changed = diffTokens(THEMES[CFG.switchFrom], THEMES[CFG.switchTo], ORDER);
  const other = tid === CFG.switchFrom ? CFG.switchTo : CFG.switchFrom;
  document.getElementById("cost").textContent = "切换成本:" + diffTokens(tokens, THEMES[other], ORDER).length + " / " + ORDER.length + " 个 token 取值不同";
  globalThis.__prism = { theme: tid, swatches, rows: aud.rows, fails: aud.fails, order: ORDER, changed, changedCount: changed.length, glowLayers: GLOW.layers.length };
}
document.getElementById("theme").addEventListener("click", () => {
  cur = cur === CFG.switchFrom ? CFG.switchTo : CFG.switchFrom; document.getElementById("theme").textContent = "切换主题:" + (cur === CFG.switchFrom ? CFG.switchTo : CFG.switchFrom); render(cur);
});
render(cur); document.title = "prism st13 theme ready";
</script>`;
const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>prism 主题切换器 · ${spec.project}</title><style>${CSS}</style></head><body>${BODY}${SCRIPT}</body></html>`;
const out = resolve(ROOT, "web/dist/st13_theme.html");
mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, html, "utf8");
console.log(`built web/dist/st13_theme.html (${Buffer.byteLength(html)} bytes)`);
// file: web/verify_st13_theme.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_st13_theme.mjs
// 卷三 13.3 W 层断言:无头 Chromium 打开主题切换器,采样 globalThis.__prism 与 Python 落盘的
// out/st13_variants.json 对照;再点按钮切主题,断言调色板真的变、对比度真的重算。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const read = (p) => JSON.parse(readFileSync(resolve(ROOT, p), "utf8"));
const py = read("out/st13_variants.json"), cfg = read("config/st13_variants.json");
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/st13_theme.html")).href;
const shots = resolve(ROOT, "out/shots"); mkdirSync(shots, { recursive: true });
let pass = 0, fail = 0;
const check = (n, ok, x = "") => { ok ? (pass += 1) : (fail += 1); console.log(`${ok ? "pass" : "fail"} ${n}${x ? " " + x : ""}`); };
const pyFor = (t) => py.perTheme.find((p) => p.theme === t);
const rowsOf = (rows) => JSON.stringify(rows.map((r) => [r.pair, r.contrast10]));
const browser = await chromium.launch({ headless: true });
const warm = await browser.newPage(); await warm.goto(pageUrl); await warm.close();  // 冷启动预热
const page = await browser.newPage({ viewport: { width: 1000, height: 720 } });
const errors = []; page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(pageUrl);
await page.waitForFunction(() => globalThis.__prism !== undefined, null, { timeout: 15000 });
const snap = () => page.evaluate(() => { const p = globalThis.__prism; return { theme: p.theme, swatches: p.swatches, rows: p.rows, changed: p.changed, changedCount: p.changedCount }; });
const s = await snap();
check("无 pageerror", errors.length === 0, errors.join("|"));
check("初始主题为 retro-cyber", s.theme === cfg.switchFrom, s.theme);
check("调色板 8 格", s.swatches.length === 8, `${s.swatches.length}`);
check("对比度逐行与 Python 逐位一致", rowsOf(s.rows) === rowsOf(pyFor(cfg.switchFrom).rows), rowsOf(s.rows));
check("切换成本与 Python 逐位一致", s.changedCount === py.changedCount && JSON.stringify(s.changed) === JSON.stringify(py.changed), `${s.changedCount}`);
const px = await page.evaluate(() => {
  const cv = document.getElementById("sign"), ctx = cv.getContext("2d"), d = ctx.getImageData(0, 0, cv.width, cv.height).data;
  const c = ctx.getImageData(210, 65, 1, 1).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n += 1;
  return { center: [c[0], c[1], c[2]], drawn: n, accent: globalThis.__prism.swatches[4].split(",").map(Number) };
});
check("招牌中心 = accent 色", JSON.stringify(px.center) === JSON.stringify(px.accent), JSON.stringify(px.center));
check("canvas 已绘制", px.drawn >= 1, `${px.drawn} px`);
const shot0 = resolve(shots, "st13_retro.png"); await page.screenshot({ path: shot0 });
await page.click("#theme");
await page.waitForFunction((t) => globalThis.__prism.theme === t, cfg.switchTo, { timeout: 5000 });
const s2 = await snap();
check("切到 cozy-clean", s2.theme === cfg.switchTo, s2.theme);
check("切换后色卡真的变", s2.swatches.some((v, i) => v !== s.swatches[i]), s2.swatches.join("|"));
check("切换后对比度重算且与 Python 一致", rowsOf(s2.rows) === rowsOf(pyFor(cfg.switchTo).rows), rowsOf(s2.rows));
const shot1 = resolve(shots, "st13_cozy.png"); await page.screenshot({ path: shot1 });
await browser.close();
check("截图非空", statSync(shot0).size > 0 && statSync(shot1).size > 0);
console.log(`ℹ pass ${pass}`); console.log(`ℹ fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);

实跑 node web/build_st13_theme.mjs 输出 built web/dist/st13_theme.html (13872 bytes);再跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_st13_theme.mjs:

pass 无 pageerror
pass 初始主题为 retro-cyber retro-cyber
pass 调色板 8 格 8
pass 对比度逐行与 Python 逐位一致 [["ink/bg",159],["muted/bg",75],["ink/surface",142],["accent/bg",58],["danger/surface",55]]
pass 切换成本与 Python 逐位一致 8
pass 招牌中心 = accent 色 [255,60,140]
pass canvas 已绘制 54600 px
pass 切到 cozy-clean cozy-clean
pass 切换后色卡真的变 245,240,230|228,220,206|92,80,66|110,98,84|168,84,52|64,116,74|150,84,32|170,52,48
pass 切换后对比度重算且与 Python 一致 [["ink/bg",69],["muted/bg",52],["ink/surface",58],["accent/bg",46],["danger/surface",48]]
pass 截图非空
ℹ pass 11
ℹ fail 0

11 条断言全绿。关键两条:「切换后色卡真的变」——切到 cozy-clean 后 8 格色卡逐格对比 retro-cyber,至少一格不同(实际 8 格全变);「切换后对比度重算」——新主题的 5 行对比度与 Python 落盘的 cozy-clean 逐行一致(retro 行 [159,75,142,58,55] → cozy 行 [69,52,58,46,48]),证明切换触发的是真重算,不是换了个贴图。canvas 已绘制 54600 px 用的是「≥ 1px」判据。变体管理到此收口:结构固定、取值可换、门禁逐变体重跑。

阅读导航:上一节:13.2 发光与溢出规范 · 下一节:14.1 品类与美术预算的关系 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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