5.1 命名规则:前缀/类型/变体/版本

把资产命名从「大家自觉」变成「机器可判」:讲清 `<前缀>_<subject>[_<变体>]_v<NN>` 这条文法,前缀、类型、变体、版本四个字段各自管什么,再用一份可跑的 Python + TypeScript 命名 lint 对同一份《星屑旅店》资产清单逐条判违例——违规命名必须真的 fail。

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 一一对应
subjectlamp-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_v01E_CASEsubject 里的大写 Table
icon_Icon-star_v01E_CASE同上,Icon 大写
furn_lamp_cozy_v01E_VARIANTcozy 不是合法变体(那是主题名,不是状态)
ui_button_bg_v01E_VARIANTbg 不是变体,且是待归一别名(见 5.3)
char_avatar_old_v01E_VARIANTold 是临时态词,见 5.3
furn_chair-base_v1E_VERSION版本没补零
furn_plant-pot_v01E_PREFIXkind 是 tile,前缀却是 furn
ui_panelE_FIELDS只有两段,连版本都没有

5.1.8 三个常被争论的点

变体到底该不该进文件名。 有的团队把 hov/dis 做成图集里的独立帧,不写进文件名;我们的选择是写进去,理由是引用它的代码要能按名字找到。代价是文件名变长,收益是状态匹配不需要三套别名。

版本号用几位。 两位(v01)够用到 99 次迭代。三位会在排序时更好看,但 99% 的资产到不了 100 版。关键是位数固定——不固定的版本号在字符串排序里必错。

前缀用 furn 还是 furniture。 前缀是给机器判的,越短越好;可读性交给清单里的 kind 字段(它是完整词)。所以前缀用缩写、kind 用全称,两者在配置里显式映射。

下一节把这条命名文法接到目录层级上:名字判对了,还得放在对的目录里,且引用的 color token 与资产 id 必须真实存在——那是「引擎内引用约束」。

阅读导航:上一节:4.3 色盲安全与对比度下限 · 下一节:5.2 目录层级与引擎内引用约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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