13.1 美术需求书(Brief)的结构化写法

外包需求靠口头传达必然返工。本节把《星屑旅店》的一张家具需求变成带字段的 Brief:参考图、尺寸、风格约束、交付格式、验收口径,并用一份真跑的 S 层脚本与 P 层 TS 内核校验必填字段、枚举取值、参考图与调色板 token 的引用完整性,再用一份故意写坏的样本证明四类违例都会被拦下。

12.3 讲的是反馈分级,从本节起进入第 13 章:外包资产规格与验收清单。行业与外包这个话题,栏目里已有一些短文(见 /posts/game/industry/ ),它们讲的是流程与经验;《移动端与 PC 性能预算 2026》 讲的是预算怎么定档。本章不重推这些,只做一件它们没做的事:把「一份美术需求」写成带字段、可校验的 schema,并给出真跑的校验器。

13.1.1 口头需求为什么会返工

外包返工的头号原因不是画得差,而是需求没写清。「做一个温暖一点的木质床头柜」——这句话里没有尺寸、没有色板引用、没有交付格式、没有验收口径。画师只能猜,猜错了就返工,返工的账最后算到谁头上都说不清。

把口头需求变成结构化 Brief,要固定五件事:

要素一句话说明字段
参考图画师该照哪张 moodboardreferences
尺寸画布多大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验收无口径,返工归因各说各话必填

两条纪律:

  1. 枚举值必须与真源同源。theme 的六个取值就是六个主题包;paletteTokens 只能引用 visual_spec.json 的 token 名。需求书里不允许出现真源里没有的名字——这也是本章第一节就外链 game/industry/ 的原因:那里的短文讲经验,这里的 schema 讲「经验必须落成可校验的字段」。
  2. acceptance 不是摆设。它是 13.3 评分卡与返工归因的输入口径;需求书里没写验收标准,13.3 的门禁就无从判起。

13.1.6 边界与取舍

  • 画师审美、参考图的实际观感需要真实环境(画师 / 真人评审),命令行不可复现,标 X;本节只做需求书的字段与引用完整性校验,不判断「这张参考图好不好看」。
  • 本节只校验 Brief 自身是否自洽;Brief 与实际交付物是否一致,是 13.2 的事(规格表 ↔ 交付物清单双向核对),两节不能互相替代。
  • 真实外包平台的需求单字段各不相同;本套用七个字段做最小集,换项目换字段名,但「必填 + 枚举 + 引用完整性」这三类校验不变。
  • 全部判据是字符串/布尔类,TS 与 Python 逐位一致。

阅读导航:上一节:12.3 反馈层级的视觉表达 · 下一节:13.2 技术规格与交付物清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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