5.1 命名规则:前缀/类型/变体/版本
美术规范里最便宜、也最先见效的一条,是命名。它不需要画师改画风,不需要程序改引擎,只要把一个文件名写对。但「写对」这件事,如果只靠文档里的口号,几乎一定会失控——站内那篇 游戏美术风格指南 已经把「命名要统一」讲到了方向层面,它讲的是道理;本节不重复那套口号式提纲,而是把命名落成一条能跑的正则 lint:给定一份资产清单,谁来判、按什么判、判错了会不会真的拦下来。
这一节的核心主张只有一句:命名规则必须能被机器执行,而不是被人类记得。
5.1.1 为什么命名值得单独一节
先看一个真实场景。《星屑旅店》是等距经营 × 装扮的休闲游戏,光美术资产就有家具图标、角色头像、UI、地块、特效五类。三个人、六周、上千个文件,如果命名靠口头约定,会稳定地长出四类事故:
- 同一个东西两个名字:
furn_lamp.png和lamp_furn.png都有人用,导入时引擎当成两张图。 - 大小写漂移:
furn_Table.png与furn_table.png在 macOS 上是同一个文件,推到 CI(Linux)上变成两个。 - 版本裸奔:
furn_chair_v1.png、furn_chair_v2.png没有补零,排序时v10排在v2前面。 - 变体词各写各的:高亮态有人写
hover、有人写highlight、有人写hov,代码里匹配状态时要写三个分支。
这四类都不是审美问题,是工程问题。它们的共同点是:能用一条正则判出来。所以本节的落点不是「建议怎么命名」,而是「写一个 lint,让不合规的命名直接构建失败」。
5.1.2 文法:一段可正则化的名字
我们给《星屑旅店》定一条文件名文法:
<kind 前缀>_<subject>[_<变体>]_v<NN>
四段含义:
| 段 | 例子 | 谁决定 | 约束 |
|---|---|---|---|
| kind 前缀 | furn / char / ui / tile / vfx / icon | 资产类型 | 枚举,和清单里的 kind 一一对应 |
| subject | lamp-base / innkeeper-idle | 美术 | 纯小写,单词用连字符,禁止下划线与大写 |
| 变体(可选) | base / lit / dark / hov / dis / alt / sm / lg | 状态 | 枚举,缺省即基础态 |
| 版本 | v01 / v02 | 版本管理 | v + 两位补零数字 |
关键设计是下划线分段、连字符分词:下划线是「字段分隔符」,只出现两次或三次;连字符是「单词分隔符」,可以出现任意次。这样 furn_lamp-base_v01 与 furn_lamp_cozy_v01 的段数不同(前者无变体、后者有),解析规则完全确定。
5.1.3 规则表:一份能被两处消费的配置
规则本身写成 JSON,作为 Python 与 TypeScript 的共同输入——这是本套「一份规范,三处消费」在命名上的落点。下面这个块是 D 层(静态 schema),它不产出数字,只定义判据:
{
"ruleSet": "naming",
"specVersion": 1,
"note": "资产文件名文法:<kind 前缀>_<subject>[_<变体>]_v<NN>。下划线分三段(无变体)或四段(有变体),subject 内的单词用连字符。规则与阈值全部是字符串/整数,Python 与 TS 两侧逐位一致。",
"kindPrefix": {
"furniture": "furn",
"character": "char",
"ui": "ui",
"tile": "tile",
"vfx": "vfx",
"icon": "icon"
},
"variants": ["base", "lit", "dark", "hov", "dis", "alt", "sm", "lg"],
"subjectPattern": "^[a-z][a-z0-9-]*$",
"versionPattern": "^v[0-9]{2}$",
"minFields": 3,
"maxFields": 4,
"maxSubjectLen": 24,
"maxBaseLen": 40
}
规则与阈值全部是字符串或整数(正则串、枚举数组、段数上下界),没有浮点。这是刻意的:只要两侧读同一份 JSON、按同样的顺序比较,违例集合就能逐位一致——这也是本套三条跨语言纪律里「预算与阈值一律整数」的直接应用。
5.1.4 S 层:Python 命名 lint
下面这段是 S 层脚本,读 config/sp05_assets.json(资产清单)与上面那份规则,逐条判违例,落盘 out/sp05_naming.json:
#!/usr/bin/env python3
# file: sim/sp05_naming.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp05_naming.py
"""命名 lint(S 层):读资产清单,按 <前缀>_<subject>[_<变体>]_v<NN> 文法逐项检查。
产出 out/sp05_naming.json —— P 层 TS 校验器读它做跨语言对照。规则是字符串/整数,
两侧违例集合逐位一致;违规命名必须真的 fail(负例断言见 core/test/sp05_naming.test.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_naming_rules.json")
OUT = os.path.join(ROOT, "out")
def lint_one(asset: dict, rules: dict) -> list:
base = os.path.splitext(os.path.basename(asset["file"]))[0]
tokens = base.split("_")
if len(tokens) == rules["minFields"]:
prefix, subject, version = tokens
variant = None
elif len(tokens) == rules["maxFields"]:
prefix, subject, variant, version = tokens
else:
return [{"id": asset["id"], "code": "E_FIELDS", "token": str(len(tokens))}]
out = []
if prefix != rules["kindPrefix"].get(asset["kind"], ""):
out.append({"id": asset["id"], "code": "E_PREFIX", "token": prefix})
if not re.match(rules["subjectPattern"], subject):
out.append({"id": asset["id"], "code": "E_CASE", "token": subject})
if variant is not None and variant not in rules["variants"]:
out.append({"id": asset["id"], "code": "E_VARIANT", "token": variant})
if not re.match(rules["versionPattern"], version):
out.append({"id": asset["id"], "code": "E_VERSION", "token": version})
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"))
os.makedirs(OUT, exist_ok=True)
violations = []
for a in assets:
violations.extend(lint_one(a, rules))
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), "violationCount": len(violations),
"counts": counts, "violations": violations}
with open(os.path.join(OUT, "sp05_naming.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)} violations={len(violations)}")
for code in sorted(counts):
print(f" {code}={counts[code]}")
print("wrote out/sp05_naming.json")
return 0
if __name__ == "__main__":
sys.exit(main())
五个违例码对应四条规则加一条结构规则:E_PREFIX(前缀与 kind 不符)、E_CASE(subject 含大写或非法字符)、E_VARIANT(变体不在枚举内)、E_VERSION(版本不是两位补零)、E_FIELDS(段数不是 3 或 4)。注意 E_FIELDS 是短路的——段数一错,其余三项不再判,避免同一处错报四条。
5.1.5 P 层:TS 校验器
同一份规则,TypeScript 侧独立实现一遍。它不 import Python 的任何东西,只读同一份 JSON:
// file: core/src/sp05/naming.ts
// tier: P
// verify: node --test 'core/test/sp05_*.test.ts'
// 命名 lint 的 TS 侧实现:与 sim/sp05_naming.py 共用 config/sp05_naming_rules.json。
// 规则是正则/枚举/整数,故两侧违例集合逐位一致。只允许可擦除 TS 语法。
export type Violation = { id: string; code: string; token: string };
export type Asset = { id: string; kind: string; file: string };
export type NamingRules = { kindPrefix: Record<string, string>; variants: string[];
subjectPattern: string; versionPattern: string; minFields: number; maxFields: number };
/** 取文件名并去掉最后一个扩展名(对齐 Python os.path.splitext)。 */
export function baseName(path: string): string {
const file = path.slice(path.lastIndexOf("/") + 1);
const dot = file.lastIndexOf(".");
return dot > 0 ? file.slice(0, dot) : file;
}
export function lintNaming(assets: Asset[], rules: NamingRules): Violation[] {
const out: Violation[] = [];
for (const a of assets) {
const t = baseName(a.file).split("_");
let prefix: string, subject: string, version: string;
let variant: string | null = null;
if (t.length === rules.minFields) {
prefix = t[0]; subject = t[1]; version = t[2];
} else if (t.length === rules.maxFields) {
prefix = t[0]; subject = t[1]; variant = t[2]; version = t[3];
} else {
out.push({ id: a.id, code: "E_FIELDS", token: String(t.length) });
continue;
}
if (prefix !== (rules.kindPrefix[a.kind] ?? "")) out.push({ id: a.id, code: "E_PREFIX", token: prefix });
if (!new RegExp(rules.subjectPattern).test(subject)) out.push({ id: a.id, code: "E_CASE", token: subject });
if (variant !== null && !rules.variants.includes(variant)) out.push({ id: a.id, code: "E_VARIANT", token: variant });
if (!new RegExp(rules.versionPattern).test(version)) out.push({ id: a.id, code: "E_VERSION", token: version });
}
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);
}
5.1.6 P 层测试:读 Python 的产物做对照
「跨语言一致」不能靠「两边写得像」。测试读的是 Python 落盘的 JSON(不是同一份源码),再断言 TS 复算出的违例集合与它逐条相同:
// file: core/test/sp05_naming.test.ts
// tier: P
// verify: node --test 'core/test/sp05_*.test.ts'
// 跨语言对照:读 sim/sp05_naming.py 落盘的 out/sp05_naming.json,断言 TS 侧 lintNaming
// 复算出的违例集合与之一致(整数/字符串类,逐位一致),并带负例断言:违规命名必须真的 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 { lintNaming, baseName } from "../src/sp05/naming.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_naming_rules.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp05_naming.json"), "utf8"));
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 = lintNaming(assets, rules).map(key);
const theirs = (py.violations as { id: string; code: string; token: string }[]).map(key);
assert.deepEqual(mine, theirs);
assert.equal(py.assetCount, assets.length);
assert.equal(digest(mine), digest(theirs));
console.log(`naming digest = ${digest(mine)}`);
});
test("负例断言:违规命名必须 fail", () => {
const cases: [string, string, string][] = [
["furn_Table-base_v01", "E_CASE", "Table-base"],
["furn_lamp_cozy_v01", "E_VARIANT", "cozy"],
["ui_button_bg_v01", "E_VARIANT", "bg"],
["furn_chair-base_v1", "E_VERSION", "v1"],
["furn_plant-pot_v01", "E_PREFIX", "furn"],
["ui_panel", "E_FIELDS", "2"],
];
const byId = new Map<string, string[]>();
for (const v of lintNaming(assets, rules)) byId.set(v.id, [...(byId.get(v.id) ?? []), `${v.code}:${v.token}`]);
for (const [id, code, token] of cases) {
assert.ok(byId.get(id)?.includes(`${code}:${token}`), `${id} 应报 ${code}:${token},实得 ${byId.get(id)}`);
}
});
test("正例:规范命名一个错都不报;段数错判 E_FIELDS", () => {
const flagged = new Set(lintNaming(assets, rules).map((v) => v.id));
for (const id of ["furn_lamp-base_v01", "char_innkeeper-idle_v01", "tile_grass-dark_v01"]) {
assert.ok(!flagged.has(id), `${id} 不应有违例`);
}
assert.equal(baseName("assets/sprites/ui/ui_panel.png"), "ui_panel");
assert.equal(lintNaming([{ id: "y", kind: "ui", file: "ui_panel_wood_base_v01.png" }], rules)[0].code, "E_FIELDS");
});
5.1.7 实跑输出与跨语言 digest
三个脚本都真跑过。S 层输出:
assetCount=24 violations=8
E_CASE=2
E_FIELDS=1
E_PREFIX=1
E_VARIANT=3
E_VERSION=1
P 层 node --test 'core/test/sp05_*.test.ts':pass 3 / fail 0。把每条违例按 id:code:token 排序拼接后取 sha256 前 16 位,两侧算出的 digest 都是 847d5ec4fddb9e57——这是整数/字符串类,逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见第 4 章)。
24 个资产里 8 条违例,分布在 6 个文件上。逐条对照能看出 lint 真的在判东西:
| 文件 | 违例 | 说明 |
|---|---|---|
furn_Table-base_v01 | E_CASE | subject 里的大写 Table |
icon_Icon-star_v01 | E_CASE | 同上,Icon 大写 |
furn_lamp_cozy_v01 | E_VARIANT | cozy 不是合法变体(那是主题名,不是状态) |
ui_button_bg_v01 | E_VARIANT | bg 不是变体,且是待归一别名(见 5.3) |
char_avatar_old_v01 | E_VARIANT | old 是临时态词,见 5.3 |
furn_chair-base_v1 | E_VERSION | 版本没补零 |
furn_plant-pot_v01 | E_PREFIX | kind 是 tile,前缀却是 furn |
ui_panel | E_FIELDS | 只有两段,连版本都没有 |
5.1.8 三个常被争论的点
变体到底该不该进文件名。 有的团队把 hov/dis 做成图集里的独立帧,不写进文件名;我们的选择是写进去,理由是引用它的代码要能按名字找到。代价是文件名变长,收益是状态匹配不需要三套别名。
版本号用几位。 两位(v01)够用到 99 次迭代。三位会在排序时更好看,但 99% 的资产到不了 100 版。关键是位数固定——不固定的版本号在字符串排序里必错。
前缀用 furn 还是 furniture。 前缀是给机器判的,越短越好;可读性交给清单里的 kind 字段(它是完整词)。所以前缀用缩写、kind 用全称,两者在配置里显式映射。
下一节把这条命名文法接到目录层级上:名字判对了,还得放在对的目录里,且引用的 color token 与资产 id 必须真实存在——那是「引擎内引用约束」。
阅读导航:上一节:4.3 色盲安全与对比度下限 · 下一节:5.2 目录层级与引擎内引用约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。