3.2 节的均值能排序,却排不出「你更看重什么」。这一节把决策偏好显式地写进权重,让排名可调、可争、可验证。定位再说一次:站点里 小游戏创作平台专题 那 24 篇是做小游戏创作平台(SaaS 口径,编辑器与平台商业),本节则是为单款游戏做品类选型决策——用一套可调参、可断言、能回答「排名稳不稳」的评分模型,替立项人做取舍。这一节是本章的核心,也是卷一里第一个把 S 层仿真与 B 层基准并排给出的地方。
从分数到决策:引入权重
加权评分模型只有一行数学:
total = sum(score[d] * weight[d]) // 100 (整数地板除,权重合计 100)
它比均值多了一个东西:权重。均值等价于「8 条维度各占 12.5%」,是一种隐含的、你从没明说过的偏好;加权则把这个偏好摆到桌面上。权重怎么定,取决于你的团队与目标——一个擅长快速出原型的小团队,会把 devCost 压重;一个已经跑通买量的团队,会把 uaDifficulty 压重。
关键工程约束有三条,都在 config 与脚本里落实了:
- 权重是整数百分比,合计 100,config 里不出现任何浮点;
- 除法一律整数地板除,保证 Python / TS / 浏览器三处消费同一份 config 得到同一组数字;
- 权重可调:改
config/ch03_genres.json的weights即可,分类与分数都不用动。
加权评分模型(S 层)
下面这段是 S 层仿真脚本,读 config/ch03_genres.json(品类 / 维度 / 权重)与 config/ch03_matrix.json(分数),算加权总分与排名,再对每个维度单独把权重 ±20% 重算,观察排名变动,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/ch03_scoring.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch03_scoring.py --config config/ --out out/
"""品类选择加权评分模型 + 单权重 ±20% 敏感度分析。
读 config/ch03_genres.json(品类 / 维度 / 权重)与 config/ch03_matrix.json(分数):
total = sum(score[d] * weight[d]) // 100 (整数地板除,权重合计 100)
对每个维度单独把它的权重 ±20%(w*120//100 / w*80//100),其余不变,重算总分与排名,
观察哪些品类的名次会变。产出 JSON + CSV + SVG。
排名规则:(总分降序, 品类 id 升序)。全整数,无浮点;权重可调,模型完全由 config 驱动。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(p: str) -> dict:
with open(p, encoding="utf-8") as f:
return json.load(f)
def totals(gs: list[str], ds: list[str], sc: dict, wt: dict) -> dict:
return {g: sum(sc[g][d] * wt[d] for d in ds) // 100 for g in gs}
def ranks(t: dict) -> dict:
return {g: i + 1 for i, (g, _) in enumerate(sorted(t.items(), key=lambda kv: (-kv[1], kv[0])))}
def svg(path: str, base: dict, lo: dict, hi: dict, order: list[str], cn: dict) -> int:
"""基线点 + 扰动区间(min..max)水平条。"""
W, H, PAD, gap = 680, 420, 130, 12
n = len(order)
vmin, vmax = min(lo.values()), max(hi.values())
span = max(vmax - vmin, 1)
pw, ph = W - PAD - 60, H - 80
bh = (ph - gap * (n + 1)) / n
sx = lambda v: PAD + (v - vmin) / span * pw
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "40", "y": "26", "font-size": "15",
"font-family": "sans-serif"}).text = (
"加权总分基线(圆点)与单权重 ±20% 扰动区间(横条),按基线降序")
for i, g in enumerate(order):
y = 40 + gap + i * (bh + gap)
cy = y + bh / 2
x0, x1 = sx(lo[g]), sx(hi[g])
ET.SubElement(root, "rect", {"x": f"{x0:.1f}", "y": f"{y:.1f}",
"width": f"{max(x1 - x0, 1):.1f}", "height": f"{bh:.1f}",
"fill": "#bae6fd"})
ET.SubElement(root, "circle", {"cx": f"{sx(base[g]):.1f}", "cy": f"{cy:.1f}", "r": "5",
"fill": "#0369a1"})
ET.SubElement(root, "text", {"x": str(PAD - 10), "y": f"{cy + 4:.1f}", "font-size": "12",
"text-anchor": "end", "font-family": "sans-serif"}).text = cn[g]
ET.SubElement(root, "text", {"x": f"{x1 + 8:.1f}", "y": f"{cy + 4:.1f}",
"font-size": "11", "font-family": "sans-serif"}).text = str(base[g])
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
gc = load(os.path.join(args.config, "ch03_genres.json"))
mc = load(os.path.join(args.config, "ch03_matrix.json"))
gs = [g["id"] for g in gc["genres"]]
cn = {g["id"]: g["cn"] for g in gc["genres"]}
ds, sc, wt = mc["dimensions"], mc["scores"], dict(gc["weights"])
base = totals(gs, ds, sc, wt)
br = ranks(base)
order = sorted(gs, key=lambda g: (-base[g], g))
rows, lo, hi, chg, top1 = [], {g: base[g] for g in gs}, {g: base[g] for g in gs}, {g: 0 for g in gs}, set()
for d in ds:
for pct, tag in ((120, "+20%"), (80, "-20%")):
w2 = dict(wt)
w2[d] = (wt[d] * pct) // 100
t = totals(gs, ds, sc, w2)
rk = ranks(t)
top1.add(min(rk, key=lambda g: rk[g]))
for g in gs:
chg[g] += 1 if rk[g] != br[g] else 0
lo[g], hi[g] = min(lo[g], t[g]), max(hi[g], t[g])
rows.append({"scenario": f"{d}{tag}", "dim": d, "pct": pct, "genre": g,
"total": t[g], "rank": rk[g]})
with open(os.path.join(args.out, "ch03_scoring.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["scenario", "dim", "pct", "genre", "total", "rank"])
for r in rows:
w.writerow([r["scenario"], r["dim"], r["pct"], r["genre"], r["total"], r["rank"]])
svgb = svg(os.path.join(args.out, "ch03_scoring.svg"), base, lo, hi, order, cn)
mx = max(chg.values())
report = {
"version": 1, "source": "sim/ch03_scoring.py", "weights": wt,
"baseline": [{"genre": g, "cn": cn[g], "total": base[g], "rank": br[g]} for g in order],
"scenarioCount": len(rows) // len(gs), "top1Set": sorted(top1), "rankChanges": chg,
"mostSensitive": sorted(g for g in gs if chg[g] == mx),
"range": {g: {"lo": lo[g], "hi": hi[g], "span": hi[g] - lo[g]} for g in order},
"csv": os.path.join(args.out, "ch03_scoring.csv"),
"svg": os.path.join(args.out, "ch03_scoring.svg"), "svgBytes": svgb,
}
with open(os.path.join(args.out, "ch03_scoring.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"baselineRank": [(cn[g], base[g]) for g in order], "top1Set": sorted(top1),
"rankChanges": {cn[g]: chg[g] for g in order},
"mostSensitive": [cn[g] for g in report["mostSensitive"]],
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
脚本的敏感度设计是单变量扰动:一次只动一条维度的权重,其余保持不变。这比「同时随机扰动所有权重」更容易归因——排名变了,你能直接说是哪条维度造成的。
实跑结果:基线排名
执行 /opt/homebrew/bin/python3 sim/ch03_scoring.py --config config/ --out out/,得到基线排名(降序,同机相对量级,绝对数值不可移植):
放置 67 · 合成 66 · 塔防 62 · 闯关 61 · 益智 60 · IO 59 · 模拟经营 58 · 超休闲 54
和 3.2 的均值排名相比,「放置 / 合成」仍居前两位,但中段次序变了:均值里塔防(63)排在闯关(62)之前,加权后依然是塔防(62)压闯关(61),但两者的差距被权重放大了——这正是权重的意义。基线下 Top1 唯一,是「放置」。
敏感度结论:谁对权重最敏感
16 个扰动场景(8 维度 × ±20%)里,各品类的排名变动次数如下:
塔防 9 · 闯关 9 · 益智 2 · IO 2 · 放置 0 · 合成 0 · 模拟经营 0 · 超休闲 0
结论可以直接读出来:
- 最敏感的是塔防与闯关,在 16 个场景里有 9 个名次发生变化,占了一半以上。它们的总分(62 / 61)只差 1 分,任何一条维度的权重小幅变动,都足以让二者互换——这是典型的近并列敏感区。
- 最稳的是放置、合成、模拟经营、超休闲,16 个场景里名次一次没变。放置与合成领先优势明显(67 / 66),模拟经营与超休闲垫底(58 / 54),离邻居都足够远。
- 榜首始终是放置(
top1Set只有一项)。这说明在本套权重下,「选放置」这个结论对权重不敏感,可以放心。
换句话说:排名稳不稳,取决于该品类与邻居的分差,而不是它的绝对分。绝对分高的品类,如果与邻居贴得近,一样会敏感。
排名稳定性基准(B 层)
敏感度分析只看 ±20% 一档。为了把「稳不稳」做成可复用的基准,下面这段是 B 层脚本:它把扰动网格加密到每维度 ±10% 与 ±20%(共 8×4 = 32 场景 + 1 基线),统计每个品类的排名跨度,并给出软告警——注意它是基准,无论多少告警退出码恒为 0。
#!/usr/bin/env python3
# file: sim/ch03_stability.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch03_stability.py --config config/ --out out/
"""品类评分「排名稳定性」基准(软告警,不硬失败)。
在 ch03_scoring.py 的单权重 ±20% 基础上加密扰动网格:每个维度单独 ±10% 与 ±20%,
共 8×4 = 32 个场景 + 1 个基线。对每个品类统计它在所有场景里出现过的排名集合、
排名跨度(max-min);再统计 Top1 / Top3 的稳定情况。
判据(软):
- 排名跨度 >= 1(扰动下换过名次)记为「对权重敏感」并告警;
- Top1 出现多于 1 个品类记为「榜首不稳」告警。
无论多少告警,退出码恒为 0——这是基准,不是门禁。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import json
import os
SPAN_WARN = 1
def load(p: str) -> dict:
with open(p, encoding="utf-8") as f:
return json.load(f)
def totals(gs: list[str], ds: list[str], sc: dict, wt: dict) -> dict:
return {g: sum(sc[g][d] * wt[d] for d in ds) // 100 for g in gs}
def ranks(t: dict) -> dict:
return {g: i + 1 for i, (g, _) in enumerate(sorted(t.items(), key=lambda kv: (-kv[1], kv[0])))}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
gc = load(os.path.join(args.config, "ch03_genres.json"))
mc = load(os.path.join(args.config, "ch03_matrix.json"))
gs = [g["id"] for g in gc["genres"]]
cn = {g["id"]: g["cn"] for g in gc["genres"]}
ds, sc, wt = mc["dimensions"], mc["scores"], dict(gc["weights"])
base = totals(gs, ds, sc, wt)
br = ranks(base)
seen = {g: {br[g]} for g in gs}
top1, top3, n = set(), set(), 1
for d in ds:
for pct in (110, 90, 120, 80):
w2 = dict(wt)
w2[d] = (wt[d] * pct) // 100
rk = ranks(totals(gs, ds, sc, w2))
for g in gs:
seen[g].add(rk[g])
top1.add(min(rk, key=lambda g: rk[g]))
top3 |= {g for g, r in rk.items() if r <= 3}
n += 1
per, unstable = {}, []
for g in gs:
lo, hi = min(seen[g]), max(seen[g])
per[g] = {"cn": cn[g], "baseRank": br[g], "minRank": lo, "maxRank": hi,
"span": hi - lo, "ranks": sorted(seen[g])}
if hi - lo >= SPAN_WARN:
unstable.append(g)
warnings = []
if len(top1) > 1:
warnings.append(f"榜首不稳:扰动下 Top1 在 {sorted(cn[g] for g in top1)} 之间切换")
for g in unstable:
warnings.append(f"{cn[g]} 排名跨度 {per[g]['span']}({per[g]['minRank']}–{per[g]['maxRank']}),对权重敏感")
report = {
"version": 1, "source": "sim/ch03_stability.py", "scenarioCount": n,
"spanWarnThreshold": SPAN_WARN,
"baseline": [{"genre": g, "cn": cn[g], "total": base[g], "rank": br[g]}
for g in sorted(gs, key=lambda x: br[x])],
"top1Set": sorted(top1), "top3Set": sorted(top3), "perGenre": per,
"unstable": unstable, "warnings": warnings,
"verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条",
}
with open(os.path.join(args.out, "ch03_stability.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"scenarioCount": n, "top1Set": report["top1Set"], "top3Set": report["top3Set"],
"spanByGenre": {cn[g]: per[g]["span"] for g in gs},
"verdict": report["verdict"], "warnings": warnings,
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
稳定性结论与决策含义
执行 /opt/homebrew/bin/python3 sim/ch03_stability.py --config config/ --out out/,结果如下(退出码 0):
scenarioCount: 33
top1Set: ["idle"]
top3Set: ["idle", "level", "merge", "tower-defense"]
spanByGenre: {超休闲 0, 合成 0, 放置 0, 闯关 1, IO 1, 模拟经营 0, 塔防 1, 益智 1}
verdict: "WARN(soft) 4 条"
三条可操作的结论:
- 榜首锁定,Top3 稳定:33 个场景里 Top1 始终是放置;Top3 只在 {放置、合成、塔防、闯关} 四个里浮动。要「稳选」,就在这四个里挑。
- 中段会小幅摇摆:闯关、IO、塔防、益智的排名跨度都是 1——它们会与紧邻的品类互换,但不会跳到很远。跨度 1 意味着「选谁都行,但别指望排名给你背书」。
- 两端完全不动:放置、合成、模拟经营、超休闲的跨度都是 0。领先的领先、垫底的垫底,权重怎么调都改不了。
这正是「软告警」的用法:4 条告警不是说模型坏了,而是说「有四个品类的名次不值得当决策依据」。模型给出的排名,稳的部分可以采信,抖的部分要回到业务判断。
小结
本节把 3.2 的中性分数升级成可调权重的加权评分,并用敏感度分析与排名稳定性基准回答了本章最实用的问题:哪些结论可以信、哪些不能。在本套权重下,「放置最优」稳如泰山,「塔防 / 闯关 / 益智 / IO 的精确次序」则随权重浮动。模型完全由 config/ 驱动、全整数、可复现,换个团队改一次权重就能重跑一遍。下一章转入平台规则与设计约束,看包体与首包如何反向决定玩法裁剪。
阅读导航:上一节:品类的商业特征矩阵 · 下一节:主包与分包体积预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。