5.2 目录层级与引擎内引用约束

名字判对了,还得放在对的目录里:讲清 kind 到目录的唯一映射、为什么目录必须纯小写(大小写混用是 URL 死链的头号来源),以及 @color/<token> 与 @asset/<id> 两条引用约束怎么和共享真源 visual_spec.json 对齐——目录错位、悬空引用、自引用都会被 lint 拦下。

5.2 目录层级与引擎内引用约束

5.1 管的是名字,这一节管的是名字待的地方。一个资产在规范里其实有两套身份:文件系统里的路径,和引擎里的引用。两者一旦不一致,就会长出「本地能跑、别人拉下来报资源丢失」这类最难查的 bug。

这一节的判据同样落成可跑的 lint,而不是文档条款。它跟 5.1 共用同一份资产清单 config/sp05_assets.json,只是换一组规则:目录分类、路径大小写、引用完整性。引擎侧真正怎么解析这些引用(导入器、序列化格式、运行时加载)属于加载管线的实现,本套不重推——那部分见 graphics-gltf-asset-pipeline ,本节只管规范侧的引用约束:写下的每个引用,目标必须真实存在。

5.2.1 目录是「类型的物理投影」

《星屑旅店》的资产分五类:家具图标、角色头像、UI、地块、特效。最省事的做法是把它们全丢进 assets/sprites/ 一个目录,靠文件名前缀区分——但那样图集打包、批量改导入设置、按类型分派给不同美术时,都要靠前缀过滤,容易漏。

所以我们让目录成为 kind 的物理投影:一个 kind 只对应一个目录。规则是死的:

kind目录归属
furnitureassets/sprites/furniture场景美术
characterassets/sprites/character角色美术
uiassets/sprites/uiUI 美术
tileassets/sprites/tile场景美术
vfxassets/sprites/vfx特效美术
iconassets/sprites/iconUI 美术

这张表本身就是一份 D 层 schema,落成 JSON 后既给 lint 当判据,也给导入器当路由表:

{
  "ruleSet": "dir-schema",
  "root": "assets/sprites",
  "dirs": [
    { "kind": "furniture", "dir": "assets/sprites/furniture", "enginePath": "sprites/furniture", "owner": "场景美术" },
    { "kind": "character", "dir": "assets/sprites/character", "enginePath": "sprites/character", "owner": "角色美术" },
    { "kind": "ui", "dir": "assets/sprites/ui", "enginePath": "sprites/ui", "owner": "UI 美术" },
    { "kind": "tile", "dir": "assets/sprites/tile", "enginePath": "sprites/tile", "owner": "场景美术" },
    { "kind": "vfx", "dir": "assets/sprites/vfx", "enginePath": "sprites/vfx", "owner": "特效美术" },
    { "kind": "icon", "dir": "assets/sprites/icon", "enginePath": "sprites/icon", "owner": "UI 美术" }
  ],
  "rules": [
    "一个 kind 只允许一个目录;目录名必须是纯小写,禁止大写与空格。",
    "目录深度固定为 assets/sprites/<kind>/,不得再套一层主题目录(主题走清单字段,不进路径)。",
    "引擎内引用一律用 @asset/<id> 与 @color/<token>,禁止裸文件路径引用。",
    "@color/<token> 的合法集合来自 config/visual_spec.json,引用完整性由 lint 兜底。"
  ]
}

注意最后两条 rules:主题不进路径是刻意的。同一套规范要在 6 个主题包(pixel / lowpoly / handdrawn / voxel / retro-cyber / cozy-clean)下实例化,如果主题进了目录,就要复制 6 份目录树、6 份引用;把主题留在清单的 theme 字段里,目录只有一份,切换主题只换参数。这是本套「一份规范,三处消费」在目录设计上的直接后果。

5.2.2 大小写:一条能在 CI 上炸你的规则

