14.2 评审评分卡与通过/打回标准

评审最怕「我觉得不太行」——没有数字就没有返工依据。本节把《星屑旅店》的评审做成一张评分卡:每项给整数分与整数权重算加权分,通过或打回由「加权分达阈 且 无单项否决」两条规则决定。S 层算出加权分与判定,P 层复算同一套规则并用负例证明单项否决必须打回、加权刚好达阈必须通过。

14.2.1 没有数字的评审等于没评

14.1 排好了「谁在什么时候查什么」,但评审现场还差一件事:结论怎么算。如果结论是「我觉得不太行」,那返工的人不知道要改到什么程度才算行,改完还得再被「我觉得」一次。评分卡要解决的就是这件事——把评审结论变成可复算的整数。

一张评分卡由三样东西构成:

  • 评分项:风格一致性、剪影可读性、命名合规、面数预算、图集槽位……每项一个整数分(10 分制 ×10,90 就是 9.0 分);
  • 整数权重:不同项重要性不同,用整数权重加权;
  • 两条判定规则:加权分 ≥ 阈值(总体够好)且 无单项否决项低于底线(没有致命短板)。

14.2.2 D 层:评分卡配置

下面这段是 D 层配置 config/sp14_scorecard.json:5 个评分项、整数权重、整数分,阈值 passThreshold 与单项否决底线 vetoFloor。样本里 face-budget 是否决项且只拿了 55(低于底线 60),用来证明「加权够也照样打回」:

{
  "project": "stardust-inn",
  "schemaVersion": 1,
  "specRef": "config/visual_spec.json",
  "note": "评审评分卡:每项 score 是 0–100 的整数(即 10 分制 ×10,90 表示 9.0 分),weight 是整数权重。加权分 weighted = Σ(score × weight),满分 maxWeighted = 100 × Σweight。判定:weighted ≥ passThreshold 且无单项否决(veto 项 score ≥ vetoFloor)才通过。全整数,逐位一致。",
  "passThreshold": 7500,
  "vetoFloor": 60,
  "items": [
    { "id": "style-consistency", "weight": 30, "score": 92, "veto": false },
    { "id": "silhouette-readability", "weight": 20, "score": 80, "veto": false },
    { "id": "naming-convention", "weight": 20, "score": 88, "veto": true },
    { "id": "face-budget", "weight": 20, "score": 55, "veto": true },
    { "id": "atlas-slot", "weight": 10, "score": 95, "veto": false }
  ]
}

注意单位:分数是 0–100 的整数(92 表示 9.2 分),阈值 7500 是在加权分尺度上——权重总和 100,满分就是 100 × 100 = 10000,阈值 7500 相当于「10 分制里的 7.5 分」。所有量都是整数,逐位一致。

14.2.3 S 层:加权分与判定

下面这段是 S 层脚本,算加权分、满分,再判「达阈」与「否决」两条:

