12.3 讲的是反馈分级,从本节起进入第 13 章:外包资产规格与验收清单。行业与外包这个话题,栏目里已有一些短文(见 /posts/game/industry/ ),它们讲的是流程与经验;《移动端与 PC 性能预算 2026》 讲的是预算怎么定档。本章不重推这些,只做一件它们没做的事:把「一份美术需求」写成带字段、可校验的 schema,并给出真跑的校验器。
13.1.1 口头需求为什么会返工
外包返工的头号原因不是画得差,而是需求没写清。「做一个温暖一点的木质床头柜」——这句话里没有尺寸、没有色板引用、没有交付格式、没有验收口径。画师只能猜,猜错了就返工,返工的账最后算到谁头上都说不清。
把口头需求变成结构化 Brief,要固定五件事:
| 要素 | 一句话说明 | 字段 |
|---|---|---|
| 参考图 | 画师该照哪张 moodboard | references |
| 尺寸 | 画布多大 | size |
| 风格约束 | 主题、色板 token、描边宽度 | style |
| 交付格式 | 交什么文件、什么通道 | deliverables |
| 验收口径 | 怎么算合格 | acceptance |
字段化之后,「写没写清」就变成一个可判定的问题——不是靠人读一遍拍脑袋,而是靠校验器逐条判。这是本章的贯穿主张:需求书也是一种规范,规范就该被校验。
13.1.2 D 层:Brief 的字段 schema
下面这段是 D 层配置 config/sp13_brief.json:它同时给出 schema(枚举、必填字段、参考图池)与两份样本——goodBrief 是合格需求书,badBrief 故意缺必填字段、用了非法枚举、引用了一张不存在的参考图:
{
"project": "stardust-inn",
"schemaVersion": 1,
"specRef": "config/visual_spec.json",
"note": "美术需求书(Brief)的结构化 schema 与两份样本。goodBrief 是合格需求书;badBrief 故意缺必填字段、用了非法枚举值、引用了一张不存在的参考图。校验四类:必填字段、枚举取值、参考图引用完整性、调色板 token 引用完整性。",
"enums": {
"theme": ["pixel", "lowpoly", "handdrawn", "voxel", "retro-cyber", "cozy-clean"],
"format": ["png", "webp", "psd", "aseprite", "fbx", "gltf"],
"channel": ["rgb", "rgba", "gray", "gray+alpha"]
},
"required": ["id", "title", "references", "size", "style", "deliverables", "acceptance"],
"referencePool": ["moodboard_room_01", "moodboard_wood_tone", "moodboard_ui_01"],
"goodBrief": {
"id": "BRIEF-FURN-014",
"title": "客房木制床头柜",
"references": ["moodboard_room_01", "moodboard_wood_tone"],
"size": { "width": 128, "height": 128 },
"style": { "theme": "cozy-clean", "paletteTokens": ["surface", "ink", "accent"], "outlinePx": 2 },
"deliverables": [
{ "format": "png", "channel": "rgba", "path": "art/furniture/nightstand_01.png" },
{ "format": "aseprite", "channel": "rgba", "path": "src/furniture/nightstand_01.aseprite" }
],
"acceptance": { "gridPx": 8, "maxColors": 16, "silhouetteReadable": true }
},
"badBrief": {
"id": "BRIEF-FURN-015",
"title": "客房木制衣柜",
"references": ["moodboard_room_01", "moodboard_ghost"],
"style": { "theme": "cozy-warm", "paletteTokens": ["surface", "neon"], "outlinePx": 2 },
"deliverables": [
{ "format": "tga", "channel": "rgba", "path": "art/furniture/wardrobe_01.tga" }
]
}
}
注意 style.theme 的六个取值与 §1 的六个主题包完全同源;style.paletteTokens 引用的是 visual_spec.json 里的 token 名,不硬编码 hex。参考图与色板都不允许自由发挥——这正是「可校验」的前提。
13.1.3 S 层:四类校验
下面这段是 S 层脚本。它对一份 Brief 校验四类违例,并把 goodBrief 与 badBrief 各跑一遍:
#!/usr/bin/env python3
# file: sim/sp13_brief.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp13_brief.py
"""13.1 美术需求书(Brief)结构化的 S 层校验器。
读 config/sp13_brief.json(schema + 两份样本)与 config/visual_spec.json(token 真源,只读)。
对一份 Brief 校验四类:
1. 必填字段是否齐全 -> MISSING:<field>
2. 枚举取值是否合法 -> ENUM:<path>=<value>
3. 参考图是否在 referencePool -> REF:<id>
4. 调色板 token 是否在真源里 -> TOKEN:<token>
对 goodBrief 与 badBrief 各算一遍:正例必须零违例,反例必须被逐条报出。
产出 out/sp13_brief.json,供 P 层跨语言对照。全部为字符串/布尔类,逐位一致。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/visual_spec.json")
CFG = os.path.join(ROOT, "config/sp13_brief.json")
OUT = os.path.join(ROOT, "out")
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def validate(brief: dict, schema: dict, tokens: dict) -> list:
"""返回排序后的违例字符串列表;空列表即合格。"""
viol = []
for f in schema["required"]:
if f not in brief:
viol.append(f"MISSING:{f}")
style = brief.get("style", {})
theme = style.get("theme")
if theme is not None and theme not in schema["enums"]["theme"]:
viol.append(f"ENUM:style.theme={theme}")
for tok in style.get("paletteTokens", []):
if tok not in tokens:
viol.append(f"TOKEN:{tok}")
for i, d in enumerate(brief.get("deliverables", [])):
fmt = d.get("format")
if fmt is not None and fmt not in schema["enums"]["format"]:
viol.append(f"ENUM:deliverables[{i}].format={fmt}")
ch = d.get("channel")
if ch is not None and ch not in schema["enums"]["channel"]:
viol.append(f"ENUM:deliverables[{i}].channel={ch}")
pool = set(schema["referencePool"])
for r in brief.get("references", []):
if r not in pool:
viol.append(f"REF:{r}")
return sorted(viol)
def main() -> int:
spec, cfg = load(SPEC), load(CFG)
tokens = spec["tokens"]
good = validate(cfg["goodBrief"], cfg, tokens)
bad = validate(cfg["badBrief"], cfg, tokens)
report = {
"project": spec["project"],
"specVersion": spec["specVersion"],
"required": cfg["required"],
"good": {"violations": good, "pass": len(good) == 0, "count": len(good)},
"bad": {"violations": bad, "pass": len(bad) == 0, "count": len(bad)},
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp13_brief.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"[good] pass={report['good']['pass']} count={report['good']['count']} "
f"violations={good}")
print(f"[bad] pass={report['bad']['pass']} count={report['bad']['count']} "
f"violations={bad}")
print(f"required={cfg['required']}")
print("wrote out/sp13_brief.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp13_brief.py:
[good] pass=True count=0 violations=[]
[bad] pass=False count=6 violations=['ENUM:deliverables[0].format=tga', 'ENUM:style.theme=cozy-warm', 'MISSING:acceptance', 'MISSING:size', 'REF:moodboard_ghost', 'TOKEN:neon']
required=['id', 'title', 'references', 'size', 'style', 'deliverables', 'acceptance']
wrote out/sp13_brief.json
正样本零违例,反样本恰好六条:缺 size 与 acceptance 两个必填字段、主题 cozy-warm 不在六个主题包里、交付格式 tga 不在枚举里、参考图 moodboard_ghost 悬空、色板 token neon 不在真源里。六条分属四类,这就是「写没写清」被拆成可判定问题的样子。
13.1.4 P 层:同一套校验的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp13/brief.ts,与 Python 用同一套规则、同一套违例字符串格式:
// file: core/src/sp13/brief.ts
// tier: P
// verify: node --test 'core/test/sp13_*.test.ts'
// 13.1 美术需求书(Brief)结构化的 TS 侧校验器。与 sim/sp13_brief.py 同一套规则:
// 必填字段、枚举取值、参考图引用完整性、调色板 token 引用完整性。
// 违例是排序后的字符串数组,与 Python 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export function validateBrief(brief: any, schema: any, tokens: any): string[] {
const viol: string[] = [];
for (const f of schema.required) {
if (!(f in brief)) viol.push("MISSING:" + f);
}
const style = brief.style ?? {};
const theme = style.theme;
if (theme !== undefined && theme !== null && !schema.enums.theme.includes(theme)) {
viol.push("ENUM:style.theme=" + theme);
}
for (const tok of style.paletteTokens ?? []) {
if (!(tok in tokens)) viol.push("TOKEN:" + tok);
}
const dels: any[] = brief.deliverables ?? [];
for (let i = 0; i < dels.length; i++) {
const d = dels[i];
const fmt = d.format;
if (fmt !== undefined && fmt !== null && !schema.enums.format.includes(fmt)) {
viol.push("ENUM:deliverables[" + i + "].format=" + fmt);
}
const ch = d.channel;
if (ch !== undefined && ch !== null && !schema.enums.channel.includes(ch)) {
viol.push("ENUM:deliverables[" + i + "].channel=" + ch);
}
}
const pool = new Set(schema.referencePool);
for (const r of brief.references ?? []) {
if (!pool.has(r)) viol.push("REF:" + r);
}
return viol.sort();
}
core/test/sp13_brief.test.ts 一边读 Python 落盘的 out/sp13_brief.json 做跨语言对照,一边用内存样本构造负例——缺必填字段、非法枚举、悬空参考图三类都必须 fail:
// file: core/test/sp13_brief.test.ts
// tier: P
// verify: node --test 'core/test/sp13_*.test.ts'
// 跨语言对照:读 sim/sp13_brief.py 落盘的 out/sp13_brief.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 { validateBrief } from "../src/sp13/brief.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp13_brief.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp13_brief.json"), "utf8"));
test("正例:合格需求书零违例,与 Python 产物逐位一致", () => {
const mine = validateBrief(cfg.goodBrief, cfg, spec.tokens);
assert.deepEqual(mine, py.good.violations);
assert.equal(mine.length, 0);
assert.equal(py.good.pass, true);
});
test("反例:badBrief 的四类违例都必须被报出", () => {
const mine = validateBrief(cfg.badBrief, cfg, spec.tokens);
assert.deepEqual(mine, py.bad.violations);
assert.ok(mine.includes("MISSING:size"));
assert.ok(mine.includes("ENUM:style.theme=cozy-warm"));
assert.ok(mine.includes("REF:moodboard_ghost"));
assert.ok(mine.includes("TOKEN:neon"));
});
test("负例:缺必填字段必须 fail", () => {
const brief = { id: "x", title: "t" };
const r = validateBrief(brief, cfg, spec.tokens);
assert.ok(r.includes("MISSING:references"));
assert.ok(r.includes("MISSING:size"));
assert.ok(r.includes("MISSING:acceptance"));
});
test("负例:非法枚举值与悬空参考图必须 fail", () => {
const brief = {
id: "x", title: "t", references: ["nope"], size: { width: 1, height: 1 },
style: { theme: "cartoon", paletteTokens: [] },
deliverables: [{ format: "bmp", channel: "cmyk" }],
acceptance: {},
};
const r = validateBrief(brief, cfg, spec.tokens);
assert.ok(r.includes("ENUM:style.theme=cartoon"));
assert.ok(r.includes("ENUM:deliverables[0].format=bmp"));
assert.ok(r.includes("ENUM:deliverables[0].channel=cmyk"));
assert.ok(r.includes("REF:nope"));
});
实跑 node --test 'core/test/sp13_*.test.ts'(此处只列本节相关的 4 条;三节合计 14 条全绿):
✔ 正例:合格需求书零违例,与 Python 产物逐位一致
✔ 反例:badBrief 的四类违例都必须被报出
✔ 负例:缺必填字段必须 fail
✔ 负例:非法枚举值与悬空参考图必须 fail
ℹ tests 14
ℹ pass 14
ℹ fail 0
违例是字符串数组、pass 是布尔,全部逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。
13.1.5 字段为什么是这些
七个必填字段不是拍脑袋定的,每个都对应一类真实返工:
| 字段 | 少了它会怎样 | 校验类别 |
|---|---|---|
id / title | 需求无法被引用、无法对账 | 必填 |
references | 画师自由发挥风格 | 必填 + 引用完整性 |
size | 交付尺寸与图集槽位对不上 | 必填 |
style | 主题跑偏、色板与规范脱节 | 必填 + 枚举 + token |
deliverables | 交付格式与引擎导入设置打架 | 必填 + 枚举 |
acceptance | 验收无口径,返工归因各说各话 | 必填 |
两条纪律:
- 枚举值必须与真源同源。
theme的六个取值就是六个主题包;paletteTokens只能引用visual_spec.json的 token 名。需求书里不允许出现真源里没有的名字——这也是本章第一节就外链game/industry/的原因:那里的短文讲经验,这里的 schema 讲「经验必须落成可校验的字段」。 acceptance不是摆设。它是 13.3 评分卡与返工归因的输入口径;需求书里没写验收标准,13.3 的门禁就无从判起。
13.1.6 边界与取舍
- 画师审美、参考图的实际观感需要真实环境(画师 / 真人评审),命令行不可复现,标 X;本节只做需求书的字段与引用完整性校验,不判断「这张参考图好不好看」。
- 本节只校验 Brief 自身是否自洽;Brief 与实际交付物是否一致,是 13.2 的事(规格表 ↔ 交付物清单双向核对),两节不能互相替代。
- 真实外包平台的需求单字段各不相同;本套用七个字段做最小集,换项目换字段名,但「必填 + 枚举 + 引用完整性」这三类校验不变。
- 全部判据是字符串/布尔类,TS 与 Python 逐位一致。
阅读导航:上一节:12.3 反馈层级的视觉表达 · 下一节:13.2 技术规格与交付物清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。