tile_water-lily_v01.png 和 tile_Water-Lily_v01.png 在 macOS 上是同一个文件,在 Linux 上是两个。开发机是 macOS、CI 是 Linux 时,这个差异会稳定地制造两类事故:本地通过、CI 报「资源重复」;或者本地引用 Water-Lily、提交后 CI 小写化路径,运行时找不到。

所以目录规则里有一条最硬:路径必须全小写。判据就是一条正则——路径里出现 [A-Z] 即 E_PATHCASE。这条规则同时防住了 Hugo 侧的死链:站点会把 URL 路径里的 Latin 字符小写化,写死大写路径的链接在产物里必然 404。

5.2.3 引用约束:@color 与 @asset

规范里的引用统一带前缀:颜色引用写成 @color/accent,资产引用写成 @asset/furn_lamp-base_v01。带前缀的好处是 lint 能一眼分清「这是个引用」而不是普通字符串,然后分头校验:

  • @color/<token>:<token> 必须命中共享真源 config/visual_spec.json 的 tokens。这一条把「美术规范」和「色彩规范」缝在了一起——第 3 章定的 8 个 token(bg / surface / ink / muted / accent / ok / warn / danger)就是引用的合法集合,写错一个名字立刻报错。
  • @asset/<id>:<id> 必须命中清单内已声明的资产。自引用(一个资产依赖自己)单列一个 E_SELFDEP,因为它是依赖图里最常见的环,值得单独报出来。

E_REFCOLOR 的存在让「引用完整性」不再是人工走查:谁都能写 @color/ghost,但 lint 会告诉你 visual_spec.json 里没有这个 token。

5.2.4 S 层:Python 目录与引用 lint

下面这段是 S 层脚本,读资产清单、路径规则,以及只读的 config/visual_spec.json,落盘 out/sp05_paths.json:

#!/usr/bin/env python3
# file: sim/sp05_paths.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp05_paths.py
"""目录层级与引擎内引用 lint(S 层):kind 决定唯一合法目录(E_DIRKIND);路径只允许
小写(E_PATHCASE);@color/<token> 必须命中 config/visual_spec.json 的 tokens(共享真源),
@asset/<id> 必须命中清单内 id 且不得自引用(E_REFCOLOR / E_REFASSET / E_SELFDEP)。
产出 out/sp05_paths.json 供 P 层 TS 校验器对照。规则是字符串/整数,两侧逐位一致。
"""
import json, os, re, sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(ROOT, "config/sp05_assets.json")
RULES = os.path.join(ROOT, "config/sp05_path_rules.json")
VISUAL = os.path.join(ROOT, "config/visual_spec.json")
OUT = os.path.join(ROOT, "out")


def lint_one(asset: dict, rules: dict, tokens: set, ids: set) -> list:
    out = []
    path = asset["file"]
    d = os.path.dirname(path)
    if d != rules["kindDir"].get(asset["kind"], ""):
        out.append({"id": asset["id"], "code": "E_DIRKIND", "token": d})
    if re.search(r"[A-Z]", path):
        out.append({"id": asset["id"], "code": "E_PATHCASE", "token": path})
    for ref in asset["colorTokens"]:
        name = ref[len(rules["colorRefPrefix"]):]
        if name not in tokens:
            out.append({"id": asset["id"], "code": "E_REFCOLOR", "token": name})
    for ref in asset["deps"]:
        name = ref[len(rules["assetRefPrefix"]):]
        if name == asset["id"]:
            out.append({"id": asset["id"], "code": "E_SELFDEP", "token": name})
        elif name not in ids:
            out.append({"id": asset["id"], "code": "E_REFASSET", "token": name})
    return sorted(out, key=lambda v: v["code"])