#!/usr/bin/env python3
# file: sim/sp14_scorecard.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp14_scorecard.py
"""14.2 评审评分卡与通过/打回标准的 S 层算账。

读 config/visual_spec.json(只读)与 config/sp14_scorecard.json。
每项 score 是 0–100 的整数(10 分制 ×10),weight 是整数权重:

  weighted    = Σ(score × weight)          ← 加权分(整数)
  maxWeighted = 100 × Σweight              ← 满分(整数)
  weightedPass = weighted >= passThreshold
  vetoLow     = 该项是否决项且 score < vetoFloor

判定 verdict = "pass" 当且仅当 weightedPass 且无 vetoLow。
reasons 形如 "VETO:<项>:<score><<floor>"。全整数,逐位一致。
落 out/sp14_scorecard.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_scorecard.json")
OUT = os.path.join(ROOT, "out")


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


def evaluate(cfg: dict):
    floor = cfg["vetoFloor"]
    items = []
    for it in cfg["items"]:
        items.append({"id": it["id"], "weight": it["weight"], "score": it["score"],
                      "veto": it["veto"], "vetoLow": it["veto"] and it["score"] < floor})
    weight_total = sum(it["weight"] for it in cfg["items"])
    weighted = sum(it["score"] * it["weight"] for it in cfg["items"])
    max_weighted = 100 * weight_total
    weighted_pass = weighted >= cfg["passThreshold"]
    reasons = [f"VETO:{it['id']}:{it['score']}<{floor}" for it in items if it["vetoLow"]]
    verdict = "pass" if (weighted_pass and not reasons) else "reject"
    return {
        "items": items, "weightTotal": weight_total, "maxWeighted": max_weighted,
        "weighted": weighted, "threshold": cfg["passThreshold"], "vetoFloor": floor,
        "weightedPass": weighted_pass, "vetoPass": not reasons,
        "verdict": verdict, "reasons": reasons,
    }


def main() -> int:
    spec, cfg = load(SPEC), load(CFG)
    r = evaluate(cfg)
    summary = {"project": spec["project"], "specVersion": spec["specVersion"], **r}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "sp14_scorecard.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"weightTotal={r['weightTotal']} maxWeighted={r['maxWeighted']} "
          f"weighted={r['weighted']} threshold={r['threshold']} "
          f"weightedPass={r['weightedPass']} vetoPass={r['vetoPass']}")
    for it in r["items"]:
        mark = "VETO" if it["veto"] else "    "
        flag = "LOW" if it["vetoLow"] else "ok"
        print(f"  {it['id']:<22} w={it['weight']:<3} score={it['score']:<3} {mark} {flag}")
    print(f"verdict={r['verdict']} reasons={r['reasons']}")
    print("wrote out/sp14_scorecard.json")
    return 0


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

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

weightTotal=100 maxWeighted=10000 weighted=8170 threshold=7500 weightedPass=True vetoPass=False
  style-consistency      w=30  score=92       ok
  silhouette-readability w=20  score=80       ok
  naming-convention      w=20  score=88  VETO ok
  face-budget            w=20  score=55  VETO LOW
  atlas-slot             w=10  score=95       ok
verdict=reject reasons=['VETO:face-budget:55<60']
wrote out/sp14_scorecard.json

读这张表:加权分 8170 ≥ 7500,weightedPass=True——总体是够好的。但 face-budget 只拿 55、低于否决底线 60,于是 vetoPass=False,判定 reject。这正是评分卡要立的规矩:总分不能掩盖致命短板。

14.2.4 P 层:同一套判定的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp14/scorecard.ts,与 Python 同一套整数规则:

// file: core/src/sp14/scorecard.ts
// tier: P
// verify: node --test 'core/test/sp14_*.test.ts'
// 14.2 评审评分卡与通过/打回标准的 TS 侧内核。与 sim/sp14_scorecard.py 同一套整数规则:
// weighted = Σ(score × weight),maxWeighted = 100 × Σweight;
// verdict = "pass" 当且仅当 weighted >= passThreshold 且无否决项低于 vetoFloor。
// 全整数,逐位一致。

export function evaluateScorecard(cfg: any): any {
  const floor = cfg.vetoFloor;
  const items = cfg.items.map((it: any) => ({
    id: it.id, weight: it.weight, score: it.score, veto: it.veto,
    vetoLow: it.veto && it.score < floor,
  }));
  const weightTotal = cfg.items.reduce((s: number, it: any) => s + it.weight, 0);
  const weighted = cfg.items.reduce((s: number, it: any) => s + it.score * it.weight, 0);
  const maxWeighted = 100 * weightTotal;
  const weightedPass = weighted >= cfg.passThreshold;
  const reasons = items
    .filter((it: any) => it.vetoLow)
    .map((it: any) => `VETO:${it.id}:${it.score}<${floor}`);
  const verdict = weightedPass && reasons.length === 0 ? "pass" : "reject";
  return {
    items, weightTotal, maxWeighted, weighted, threshold: cfg.passThreshold, vetoFloor: floor,
    weightedPass, vetoPass: reasons.length === 0, verdict, reasons,
  };
}

14.2.5 负例:单项否决与刚好达阈

core/test/sp14_scorecard.test.ts 读 Python 产物做跨语言对照,并构造两条边界负例:

// file: core/test/sp14_scorecard.test.ts
// tier: P
// verify: node --test 'core/test/sp14_*.test.ts'
// 跨语言对照:读 out/sp14_scorecard.json,断言加权分与判定逐位一致。
// 负例:单项否决(加权够也打回)、加权刚好达阈(必须通过)。
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 { evaluateScorecard } from "../src/sp14/scorecard.ts";

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

test("评分卡:TS 复算加权分与判定和 Python 产物逐位一致", () => {
  const mine = evaluateScorecard(cfg);
  assert.equal(mine.weighted, py.weighted);
  assert.equal(mine.maxWeighted, py.maxWeighted);
  assert.equal(mine.weightTotal, py.weightTotal);
  assert.equal(mine.weightedPass, py.weightedPass);
  assert.equal(mine.vetoPass, py.vetoPass);
  assert.equal(mine.verdict, py.verdict);
  assert.deepEqual(mine.reasons, py.reasons);
  assert.deepEqual(mine.items, py.items);
});

test("负例:单项否决必须打回(加权够也拦下)", () => {
  const bad = {
    passThreshold: 7500,
    vetoFloor: 60,
    items: [
      { id: "a", weight: 50, score: 100, veto: false },
      { id: "b", weight: 50, score: 55, veto: true },
    ],
  };
  const r = evaluateScorecard(bad);
  assert.equal(r.weighted, 7750);
  assert.equal(r.weightedPass, true);
  assert.equal(r.vetoPass, false);
  assert.equal(r.verdict, "reject");
  assert.deepEqual(r.reasons, ["VETO:b:55<60"]);
});

test("负例:加权刚好达阈必须通过", () => {
  const edge = {
    passThreshold: 7500,
    vetoFloor: 60,
    items: [
      { id: "a", weight: 50, score: 75, veto: false },
      { id: "b", weight: 50, score: 75, veto: true },
    ],
  };
  const r = evaluateScorecard(edge);
  assert.equal(r.weighted, 7500);
  assert.equal(r.weightedPass, true);
  assert.equal(r.vetoPass, true);
  assert.equal(r.verdict, "pass");
});

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

✔ 评分卡:TS 复算加权分与判定和 Python 产物逐位一致 (1.22075ms)
✔ 负例:单项否决必须打回(加权够也拦下) (0.116167ms)
✔ 负例:加权刚好达阈必须通过 (0.082208ms)
ℹ tests 11
ℹ pass 11
ℹ fail 0

两条负例各守一条边界:第一条加权 7750 ≥ 7500 却因 b=55 低于否决底线而 reject;第二条加权 7500 恰好等于阈值,>= 判定必须放行——这是最容易写错的一格(写成 > 就会把刚好达阈的资产误打回)。加权分、满分、阈值全为整数,TS 与 Python 逐位一致。

14.2.6 边界与取舍

  • 真实评审的审美判断、画师打分、专家主观权重需要真实环境(评审人),命令行不可复现,标 X;本节只做已给分数下的加权与判定,不产生分数本身。
  • 权重、阈值、否决项是**《星屑旅店》这一款治愈系休闲游戏**的取值;换项目就换权重与阈值,但「加权达阈 + 单项否决」的双规则不变。
  • 评分与权重一律整数(10 分制 ×10),加权分是整数乘法之和,逐位一致;本节不涉及浮点派生量。

阅读导航:上一节:14.1 评审的时机与参与角色 · 下一节:14.3 走查记录与决策留痕 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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