1.2 能力矩阵与产出物

把「TA 要会什么」写成一份带权重的能力矩阵,是本节的全部工作。难点不在列清单,而在怎么算「够不够」:平均分够不等于达标,只要有一项低于职级要求,这一项就整块不计入加权覆盖率。本节给出一份可断言的 TypeScript 能力矩阵内核与两条负例,并与 Python 侧逐位对齐。

站内 《美术制作与视觉规范》 第一卷已经给过一份职责矩阵,回答「谁做什么」;本节要回答的是更细的一层——同一个人,凭什么算到了中级。渲染原理专题 与 《渲染引擎》 里的能力要求是「懂某类原理」,TA 的能力要求是「交付某类产物」,两者不能混着算。

能力项必须挂产出物

一条写不出产出物的能力项,就是一条无法验收的能力项。所以本节的矩阵里,每项能力都强制挂一个 deliverable——它是这项能力的验收凭据,不是举例说明。

能力项family权重‰可验收产出物
c-tool 工具开发tool300lint 脚本 + 报告 schema
c-pipeline 管线集成pipeline300导出预设 + 导入设置表
c-perf 性能分析performance250面数 / 骨骼 / 材质槽预算表
c-comm 跨职能沟通interface150接口矩阵 + 评审记录

四项权重之和必须恰为 1000——这不是审美,而是让「加权覆盖率」直接等于千分比,省掉一次除法。真实能力评估需真实团队环境,命令行不可复现(X)。

职级要求:三档,一档一张表

下面这段是 D 层能力矩阵配置:每项能力的权重、每档职级的最低等级要求(L0–L5)、以及某个 TA 的当前等级 profile。它是 S 层覆盖账与 P 层校验器的共同输入。

{
  "version": 1,
  "project": "stardust-inn",
  "note": "TA 能力矩阵与产出物。capabilities = 能力项(family ∈ tool|pipeline|performance|interface,deliverable = 该能力的可验收产出物);tiers = 职级对每项能力的最低等级要求(L0..L5);profile = 某个 TA 的当前等级;weightsPermille = 加权覆盖率权重,三档共用,四项之和必须恰为 1000。等级与权重全整数。真实能力评估需真实团队环境,命令行不可复现(X)。",
  "levels": ["L0", "L1", "L2", "L3", "L4", "L5"],
  "weightsPermille": { "c-tool": 300, "c-pipeline": 300, "c-perf": 250, "c-comm": 150 },
  "capabilities": [
    { "id": "c-tool", "name": "工具开发", "family": "tool", "deliverable": "lint 脚本 + 报告 schema" },
    { "id": "c-pipeline", "name": "管线集成", "family": "pipeline", "deliverable": "导出预设 + 导入设置表" },
    { "id": "c-perf", "name": "性能分析", "family": "performance", "deliverable": "面数 / 骨骼 / 材质槽预算表" },
    { "id": "c-comm", "name": "跨职能沟通", "family": "interface", "deliverable": "接口矩阵 + 评审记录" }
  ],
  "tiers": [
    { "id": "ta-junior", "name": "初级 TA", "requires": { "c-tool": 2, "c-pipeline": 2, "c-perf": 1, "c-comm": 2 } },
    { "id": "ta-mid", "name": "中级 TA", "requires": { "c-tool": 3, "c-pipeline": 3, "c-perf": 3, "c-comm": 3 } },
    { "id": "ta-senior", "name": "高级 TA", "requires": { "c-tool": 4, "c-pipeline": 4, "c-perf": 4, "c-comm": 4 } }
  ],
  "profile": { "c-tool": 3, "c-pipeline": 4, "c-perf": 2, "c-comm": 4 }
}

读这份配置要抓住两点。第一,职级要求是一张表不是一个数:ta-mid 要求四项都到 3 级,而不是「平均 3 级」。第二,profile 是当前等级,不是「期望等级」——它跟要求比出来的差,才是要补的东西。

S 层:加权覆盖账

同一份配置,S 层读它算每档职级的加权覆盖率与硬缺口。本机跑 /opt/homebrew/bin/python3 sim/ta01_capability.py,真实输出:权重和 weightSumPermille=1000,profile 平均 profileAvgLevelX100=325;ta-junior 覆盖率 1000(fullyCovered=true,无缺口),ta-mid 750(缺口 c-perf),ta-senior 450(缺口 c-tool、c-perf),maxTier=ta-junior。平均 3.25 级看着够中级,但性能项只有 2 级、低于中级的 3 级要求,中级覆盖率因此掉到 750‰——这就是「平均分掩盖硬缺口」。真实能力评估需真实团队环境,命令行不可复现(X)。

