6.1 资产清单的字段设计

资产清单不是给美术看的表格,而是给工具链吃的契约。本节把《星屑旅店》的 13 条资产样本固化成 JSON Schema 形态的字段规范,再用一份真跑的 S 层 Python 校验器和一份 P 层 TS 内核,逐字段拦下缺失字段、非法枚举、非幂次尺寸与不存在的色彩 token,并给出跨语言逐位一致的违例集合与负例断言。

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 / 地块 / 特效),一条清单条目固定八个字段:

字段类型约束为什么必须有
idstring^[a-z][a-z0-9_]{2,31}$稳定主键,引用只认它,不认路径
pathstring^art/[a-z]+/[a-z0-9_]+\.(png|webp)$与 5.2 目录规范对齐,供打包器定位
typeenumatlas / icon / portrait / tile / ui / vfx决定后续预算与装箱口径
tierenumlow / mid / high决定面数与贴图预算档(见 7.1)
size[int,int]正数;type != ui 时两维须为 2 的幂显存账的输入(见 8.1)
tokensstring[]必须是 visual_spec.json 里的 token 名颜色不写死在资产里
refsstring[]目标 id 必须存在依赖图的显式边(见 6.2)
hashstring^[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 引用关系与依赖声明 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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