def main() -> int:
    assets = json.load(open(ASSETS, encoding="utf-8"))["assets"]
    rules = json.load(open(RULES, encoding="utf-8"))
    tokens = set(json.load(open(VISUAL, encoding="utf-8"))["tokens"].keys())
    ids = {a["id"] for a in assets}
    os.makedirs(OUT, exist_ok=True)
    violations = []
    for a in assets:
        violations.extend(lint_one(a, rules, tokens, ids))
    violations.sort(key=lambda v: (v["id"], v["code"]))
    counts = {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
    summary = {"project": "stardust-inn", "ruleSet": rules["ruleSet"],
               "assetCount": len(assets), "tokenCount": len(tokens),
               "violationCount": len(violations), "counts": counts, "violations": violations}
    with open(os.path.join(OUT, "sp05_paths.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"assetCount={len(assets)} tokens={len(tokens)} violations={len(violations)}")
    for code in sorted(counts):
        print(f"  {code}={counts[code]}")
    print("wrote out/sp05_paths.json")
    return 0


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

5.2.5 P 层:TS 校验器

同一份规则,TS 侧独立实现。注意它只从 visual_spec.json 取合法 token 集合,规则本身读 sp05_path_rules.json:

// file: core/src/sp05/paths.ts
// tier: P
// verify: node --test 'core/test/sp05_*.test.ts'
// 目录层级与引用完整性的 TS 侧实现:与 sim/sp05_paths.py 共用 config/sp05_path_rules.json。
// @color/<token> 的合法集合来自共享真源 config/visual_spec.json(只读)。两侧逐位一致。

export type Violation = { id: string; code: string; token: string };
export type Asset = { id: string; kind: string; file: string; colorTokens: string[]; deps: string[] };
export type PathRules = { kindDir: Record<string, string>; colorRefPrefix: string; assetRefPrefix: string };

function dirOf(path: string): string {
  return path.slice(0, path.lastIndexOf("/"));
}

export function lintPaths(assets: Asset[], rules: PathRules, tokens: Set<string>): Violation[] {
  const ids = new Set(assets.map((a) => a.id));
  const out: Violation[] = [];
  for (const a of assets) {
    const d = dirOf(a.file);
    if (d !== (rules.kindDir[a.kind] ?? "")) out.push({ id: a.id, code: "E_DIRKIND", token: d });
    if (/[A-Z]/.test(a.file)) out.push({ id: a.id, code: "E_PATHCASE", token: a.file });
    for (const ref of a.colorTokens) {
      const name = ref.slice(rules.colorRefPrefix.length);
      if (!tokens.has(name)) out.push({ id: a.id, code: "E_REFCOLOR", token: name });
    }
    for (const ref of a.deps) {
      const name = ref.slice(rules.assetRefPrefix.length);
      if (name === a.id) out.push({ id: a.id, code: "E_SELFDEP", token: name });
      else if (!ids.has(name)) out.push({ id: a.id, code: "E_REFASSET", token: name });
    }
  }
  return out.sort((x, y) =>
    x.id < y.id ? -1 : x.id > y.id ? 1 : x.code < y.code ? -1 : x.code > y.code ? 1 : 0);
}

/** 引擎侧导入路径:目录分类 → 运行时按 kind 归一,供看板与导入器消费。 */
export function enginePath(kind: string, rules: PathRules): string {
  return rules.kindDir[kind] ?? "";
}

5.2.6 P 层测试:对照 Python 产物

// file: core/test/sp05_paths.test.ts
// tier: P
// verify: node --test 'core/test/sp05_*.test.ts'
// 跨语言对照:读 sim/sp05_paths.py 落盘的 out/sp05_paths.json,断言 TS 侧 lintPaths 的违例
// 集合与之一致。负例断言:目录错位 / 悬空引用 / 自引用 / 大写路径都必须 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { lintPaths, enginePath } from "../src/sp05/paths.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const assets = JSON.parse(readFileSync(resolve(ROOT, "config/sp05_assets.json"), "utf8")).assets;
const rules = JSON.parse(readFileSync(resolve(ROOT, "config/sp05_path_rules.json"), "utf8"));
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp05_paths.json"), "utf8"));
const TOKENS = new Set<string>(Object.keys(spec.tokens));
const key = (v: { id: string; code: string; token: string }): string => `${v.id}:${v.code}:${v.token}`;
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);

test("跨语言:TS 违例集合 == Python 违例集合(逐位一致)", () => {
  const mine = lintPaths(assets, rules, TOKENS).map(key);
  const theirs = (py.violations as { id: string; code: string; token: string }[]).map(key);
  assert.deepEqual(mine, theirs);
  assert.equal(py.tokenCount, TOKENS.size);
  assert.equal(digest(mine), digest(theirs));
  console.log(`paths digest = ${digest(mine)}`);
});

test("负例断言:目录错位 / 悬空引用 / 自引用 / 大写路径都必须 fail", () => {
  const cases: [string, string][] = [
    ["vfx_magic-spark_v01", "E_DIRKIND"],
    ["tile_water-lily_v01", "E_PATHCASE"],
    ["furn_lamp_cozy_v01", "E_REFCOLOR"],
    ["furn_chair-base_v1", "E_REFASSET"],
    ["tile_floor-plank_v01", "E_SELFDEP"],
  ];
  const byId = new Map<string, string[]>();
  for (const v of lintPaths(assets, rules, TOKENS)) byId.set(v.id, [...(byId.get(v.id) ?? []), v.code]);
  for (const [id, code] of cases) assert.ok(byId.get(id)?.includes(code), `${id} 应报 ${code},实得 ${byId.get(id)}`);
});

test("@color/<token> 必须命中 visual_spec.json(共享真源)", () => {
  const codes = lintPaths(assets, rules, TOKENS).filter((v) => v.code === "E_REFCOLOR").map((v) => v.token).sort();
  assert.deepEqual(codes, ["brand", "ghost"]);
});

test("正例与引擎路径映射", () => {
  const flagged = new Set(lintPaths(assets, rules, TOKENS).map((v) => v.id));
  for (const id of ["furn_lamp-base_v01", "ui_panel-wood_v03", "icon_coin-gold_v01"]) {
    assert.ok(!flagged.has(id), `${id} 不应有违例`);
  }
  assert.equal(enginePath("furniture", rules), "assets/sprites/furniture");
  assert.equal(enginePath("nope", rules), "");
});

5.2.7 实跑输出

assetCount=24 tokens=8 violations=10
  E_DIRKIND=2
  E_PATHCASE=3
  E_REFASSET=2
  E_REFCOLOR=2
  E_SELFDEP=1

P 层 node --test 'core/test/sp05_*.test.ts':pass 4 / fail 0,digest 6f37f2fb860b9924(两侧逐位一致)。10 条违例里最值得看的是 E_PATHCASE=3——它包含 5.1 那两条 E_CASE 的资产(furn_Table-base_v01、icon_Icon-star_v01),因为名字里有大写,路径里必然也有。同一处错误被两条规则分别判出,是设计如此:命名 lint 管「名字」,路径 lint 管「它落到磁盘上长什么样」,两条都该拦。

5.2.8 决策与取舍

为什么不让主题进目录。 见 5.2.1:主题进目录会让 6 个主题包复制 6 份目录树。代价是「同一资产在不同主题下同名不同内容」需要靠清单的 theme 字段区分,而不是靠路径——这对 lint 更友好(路径只有一份)。

自引用为什么单独报。 环检测是第 4 章(卷二)依赖图的核心,但自引用是最短、最常出现的环,单独一个 E_SELFDEP 能让人一眼看懂。完整的环检测与悬空引用清理在卷二 4.2 展开。

目录深度要不要限制。 我们的 schema 固定深度,不给主题留目录层。好处是 lint 判据简单(dirname 直接比较);代价是「按主题分目录」这类组织方式不支持。这是有意的约束——规范的价值在于收窄可能性。

下一节把命名反面的东西讲透:保留字黑名单与命名反模式,同样落成可跑的 lint。

阅读导航:上一节:5.1 命名规则:前缀/类型/变体/版本 · 下一节:5.3 保留字与命名反模式 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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