14.1 评审的时机与参与角色

评审不能随时拉个会了事,而要排成概念、制作中、接入前三个固定节点,每个节点各拦一类错。本节把《星屑旅店》三个节点的检查项、必需角色与否决权写成一份可校验的排班矩阵,用真跑的 S 层脚本与 P 层 TS 内核给出逐位一致的缺项与缺角色违例,并用两条负例证明门禁真的会 fail。

14.1.1 评审不是「随时拉个会」,而是三个固定节点

美术评审最容易退化成两种极端:要么「等全做完再说」,要么「一天一小会」。前者把错误留到最后——一套 200 张的家具图标如果到接入前才发现描边口径不对,返工的就是 200 张;后者的问题则是没人知道这次评审到底要检查什么、谁说了算。

做法是把评审排成三个节点,每个节点拦一类错,越早越便宜:

节点时机拦的是哪类错返工代价
概念评审动笔前风格跑偏、参考选错、剪影目标不清只废草图
制作中评审做到一半命名违规、面数超标、图集槽位不够废一半资产
接入前评审提交引擎前清单字段缺、依赖悬空、整包超预算只废一次导入

工程侧的「美术方向愿景」在 《游戏美术方向与风格指南 2026》 那篇里讲的是口号式的方向;本节讲的是可排班、可校验的评审节点表——把「谁在什么时候查什么」写成一份能被脚本校验的矩阵。两者的分工很清楚:那篇回答「要什么风格」,本节回答「怎么保证做出来还是那个风格」。

14.1.2 D 层:评审节点表

下面这段是 D 层配置 config/sp14_review_nodes.json:三个节点各带必需检查项 requiredChecks、必需在场角色 requiredRoles 与有否决权的角色 vetoRoles;plan 是实际排班(node + check + role 三元组)。样本故意留了两处缺口,好让校验器真的会拦:

{
  "project": "stardust-inn",
  "schemaVersion": 1,
  "specRef": "config/visual_spec.json",
  "note": "美术评审节点表:nodes 是三个评审节点(概念 / 制作中 / 接入前),各带必需检查项 requiredChecks、必需在场角色 requiredRoles 与有否决权的角色 vetoRoles。plan 是实际排班(node+check+role 三元组)。校验规则:某节点缺必需检查项即报 MISSING_CHECK,缺必需角色即报 MISSING_ROLE。全部为字符串集合运算,逐位一致。",
  "nodes": [
    {
      "id": "concept",
      "label": "概念评审",
      "stage": 1,
      "requiredChecks": ["style-consistency", "palette-lock", "silhouette-goal", "reference-board"],
      "requiredRoles": ["art-director", "concept-artist", "ta"],
      "vetoRoles": ["art-director"]
    },
    {
      "id": "in-production",
      "label": "制作中评审",
      "stage": 2,
      "requiredChecks": ["naming-convention", "face-budget", "atlas-slot", "mip-tier"],
      "requiredRoles": ["ta", "lead-artist", "art-director"],
      "vetoRoles": ["ta"]
    },
    {
      "id": "pre-integration",
      "label": "接入前评审",
      "stage": 3,
      "requiredChecks": ["manifest-fields", "dependency-ref", "package-budget", "vram-chain"],
      "requiredRoles": ["ta", "engine-programmer", "art-director"],
      "vetoRoles": ["ta", "engine-programmer"]
    }
  ],
  "plan": [
    { "node": "concept", "check": "style-consistency", "role": "art-director" },
    { "node": "concept", "check": "palette-lock", "role": "art-director" },
    { "node": "concept", "check": "silhouette-goal", "role": "concept-artist" },
    { "node": "concept", "check": "reference-board", "role": "ta" },
    { "node": "in-production", "check": "naming-convention", "role": "ta" },
    { "node": "in-production", "check": "face-budget", "role": "lead-artist" },
    { "node": "in-production", "check": "atlas-slot", "role": "art-director" },
    { "node": "pre-integration", "check": "manifest-fields", "role": "ta" },
    { "node": "pre-integration", "check": "dependency-ref", "role": "engine-programmer" },
    { "node": "pre-integration", "check": "package-budget", "role": "ta" },
    { "node": "pre-integration", "check": "vram-chain", "role": "ta" }
  ]
}

