6.1.1 清单是契约,不是表格
上一章把命名与目录约束讲成了可 lint 的规则;这一章往前走一步,讲资产清单(manifest):每条资产带哪些字段、字段之间怎么互相引用(6.2)、清单和磁盘目录怎么互相对账(6.3)。先划界:《游戏美术风格指南 2026》 讲的是风格方向与 Art Bible 的口号式提纲,本套只做美术侧清单 schema 与可跑的校验;《渲染引擎》的资产管线入口 是引擎实现侧的门户,导入器怎么读清单、怎么烘焙、怎么进运行时属于引擎实现,本文一律外链,不重推。
一份清单之所以不能是一张 Excel,是因为它有三个消费者,而这三个消费者对字段的容忍度完全不同:
- 打包器只认
path与hash:它要知道打哪些文件、哪些变了。 - 校验器只认
id/type/size/tokens:它要判预算与引用完整性。 - 人只认
owner与tier:他要认领任务、判断改动影响面。
字段设计的目标就是:让这三方读同一份数据,谁也不用回头问「这个数字从哪来」。
6.1.2 八个别填字段
以《星屑旅店》为例(等距经营 × 装扮,资产类型覆盖家具图标 / 角色头像 / UI / 地块 / 特效),一条清单条目固定八个字段:
| 字段 | 类型 | 约束 | 为什么必须有 |
|---|---|---|---|
id | string | ^[a-z][a-z0-9_]{2,31}$ | 稳定主键,引用只认它,不认路径 |
path | string | ^art/[a-z]+/[a-z0-9_]+\.(png|webp)$ | 与 5.2 目录规范对齐,供打包器定位 |
type | enum | atlas / icon / portrait / tile / ui / vfx | 决定后续预算与装箱口径 |
tier | enum | low / mid / high | 决定面数与贴图预算档(见 7.1) |
size | [int,int] | 正数;type != ui 时两维须为 2 的幂 | 显存账的输入(见 8.1) |
tokens | string[] | 必须是 visual_spec.json 里的 token 名 | 颜色不写死在资产里 |
refs | string[] | 目标 id 必须存在 | 依赖图的显式边(见 6.2) |
hash | string | ^[0-9a-f]{8}$ | 变更检测与增量打包 |
两条容易写错的纪律:
id是唯一主键,引用只认 id,不认路径——路径会因为 5.2 的目录调整而变,id 不变。tokens是「颜色不写死在资产里」的抓手:它引用 token 名而不是 RGB 值,所以改一次visual_spec.json能同时改到三处消费。
6.1.3 用 JSON Schema 固化字段约束
下面这段是 D 层文件,把上表固化成 draft-07 的 JSON Schema。它不是「文档」,是校验器的机器可读输入,落盘在 config/sp06_manifest.schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "stardust-inn 资产清单条目",
"type": "object",
"required": ["id", "path", "type", "tier", "size", "tokens", "hash", "owner"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_]{2,31}$" },
"path": { "type": "string", "pattern": "^art/[a-z]+/[a-z0-9_]+\\.(png|webp)$" },
"type": { "type": "string", "enum": ["atlas", "icon", "portrait", "tile", "ui", "vfx"] },
"tier": { "type": "string", "enum": ["low", "mid", "high"] },
"size": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 },
"tokens": { "type": "array", "items": { "type": "string" } },
"refs": { "type": "array", "items": { "type": "string" } },
"hash": { "type": "string", "pattern": "^[0-9a-f]{8}$" },
"owner": { "type": "string", "enum": ["art", "ta", "out"] }
}
}
6.1.4 S 层:真跑一份样本清单
下面这段是 S 层脚本,读 config/visual_spec.json(色彩真源)与 config/sp06_manifest.json(13 条资产样本),逐字段跑规则,把违例按「<id>:<规则码>」排序后落 out/sp06_schema.json。注意 tokens 的合法性是拿真源比出来的,不是硬编码一份白名单:
#!/usr/bin/env python3
# file: sim/sp06_schema.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp06_schema.py
"""6.1 清单字段 schema 的 S 层校验器:读清单与色彩真源,逐条资产跑字段规则。
违例是「<id>:<规则码>」字符串,排序后落 out/sp06_schema.json,供 P 层跨语言对照。
整数类(尺寸、幂次判定)逐位一致;本脚本不涉及色彩派生量。"""
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ID_RE = re.compile(r"^[a-z][a-z0-9_]{2,31}$")
PATH_RE = re.compile(r"^art/[a-z]+/[a-z0-9_]+\.(png|webp)$")
HASH_RE = re.compile(r"^[0-9a-f]{8}$")
REQUIRED = ["id", "path", "type", "tier", "size", "tokens", "hash", "owner"]
def is_pow2(n) -> bool:
return isinstance(n, int) and n > 0 and (n & (n - 1)) == 0
def check_schema(spec: dict, manifest: dict) -> list:
types = set(manifest["assetTypes"])
tiers = set(manifest["tiers"])
owners = set(manifest["owners"])
tokens = set(spec["tokens"])
pow2 = bool(manifest.get("powerOfTwoSizes", False))
seen: set = set()
out: list = []
for i, a in enumerate(manifest["assets"]):
aid = a.get("id") or f"__row{i}__"
out += [f"{aid}:MISSING:{f}" for f in REQUIRED if f not in a]
if "id" in a and not ID_RE.match(str(a["id"])):
out.append(f"{aid}:BAD_ID")
if "path" in a and not PATH_RE.match(str(a["path"])):
out.append(f"{aid}:BAD_PATH")
if "type" in a and a["type"] not in types:
out.append(f"{aid}:BAD_TYPE")
if "tier" in a and a["tier"] not in tiers:
out.append(f"{aid}:BAD_TIER")
if "owner" in a and a["owner"] not in owners:
out.append(f"{aid}:BAD_OWNER")
if "hash" in a and not HASH_RE.match(str(a["hash"])):
out.append(f"{aid}:BAD_HASH")
if "size" in a:
sz = a["size"]
ok = isinstance(sz, list) and len(sz) == 2 and all(isinstance(v, int) and v > 0 for v in sz)
if not ok or (pow2 and a.get("type") != "ui" and not (is_pow2(sz[0]) and is_pow2(sz[1]))):
out.append(f"{aid}:BAD_SIZE")
out += [f"{aid}:UNKNOWN_TOKEN:{t}" for t in a.get("tokens", []) if t not in tokens]
if aid in seen:
out.append(f"{aid}:DUPLICATE_ID")
seen.add(aid)
return sorted(out)
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
mf = json.load(open(os.path.join(ROOT, "config/sp06_manifest.json"), encoding="utf-8"))
v = check_schema(spec, mf)
report = {"project": mf["project"], "assetCount": len(mf["assets"]),
"requiredFields": REQUIRED, "violations": v, "count": len(v)}
with open(os.path.join(ROOT, "out/sp06_schema.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"assets={report['assetCount']} violations={report['count']}")
for x in v:
print(f" {x}")
print("wrote out/sp06_schema.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp06_schema.py:
assets=13 violations=4
furn_lamp_01:BAD_HASH
portrait_guest_01:UNKNOWN_TOKEN:surface2
tile_floor_01:BAD_SIZE
ui_button:MISSING:owner
wrote out/sp06_schema.json
样本里故意留了 4 条真违例,证明门禁真的会拦下东西:ui_button 漏了 owner、tile_floor_01 的 48×48 不是 2 的幂、portrait_guest_01 引用了不存在的 token surface2、furn_lamp_01 的 hash 写成了 0xLAMP。
6.1.5 P 层:同一套规则的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp06/schema.ts,与上面的 Python 逐条对应。两侧都不写「大概」的规则——正则、枚举、幂次判定完全一致:
// file: core/src/sp06/schema.ts
// tier: P
// verify: node --test 'core/test/sp06_*.test.ts'
// 6.1 清单字段 schema 的 P 层校验器。与 sim/sp06_schema.py 同一套规则:
// 逐条资产检查必填字段、id/path/hash 形态、枚举值、尺寸与 2 的幂、token 引用。
// 违例是「<资产 id>:<规则码>」字符串,排序后与 Python 落盘的 out/sp06_schema.json
// 逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export const ID_RE = /^[a-z][a-z0-9_]{2,31}$/;
export const PATH_RE = /^art\/[a-z]+\/[a-z0-9_]+\.(png|webp)$/;
export const HASH_RE = /^[0-9a-f]{8}$/;
export const REQUIRED = ["id", "path", "type", "tier", "size", "tokens", "hash", "owner"];
export function isPow2(n: number): boolean {
return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;
}
export function checkSchema(spec: any, manifest: any): string[] {
const types = new Set(manifest.assetTypes);
const tiers = new Set(manifest.tiers);
const owners = new Set(manifest.owners);
const tokens = new Set(Object.keys(spec.tokens));
const pow2 = Boolean(manifest.powerOfTwoSizes);
const seen = new Set<string>();
const out: string[] = [];
manifest.assets.forEach((a: any, i: number) => {
const aid = a.id || `__row${i}__`;
for (const f of REQUIRED) {
if (!(f in a)) out.push(`${aid}:MISSING:${f}`);
}
if ("id" in a && !ID_RE.test(String(a.id))) out.push(`${aid}:BAD_ID`);
if ("path" in a && !PATH_RE.test(String(a.path))) out.push(`${aid}:BAD_PATH`);
if ("type" in a && !types.has(a.type)) out.push(`${aid}:BAD_TYPE`);
if ("tier" in a && !tiers.has(a.tier)) out.push(`${aid}:BAD_TIER`);
if ("owner" in a && !owners.has(a.owner)) out.push(`${aid}:BAD_OWNER`);
if ("hash" in a && !HASH_RE.test(String(a.hash))) out.push(`${aid}:BAD_HASH`);
if ("size" in a) {
const sz = a.size;
const ok = Array.isArray(sz) && sz.length === 2 && sz.every((v: any) => Number.isInteger(v) && v > 0);
if (!ok) out.push(`${aid}:BAD_SIZE`);
else if (pow2 && a.type !== "ui" && !(isPow2(sz[0]) && isPow2(sz[1]))) out.push(`${aid}:BAD_SIZE`);
}
for (const t of a.tokens || []) {
if (!tokens.has(t)) out.push(`${aid}:UNKNOWN_TOKEN:${t}`);
}
if (seen.has(aid)) out.push(`${aid}:DUPLICATE_ID`);
seen.add(aid);
});
out.sort();
return out;
}
6.1.6 跨语言对照与负例
P 层测试读的不是同一份源码,而是 Python 落盘的 out/sp06_schema.json——这正是「三处消费」不被退化成「三处各写一遍」的机制。core/test/sp06_schema.test.ts:
// file: core/test/sp06_schema.test.ts
// tier: P
// verify: node --test 'core/test/sp06_*.test.ts'
// 跨语言对照:读 sim/sp06_schema.py 落盘的 out/sp06_schema.json,断言 TS 侧复算的违例
// 集合与之逐位一致(字符串数组)。负例:坏字段必须被拦下、干净资产必须放行。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkSchema, isPow2 } from "../src/sp06/schema.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const manifest = JSON.parse(readFileSync(resolve(ROOT, "config/sp06_manifest.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp06_schema.json"), "utf8"));
const BASE = { assetTypes: ["icon"], tiers: ["low"], owners: ["art"], powerOfTwoSizes: true };
const GOOD = { id: "ok_asset", path: "art/furniture/x.png", type: "icon", tier: "low",
size: [64, 64], tokens: ["bg"], hash: "aabbccdd", owner: "art" };
test("清单字段违例:TS 复算与 Python 产物逐位一致", () => {
const mine = checkSchema(spec, manifest);
assert.deepEqual(mine, py.violations);
assert.equal(mine.length, py.count);
});
test("幂次判定:整数、边界正确", () => {
assert.equal(isPow2(64), true);
assert.equal(isPow2(1024), true);
assert.equal(isPow2(48), false);
assert.equal(isPow2(0), false);
assert.equal(isPow2(-4), false);
});
const NO_OWNER = { id: "no_owner", path: "art/furniture/y.png", type: "icon", tier: "low",
size: [64, 64], tokens: ["bg"], hash: "aabbccdd" };
test("负例:坏字段必须真的被拦下,干净资产必须放行", () => {
const bad = { ...BASE, assets: [GOOD, NO_OWNER,
{ ...GOOD, id: "bad_tok", tokens: ["nope"] },
{ ...GOOD, id: "bad_size", size: [48, 64] },
{ ...GOOD, id: "bad_hash", hash: "0xLAMP" }] };
const v = checkSchema(spec, bad);
assert.ok(v.includes("no_owner:MISSING:owner"), v.join(","));
assert.ok(v.includes("bad_tok:UNKNOWN_TOKEN:nope"), v.join(","));
assert.ok(v.includes("bad_size:BAD_SIZE"), v.join(","));
assert.ok(v.includes("bad_hash:BAD_HASH"), v.join(","));
assert.equal(v.some((x) => x.startsWith("ok_asset:")), false, "干净资产不应报错");
});
test("负例:重复 id 被检出", () => {
const dup = { ...BASE, powerOfTwoSizes: false, assets: [GOOD, { ...GOOD }] };
assert.ok(checkSchema(spec, dup).includes("ok_asset:DUPLICATE_ID"));
});
实跑 node --test 'core/test/sp06_*.test.ts'(三节共 13 条,此处按测试文件归并展示):
✔ 清单字段违例:TS 复算与 Python 产物逐位一致
✔ 幂次判定:整数、边界正确
✔ 负例:坏字段必须真的被拦下,干净资产必须放行
✔ 负例:重复 id 被检出
ℹ tests 13
ℹ pass 13
ℹ fail 0
违例集合是字符串数组,逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。负例是本节的一半:ok_asset 必须放行,no_owner / bad_tok / bad_size / bad_hash 必须各报一条。
6.1.7 边界与取舍
- 真 DCC / 真实资产库的导入校验需要真实环境(DCC / 渲染器 / 画师),命令行不可复现,标 X;本节只做导出清单的 schema 静态校验。
- 字段能加不能滥加:每加一个字段,就要有一个消费者真的读它,否则它会退化成没人维护的注释。
size强制 2 的幂对ui放行——UI 尺寸由 9.1 的栅格与安全区决定,不是贴图。
阅读导航:上一节:5.3 保留字与命名反模式 · 下一节:6.2 引用关系与依赖声明 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。