#!/usr/bin/env python3
# file: sim/ta01_capability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta01_capability.py
"""TA 能力矩阵的加权覆盖账与产出物清单。

读 config/ta01_capability.json:能力项各有权重(之和必须恰为 1000),三档职级对每项
给出最低等级要求,profile 是当前等级。对每一档算:① 硬缺口(profile < require);
② 加权覆盖率(只累加达标项的权重,因权重和为 1000,其和即千分比);③ 是否满分。
核心观点:**平均分会掩盖硬缺口**——本 profile 平均 3.25 级看着够中级,但性能项只有
2 级、低于中级要求 3 级,中级覆盖率只有 750‰。产出 out/ 下 json + csv + svg,另落
canonical 摘要与 sha256 前 16 位供 P 层 TS 侧对照。
真实能力评估需真实团队环境,命令行不可复现(X)。"""
import csv
import hashlib
import json
import os

FILL = ["#3d4a57", "#64748b", "#94a3b8"]


def analyze(cfg):
    caps = cfg["capabilities"]
    order = [c["id"] for c in caps]
    w, prof = cfg["weightsPermille"], cfg["profile"]
    wsum = sum(w[c] for c in order)
    tiers, cov, gaps = [], {}, {}
    for t in cfg["tiers"]:
        g = [c for c in order if prof[c] < t["requires"][c]]
        cw = sum(w[c] for c in order if prof[c] >= t["requires"][c])
        cov[t["id"]], gaps[t["id"]] = cw, g
        tiers.append({"id": t["id"], "name": t["name"], "weightedCoveragePermille": cw,
                      "fullyCovered": cw == wsum, "gaps": g,
                      "gapDetail": [{"cap": c, "profile": prof[c], "require": t["requires"][c],
                                     "deficit": t["requires"][c] - prof[c]} for c in g]})
    fam = {}
    for c in caps:
        fam.setdefault(c["family"], []).append(c["deliverable"])
    canon = json.dumps({"tiers": {k: cov[k] for k in cov}, "gaps": {k: gaps[k] for k in gaps}},
                       separators=(",", ":"), ensure_ascii=False)
    return {"version": cfg["version"], "project": cfg["project"], "weightSumPermille": wsum,
            "capabilityCount": len(caps), "deliverableCount": len(caps),
            "profileAvgLevelX100": sum(prof[c] for c in order) * 100 // len(order),
            "tiers": tiers, "maxTier": next((t["id"] for t in tiers if t["fullyCovered"]), None),
            "deliverablesByFamily": fam, "canonical": canon,
            "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}


def write_svg(rep, path):
    """三档职级加权覆盖率条形图:条长 = 覆盖率千分比 × 0.22(整数地板除)。"""
    parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="360" height="168">']
    for i, t in enumerate(rep["tiers"]):
        y, pm = 16 + i * 42, t["weightedCoveragePermille"]
        parts.append(f'<text x="16" y="{y + 12}" fill="#94a3b8" font-family="monospace" '
                     f'font-size="11">{t["id"]} {pm}‰</text>'
                     f'<rect x="112" y="{y}" width="{pm * 220 // 1000}" height="26" rx="4" '
                     f'fill="{FILL[i]}"/>')
    parts.append(f'<text x="16" y="162" fill="#94a3b8" font-family="monospace" font-size="10">'
                 f'maxTier={rep["maxTier"]} avgX100={rep["profileAvgLevelX100"]}</text></svg>')
    open(path, "w", encoding="utf-8").write("".join(parts) + "\n")


def main():
    cfg = json.load(open("config/ta01_capability.json", encoding="utf-8"))
    rep = analyze(cfg)
    os.makedirs("out", exist_ok=True)
    json.dump(rep, open("out/ta01_capability.json", "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    with open("out/ta01_capability.csv", "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["capability", "family", "profile", "require_junior", "require_mid",
                     "require_senior", "deliverable"])
        for c in cfg["capabilities"]:
            wr.writerow([c["id"], c["family"], cfg["profile"][c["id"]]] +
                        [t["requires"][c["id"]] for t in cfg["tiers"]] + [c["deliverable"]])
    write_svg(rep, "out/ta01_capability.svg")
    print(json.dumps({"weightSumPermille": rep["weightSumPermille"],
                      "profileAvgLevelX100": rep["profileAvgLevelX100"],
                      "maxTier": rep["maxTier"],
                      "tiers": [{k: t[k] for k in ("id", "weightedCoveragePermille",
                                                   "fullyCovered", "gaps")}
                                for t in rep["tiers"]],
                      "digest": rep["digest"]}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

P 层:能力矩阵内核

下面这段是 P 层 TypeScript 内核 core/src/ta01/capability.ts。gapsFor 列出硬缺口,weightedCoverage 只累加达标项的权重,maxCoveredTier 取覆盖率为满权的最高档,avgLevelX100 则是那个会骗人的平均分。

// file: core/src/ta01/capability.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// TA 能力矩阵内核:把「职级要求 × 当前等级 × 权重」算成一份可断言的覆盖账。
//
// 关键设计:**硬缺口优先于平均分**。加权覆盖率只把「达标项」的权重加进来;
// 只要有一项低于要求,这一项就整块不计入,而不是按差值打折。这样才能拦住
// 「平均分够、但某一项硬性不达标」的假通过。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type CapFamily = "tool" | "pipeline" | "performance" | "interface";

export type Capability = {
  id: string;
  name: string;
  family: CapFamily;
  deliverable: string;
};

export type Tier = {
  id: string;
  name: string;
  requires: Record<string, number>;
};

export type Profile = Record<string, number>;
export type Weights = Record<string, number>;

/** 硬缺口:profile < require 的能力项,按 order 顺序返回。 */
export function gapsFor(tier: Tier, profile: Profile, order: string[]): string[] {
  return order.filter((c) => profile[c] < tier.requires[c]);
}

/** 加权覆盖率(‰):只累加达标项的权重。权重和恰为 1000,故其和即千分比。 */
export function weightedCoverage(
  tier: Tier, profile: Profile, order: string[], weights: Weights,
): number {
  let sum = 0;
  for (const c of order) {
    if (profile[c] >= tier.requires[c]) sum += weights[c];
  }
  return sum;
}

/** 覆盖率为满权的最高档;没有一档满分则返回 null。 */
export function maxCoveredTier(
  tiers: Tier[], profile: Profile, order: string[], weights: Weights,
): string | null {
  const full = order.reduce((a, c) => a + weights[c], 0);
  let out: string | null = null;
  for (const t of tiers) if (weightedCoverage(t, profile, order, weights) === full) out = t.id;
  return out;
}

/** 平均等级 ×100(整数地板除)——正是「掩盖硬缺口」的那个数。 */
export function avgLevelX100(profile: Profile, order: string[]): number {
  let sum = 0;
  for (const c of order) sum += profile[c];
  return Math.floor((sum * 100) / order.length);
}

/** canonical 摘要:职级键序、缺口项顺序都与 Python 侧一致。 */
export function canonical(
  tierCov: Record<string, number>, tierGaps: Record<string, string[]>, tierOrder: string[],
): string {
  const t: Record<string, number> = {};
  const g: Record<string, string[]> = {};
  for (const id of tierOrder) { t[id] = tierCov[id]; g[id] = tierGaps[id]; }
  return JSON.stringify({ tiers: t, gaps: g });
}

负例:平均分够 ≠ 达标

最危险的失败模式是假通过:用平均分判定,一个能力项差两级也能被平均掉。所以这段 P 层测试专门构造了这条负例,并验证达标项是「整块不计入」而不是「按差值打折」。

// file: core/test/ta01_capability.test.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// 能力矩阵断言:权重和必须恰为 1000、每项能力必须有可验收产出物、每档职级要求齐全;
// 再做跨语言对照——TS 侧独立复算 config/ta01_capability.json,与 Python
// (sim/ta01_capability.py)落盘的 out/ta01_capability.json 逐位比对 + digest 对照。
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 {
  gapsFor, weightedCoverage, maxCoveredTier, avgLevelX100, canonical,
} from "../src/ta01/capability.ts";
import type { Capability, Tier, Profile, Weights } from "../src/ta01/capability.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta01_capability.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta01_capability.json"), "utf8"));
const caps = cfg.capabilities as Capability[];
const tiers = cfg.tiers as Tier[];
const profile = cfg.profile as Profile;
const weights = cfg.weightsPermille as Weights;
const order = caps.map((c) => c.id);
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
const cov: Record<string, number> = {};
const gp: Record<string, string[]> = {};
for (const t of tiers) {
  cov[t.id] = weightedCoverage(t, profile, order, weights);
  gp[t.id] = gapsFor(t, profile, order);
}

test("不变量:权重和恰为 1000、每项能力有产出物、每档职级要求齐", () => {
  assert.equal(order.reduce((a, c) => a + weights[c], 0), 1000);
  for (const c of caps) {
    assert.ok(c.deliverable.length > 0, `${c.id} 缺产出物`);
    assert.ok(c.family.length > 0, `${c.id} 缺 family`);
  }
  for (const t of tiers) assert.equal(Object.keys(t.requires).length, order.length, t.id);
  assert.ok(order.every((c) => c in profile), "profile 缺能力项");
});

test("跨语言:加权覆盖率与硬缺口逐位一致(整数类)", () => {
  for (const t of tiers) {
    const row = py.tiers.find((r: { id: string }) => r.id === t.id);
    assert.equal(cov[t.id], row.weightedCoveragePermille, t.id);
    assert.deepEqual(gp[t.id], row.gaps, t.id);
  }
});

test("跨语言:maxTier、平均分、canonical 与 digest 一致", () => {
  assert.equal(maxCoveredTier(tiers, profile, order, weights), py.maxTier);
  assert.equal(avgLevelX100(profile, order), py.profileAvgLevelX100);
  const canon = canonical(cov, gp, tiers.map((t) => t.id));
  assert.equal(canon, py.canonical);
  assert.equal(sha16(canon), py.digest);
});

test("负例:平均分掩盖硬缺口,加权覆盖率必须拦住", () => {
  // 朴素判定:平均等级 >= 3 就算够中级 —— 本 profile 平均 3.25,会被判「够」
  assert.equal(avgLevelX100(profile, order) >= 300, true);
  const mid = tiers.find((t) => t.id === "ta-mid") as Tier;
  assert.deepEqual(gapsFor(mid, profile, order), ["c-perf"], "中级应在性能项上有硬缺口");
  assert.ok(cov["ta-mid"] < 1000, "有硬缺口却拿满权");
  assert.equal(maxCoveredTier(tiers, profile, order, weights), "ta-junior");
});

test("负例:达标项缺一即整块不计入,而不是按差值打折", () => {
  const t: Tier = { id: "x", name: "x", requires: { a: 3, b: 3 } };
  const w: Weights = { a: 500, b: 500 };
  assert.equal(weightedCoverage(t, { a: 3, b: 2 }, ["a", "b"], w), 500);
  assert.equal(weightedCoverage(t, { a: 2, b: 2 }, ["a", "b"], w), 0);
  assert.deepEqual(gapsFor(t, { a: 2, b: 2 }, ["a", "b"]), ["a", "b"]);
});

在本机跑 node --test 'core/test/ta01_*.test.ts',真实输出(同机相对量级,耗时不可移植):

✔ 不变量:权重和恰为 1000、每项能力有产出物、每档职级要求齐 (0.524042ms)
✔ 跨语言:加权覆盖率与硬缺口逐位一致(整数类) (0.434083ms)
✔ 跨语言:maxTier、平均分、canonical 与 digest 一致 (0.3835ms)
✔ 负例:平均分掩盖硬缺口,加权覆盖率必须拦住 (0.092208ms)
✔ 负例:达标项缺一即整块不计入,而不是按差值打折 (0.074417ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

两条负例守住同一件事的两面:平均分掩盖硬缺口 守住「判定口径必须按项不按均值」,整块不计入 守住「缺口不能被差值稀释」。只要有一条从「拦下」变成「放行」,能力矩阵就退化成了好看的自评表。

三类工作对应四项能力,但权重并不均分——工具与管线各占 300‰,因为它们的产出物最容易被别人复用;沟通只占 150‰,却决定了另外三项能不能落地。下一节把「能力」变成「接口」:TA 与美术、程序、策划之间,到底有哪些必须写成契约的往返。

阅读导航:上一节:1.1 技术美术的三类工作:工具、管线、性能 · 下一节:1.3 TA 与美术、程序、策划的接口 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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