读这份表:concept 排满了 4 项检查、3 个角色都在场;in-production 少排了 mip-tier;pre-integration 少了 art-director 这个角色。两种缺口是不同的病,下面分开报。

14.1.3 S 层:节点 × 检查项矩阵

下面这段是 S 层脚本。它把每个节点的排班汇总成「已排检查项 / 已到场角色」,再与必需集合各算一次差集:

#!/usr/bin/env python3
# file: sim/sp14_review.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp14_review.py
"""14.1 评审时机与参与角色的 S 层校验器。

读 config/visual_spec.json(只读)与 config/sp14_review_nodes.json。
对每个评审节点,把 plan 里属于该节点的排班汇总成「节点 × 检查项」矩阵:

  assignedChecks = 该节点已排班的检查项(去重,保序)
  presentRoles   = 该节点已排班的角色(去重,保序)
  missingChecks  = requiredChecks - assignedChecks
  missingRoles   = requiredRoles - presentRoles

违例是字符串:缺检查项 → "<节点>:MISSING_CHECK:<检查项>";
缺角色 → "<节点>:MISSING_ROLE:<角色>"。全部为集合运算,逐位一致。
落 out/sp14_review.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/sp14_review_nodes.json")
OUT = os.path.join(ROOT, "out")


def load(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def dedup(seq):
    return list(dict.fromkeys(seq))


def validate(cfg: dict):
    nodes = []
    for n in cfg["nodes"]:
        rows = [p for p in cfg["plan"] if p["node"] == n["id"]]
        assigned = dedup(r["check"] for r in rows)
        present = dedup(r["role"] for r in rows)
        missing_checks = [c for c in n["requiredChecks"] if c not in assigned]
        missing_roles = [r for r in n["requiredRoles"] if r not in present]
        veto_present = [r for r in n["vetoRoles"] if r in present]
        nodes.append({
            "id": n["id"], "label": n["label"], "stage": n["stage"],
            "assignedChecks": assigned, "presentRoles": present,
            "missingChecks": missing_checks, "missingRoles": missing_roles,
            "vetoRoles": n["vetoRoles"], "vetoRolesPresent": veto_present,
            "complete": len(missing_checks) == 0 and len(missing_roles) == 0,
        })
    violations = []
    for n in nodes:
        violations += [f"{n['id']}:MISSING_CHECK:{c}" for c in n["missingChecks"]]
        violations += [f"{n['id']}:MISSING_ROLE:{r}" for r in n["missingRoles"]]
    return nodes, sorted(violations)


def main() -> int:
    spec, cfg = load(SPEC), load(CFG)
    nodes, violations = validate(cfg)
    check_count = sum(len(n["requiredChecks"]) for n in cfg["nodes"])
    summary = {
        "project": spec["project"], "specVersion": spec["specVersion"],
        "nodeCount": len(cfg["nodes"]), "checkCount": check_count,
        "assignmentCount": len(cfg["plan"]), "nodes": nodes,
        "violations": violations, "count": len(violations),
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "sp14_review.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"nodes={summary['nodeCount']} requiredChecks={check_count} "
          f"assignments={summary['assignmentCount']} violations={summary['count']}")
    for n in nodes:
        flag = "ok" if n["complete"] else "FAIL"
        print(f"  {n['id']:<15} stage={n['stage']} checks={len(n['assignedChecks'])}/"
              f"{len(n['assignedChecks']) + len(n['missingChecks'])} "
              f"roles={len(n['presentRoles'])} veto={n['vetoRolesPresent']} {flag}")
    for v in violations:
        print(f"  VIOLATION {v}")
    print("wrote out/sp14_review.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/sp14_review.py:

nodes=3 requiredChecks=12 assignments=11 violations=2
  concept         stage=1 checks=4/4 roles=3 veto=['art-director'] ok
  in-production   stage=2 checks=3/4 roles=3 veto=['ta'] FAIL
  pre-integration stage=3 checks=4/4 roles=2 veto=['ta', 'engine-programmer'] FAIL
  VIOLATION in-production:MISSING_CHECK:mip-tier
  VIOLATION pre-integration:MISSING_ROLE:art-director
wrote out/sp14_review.json

读这张表:concept 是 ok,另两个节点 FAIL。两条违例精确指出缺的是什么——in-production:MISSING_CHECK:mip-tier(检查项缺)与 pre-integration:MISSING_ROLE:art-director(角色缺)。12 项必需检查排了 11 条班,缺口就是这两处。

14.1.4 P 层:同一套矩阵校验的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp14/review.ts,与 Python 逐条对应——汇总、两次差集、违例字符串格式完全一致。全部是字符串集合运算,逐位一致:

// file: core/src/sp14/review.ts
// tier: P
// verify: node --test 'core/test/sp14_*.test.ts'
// 14.1 评审时机与参与角色的 TS 侧校验器。与 sim/sp14_review.py 同一套集合规则:
// 每个节点从 plan 汇总 assignedChecks / presentRoles,各算一次差集得
// missingChecks / missingRoles;缺项即 "<节点>:MISSING_CHECK:<检查项>",
// 缺角色即 "<节点>:MISSING_ROLE:<角色>"。全为字符串集合运算,逐位一致。

function dedup(seq: string[]): string[] {
  return [...new Set(seq)];
}

export function validateReviewMatrix(cfg: any): any {
  const nodes = cfg.nodes.map((n: any) => {
    const rows = cfg.plan.filter((p: any) => p.node === n.id);
    const assignedChecks = dedup(rows.map((r: any) => r.check));
    const presentRoles = dedup(rows.map((r: any) => r.role));
    const missingChecks = n.requiredChecks.filter((c: string) => !assignedChecks.includes(c));
    const missingRoles = n.requiredRoles.filter((r: string) => !presentRoles.includes(r));
    const vetoRolesPresent = n.vetoRoles.filter((r: string) => presentRoles.includes(r));
    const complete = missingChecks.length === 0 && missingRoles.length === 0;
    return {
      id: n.id, label: n.label, stage: n.stage, assignedChecks, presentRoles,
      missingChecks, missingRoles, vetoRoles: n.vetoRoles, vetoRolesPresent, complete,
    };
  });
  const violations: string[] = [];
  for (const n of nodes) {
    for (const c of n.missingChecks) violations.push(`${n.id}:MISSING_CHECK:${c}`);
    for (const r of n.missingRoles) violations.push(`${n.id}:MISSING_ROLE:${r}`);
  }
  violations.sort();
  return { nodes, violations, count: violations.length };
}

配套的 P 层测试 core/test/sp14_review.test.ts 读 Python 落盘的 out/sp14_review.json 做跨语言对照,并用内存对象构造「缺检查项」「缺角色」两条负例:

// file: core/test/sp14_review.test.ts
// tier: P
// verify: node --test 'core/test/sp14_*.test.ts'
// 跨语言对照:读 sim/sp14_review.py 落盘的 out/sp14_review.json,断言节点矩阵与违例
// 集合逐位一致。负例:缺检查项、缺角色都必须 fail。
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 { validateReviewMatrix } from "../src/sp14/review.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp14_review_nodes.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp14_review.json"), "utf8"));

test("评审节点矩阵:TS 复算与 Python 产物逐位一致", () => {
  const mine = validateReviewMatrix(cfg);
  assert.deepEqual(mine.violations, py.violations);
  assert.equal(mine.count, py.count);
  assert.equal(mine.nodes.length, py.nodes.length);
  for (const n of mine.nodes) {
    const p = py.nodes.find((x: any) => x.id === n.id);
    assert.deepEqual(n.assignedChecks, p.assignedChecks, n.id);
    assert.deepEqual(n.presentRoles, p.presentRoles, n.id);
    assert.deepEqual(n.missingChecks, p.missingChecks, n.id);
    assert.deepEqual(n.missingRoles, p.missingRoles, n.id);
    assert.deepEqual(n.vetoRolesPresent, p.vetoRolesPresent, n.id);
    assert.equal(n.complete, p.complete, n.id);
  }
});

test("正例:concept 节点检查项与角色齐全", () => {
  const c = validateReviewMatrix(cfg).nodes.find((x: any) => x.id === "concept");
  assert.deepEqual(c.missingChecks, []);
  assert.deepEqual(c.missingRoles, []);
  assert.equal(c.complete, true);
});

test("负例:缺检查项必须 fail", () => {
  const bad = {
    nodes: [{ id: "n", label: "x", stage: 1, requiredChecks: ["a", "b"], requiredRoles: [], vetoRoles: [] }],
    plan: [{ node: "n", check: "a", role: "ta" }],
  };
  assert.deepEqual(validateReviewMatrix(bad).violations, ["n:MISSING_CHECK:b"]);
});

test("负例:缺角色必须 fail", () => {
  const bad = {
    nodes: [{ id: "n", label: "x", stage: 1, requiredChecks: [], requiredRoles: ["ta", "art-director"], vetoRoles: [] }],
    plan: [{ node: "n", check: "a", role: "ta" }],
  };
  assert.deepEqual(validateReviewMatrix(bad).violations, ["n:MISSING_ROLE:art-director"]);
});

实跑 node --test 'core/test/sp14_*.test.ts'(此处只列本节相关的 4 条;三节合计 11 条全绿):

✔ 评审节点矩阵:TS 复算与 Python 产物逐位一致 (1.031542ms)
✔ 正例:concept 节点检查项与角色齐全 (0.112125ms)
✔ 负例:缺检查项必须 fail (0.085667ms)
✔ 负例:缺角色必须 fail (0.071375ms)
ℹ tests 11
ℹ pass 11
ℹ fail 0

14.1.5 判读:缺项与缺角色是两种病

  • 缺检查项(MISSING_CHECK):排班时漏了一项检查。这类错误最隐蔽——会上大家都到齐了、聊得也热闹,但没人查 mip-tier,于是贴图分辨率与 mip 链的规格漏洞一路溜到接入前才炸。
  • 缺角色(MISSING_ROLE):必需的角色没在场。pre-integration 缺的正是 art-director——有否决权的人不在场,评审等于没评:没人能在「这条预算超了、先放行」这件事上说「不行」。

所以规范的第二条是:每个节点的 vetoRoles 必须是 requiredRoles 的子集,且必须在 plan 里真的到场。一个不在场的否决者,和一个根本没有否决者的节点,是同一种失败——矩阵校验把这两件事都变成可报的字符串。

14.1.6 边界与取舍

  • 真实评审会的临场判断、审美争论、口头决策需要真实环境(会议室 / 评审人),命令行不可复现,标 X;本节只校验排班矩阵的完备性,不评价评审质量本身。
  • 三个节点、12 项检查、11 条排班是**《星屑旅店》这一款治愈系休闲游戏**的取值;换项目就换检查项与角色,但「节点 × 检查项 + 角色在场」的矩阵结构不变。
  • 全部为字符串集合运算,TS 与 Python 逐位一致;本节不涉及任何浮点派生量,故没有跨语言容差问题。

阅读导航:上一节:13.3 验收标准与返工归因 · 下一节:14.2 评审评分卡与通过/打回标准 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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