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 | 目录 | 归属 |
|---|---|---|
| furniture | assets/sprites/furniture | 场景美术 |
| character | assets/sprites/character | 角色美术 |
| ui | assets/sprites/ui | UI 美术 |
| tile | assets/sprites/tile | 场景美术 |
| vfx | assets/sprites/vfx | 特效美术 |
| icon | assets/sprites/icon | UI 美术 |
这张表本身就是一份 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 保留字与命名反模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。