1.2 选型评分模型与敏感度分析

给八条引擎 × 八条选择维度配上可调权重,算出加权总分与排名,再做单权重 ±20% 敏感度分析与 33 个场景的排名稳定性基准:LayaAir 与 Cocos Creator 总分只差 1 分、榜首随权重切换,Defold 与 Phaser 名次纹丝不动,回答哪些选型结论可以信、哪些不能。

1.1 节的能力覆盖数能排序,却排不出「你更看重什么」:Cocos Creator 和 LayaAir 都是 8 条全内置,覆盖数打平,但一个生态强、一个包体小,怎么选取决于你的团队。这一节把决策偏好显式写进权重,让排名可调、可争、可验证。定位再说一次:本站那篇小程序小游戏开发综述 给的是引擎选型、性能、变现的定性综述,本节给的是可跑的加权评分与敏感度模型——用一套可调参、可断言、能回答「排名稳不稳」的模型,替技术负责人做取舍;本站的 Phaser 工程实操系列 是在 Phaser 里写游戏,本节只在选型层面把它和其余七个引擎横向对比。这一节是本章核心,也是卷二里第一个把 S 层仿真与 B 层基准并排给出的地方。

从覆盖数到加权总分:引入权重

1.1 的覆盖数是一个纯计数:内置了几条能力轴。它有两个盲点。第一,它把八条轴一视同仁——但「包体可控度」和「长期维护」对一支小团队的分量显然不同。第二,它只有 0/1,没有程度:Cocos 和 LayaAir 都内置编辑器,可两者的编辑器成熟度不在一个量级。

修掉这两个盲点要两步:先给每个引擎在每条维度上打一个 0–100 的程度分,再给每条维度配一个整数权重。加权评分模型只有一行数学:

total = sum(score[e][d] * weight[d]) // 100      (整数地板除,权重合计 100)

它比覆盖数多了一个东西:权重。覆盖数等价于「八条轴各占 12.5%」,是一种隐含的、你从没明说过的偏好;加权则把这个偏好摆到桌面上。权重怎么定,取决于你的团队与目标——一支擅长快速出原型的小团队会把 learningCost 压重,一支已经跑通买量的团队会把 platformFit 压重。

关键工程约束有三条,都在 config 与脚本里落实了:

  • 权重是整数百分比,合计 100,config 里不出现任何浮点;
  • 除法一律整数地板除,保证 Python / TS / 浏览器三处消费同一份 config 得到同一组数字;
  • 权重可调:改 config/e01_engines.json 的 weights 即可,引擎清单与分数都不用动。

分数存在 config/e01_matrix.json 里,是设计假设口径的有利度分,不是任何引擎后台的实测;它对应的引擎特性本身是公开可观察信息(观察时点 2026-10,与 1.1 的格局表同源)。这一点必须反复强调:分数和权重都是你的假设,模型的价值不在于给出一个「正确答案」,而在于把假设的后果算给你看。

加权评分模型(S 层)

下面这段是 S 层仿真脚本,读 config/e01_engines.json(引擎 / 维度 / 权重)与 config/e01_matrix.json(引擎 × 维度分数),算加权总分与排名,再对每条维度单独把权重 ±20% 重算,观察排名变动,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/e01_selection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e01_selection.py --config config/ --out out/
"""引擎选型加权评分模型 + 单权重 ±20% 敏感度分析。

读 config/e01_engines.json(引擎 / 维度 / 权重)与 config/e01_matrix.json(引擎 × 维度分数):
    total = sum(score[e][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(es: list[str], ds: list[str], sc: dict, wt: dict) -> dict:
    return {e: sum(sc[e][d] * wt[d] for d in ds) // 100 for e in es}


def ranks(t: dict) -> dict:
    return {e: i + 1 for i, (e, _) 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, 150, 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, e in enumerate(order):
        y = 40 + gap + i * (bh + gap)
        cy = y + bh / 2
        x0, x1 = sx(lo[e]), sx(hi[e])
        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[e]):.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[e]
        ET.SubElement(root, "text", {"x": f"{x1 + 8:.1f}", "y": f"{cy + 4:.1f}",
                                     "font-size": "11", "font-family": "sans-serif"}).text = str(base[e])
    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, "e01_engines.json"))
    mc = load(os.path.join(args.config, "e01_matrix.json"))
    es = [e["id"] for e in gc["engines"]]
    cn = {e["id"]: e["cn"] for e in gc["engines"]}
    ds, sc, wt = mc["dimensions"], mc["scores"], dict(gc["weights"])

    base = totals(es, ds, sc, wt)
    br = ranks(base)
    order = sorted(es, key=lambda e: (-base[e], e))

    rows, lo, hi, chg, top1 = [], {e: base[e] for e in es}, {e: base[e] for e in es}, {e: 0 for e in es}, set()
    for d in ds:
        for pct, tag in ((120, "+20%"), (80, "-20%")):
            w2 = dict(wt)
            w2[d] = (wt[d] * pct) // 100
            t = totals(es, ds, sc, w2)
            rk = ranks(t)
            top1.add(min(rk, key=lambda e: rk[e]))
            for e in es:
                chg[e] += 1 if rk[e] != br[e] else 0
                lo[e], hi[e] = min(lo[e], t[e]), max(hi[e], t[e])
                rows.append({"scenario": f"{d}{tag}", "dim": d, "pct": pct, "engine": e,
                             "total": t[e], "rank": rk[e]})

    with open(os.path.join(args.out, "e01_selection.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["scenario", "dim", "pct", "engine", "total", "rank"])
        for r in rows:
            w.writerow([r["scenario"], r["dim"], r["pct"], r["engine"], r["total"], r["rank"]])
    svgb = svg(os.path.join(args.out, "e01_selection.svg"), base, lo, hi, order, cn)

    mx = max(chg.values())
    report = {
        "version": 1, "source": "sim/e01_selection.py", "weights": wt,
        "baseline": [{"engine": e, "cn": cn[e], "total": base[e], "rank": br[e]} for e in order],
        "scenarioCount": len(rows) // len(es), "top1Set": sorted(top1), "rankChanges": chg,
        "mostSensitive": sorted(e for e in es if chg[e] == mx),
        "range": {e: {"lo": lo[e], "hi": hi[e], "span": hi[e] - lo[e]} for e in order},
        "csv": os.path.join(args.out, "e01_selection.csv"),
        "svg": os.path.join(args.out, "e01_selection.svg"), "svgBytes": svgb,
    }
    with open(os.path.join(args.out, "e01_selection.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({
        "baselineRank": [(cn[e], base[e]) for e in order], "top1Set": sorted(top1),
        "rankChanges": {cn[e]: chg[e] for e in order},
        "mostSensitive": [cn[e] for e in report["mostSensitive"]],
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

脚本的敏感度设计是单变量扰动:一次只动一条维度的权重,其余保持不变。这比「同时随机扰动所有权重」更容易归因——排名变了,你能直接说是哪条维度造成的。

实跑结果:基线排名

执行 /opt/homebrew/bin/python3 sim/e01_selection.py --config config/ --out out/,得到基线排名(降序,同机相对量级,绝对数值不可移植):

LayaAir 77 · Cocos Creator 76 · PixiJS 76 · Defold 74 · Phaser 72 · 原生小程序 Canvas API 68 · Egret 67 · Unity WebGL 50

读这个排名的第一件事,是看差距而不是看名次。榜首 LayaAir 77,第二、三名 Cocos Creator 与 PixiJS 并列 76——前三名挤在 1 分之内。这不是「LayaAir 更好」,而是「在本套权重下这三条路线几乎打平」。真正拉开距离的是两端:Defold 与 Phaser 处在 72–74 的中段,原生 Canvas 与 Egret 掉到 67–68,而 Unity WebGL 以 50 分断层垫底——它的包体与首包分太低(26 / 24),任何权重都救不回来。

敏感度结论:谁对权重最敏感

16 个扰动场景(8 维度 × ±20%)里,各引擎的排名变动次数如下:

Cocos Creator 10 · PixiJS 8 · LayaAir 2 · 原生小程序 Canvas API 2 · Egret 2 · Defold 0 · Phaser 0 · Unity WebGL 0

结论可以直接读出来:

  • 最敏感的是 Cocos Creator,16 个场景里有 10 个名次发生变化,占了近三分之二。原因不难理解:它与 LayaAir、PixiJS 的总分只差 0–1 分,任何一条维度的权重小幅变动,都足以让三者互换。这是典型的近并列敏感区。
  • 次敏感的是 PixiJS(8 次):它的强项高度集中在包体与首包(88 / 84),一旦把权重从「包体优先」往「生态优先」挪,它就会往下掉。
  • 最稳的是 Defold、Phaser、Unity WebGL,16 个场景里名次一次没变。Defold 与 Phaser 稳在中段,Unity 稳在垫底——离邻居足够远,怎么调权重都动不了。
  • 榜首不唯一:top1Set 有 Cocos Creator 与 LayaAir 两项。这说明「选 Cocos 还是 LayaAir」这个结论对权重敏感,不能只靠这一张表拍板。

换句话说:排名稳不稳,取决于该引擎与邻居的分差,而不是它的绝对分。Cocos 绝对分很高(76),却因为与邻居贴得太近而最敏感;Unity 绝对分很低(50),却因为断层而纹丝不动。

排名稳定性基准(B 层)

敏感度分析只看 ±20% 一档。为了把「稳不稳」做成可复用的基准,下面这段是 B 层脚本:它把扰动网格加密到每维度 ±10% 与 ±20%(共 8×4 = 32 场景 + 1 基线),统计每个引擎的排名跨度,并给出软告警——注意它是基准,无论多少告警退出码恒为 0。

#!/usr/bin/env python3
# file: sim/e01_stability.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e01_stability.py --config config/ --out out/
"""引擎选型「排名稳定性」基准(软告警,不硬失败)。

在 e01_selection.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(es: list[str], ds: list[str], sc: dict, wt: dict) -> dict:
    return {e: sum(sc[e][d] * wt[d] for d in ds) // 100 for e in es}


def ranks(t: dict) -> dict:
    return {e: i + 1 for i, (e, _) 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, "e01_engines.json"))
    mc = load(os.path.join(args.config, "e01_matrix.json"))
    es = [e["id"] for e in gc["engines"]]
    cn = {e["id"]: e["cn"] for e in gc["engines"]}
    ds, sc, wt = mc["dimensions"], mc["scores"], dict(gc["weights"])

    base = totals(es, ds, sc, wt)
    br = ranks(base)
    seen = {e: {br[e]} for e in es}
    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(es, ds, sc, w2))
            for e in es:
                seen[e].add(rk[e])
            top1.add(min(rk, key=lambda e: rk[e]))
            top3 |= {e for e, r in rk.items() if r <= 3}
            n += 1

    per, unstable = {}, []
    for e in es:
        lo, hi = min(seen[e]), max(seen[e])
        per[e] = {"cn": cn[e], "baseRank": br[e], "minRank": lo, "maxRank": hi,
                  "span": hi - lo, "ranks": sorted(seen[e])}
        if hi - lo >= SPAN_WARN:
            unstable.append(e)

    warnings = []
    if len(top1) > 1:
        warnings.append(f"榜首不稳:扰动下 Top1 在 {sorted(cn[e] for e in top1)} 之间切换")
    for e in unstable:
        warnings.append(f"{cn[e]} 排名跨度 {per[e]['span']}({per[e]['minRank']}–{per[e]['maxRank']}),对权重敏感")

    report = {
        "version": 1, "source": "sim/e01_stability.py", "scenarioCount": n,
        "spanWarnThreshold": SPAN_WARN,
        "baseline": [{"engine": e, "cn": cn[e], "total": base[e], "rank": br[e]}
                     for e in sorted(es, key=lambda x: br[x])],
        "top1Set": sorted(top1), "top3Set": sorted(top3), "perEngine": per,
        "unstable": unstable, "warnings": warnings,
        "verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条",
    }
    with open(os.path.join(args.out, "e01_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"],
        "spanByEngine": {cn[e]: per[e]["span"] for e in es},
        "verdict": report["verdict"], "warnings": warnings,
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

稳定性结论与决策含义

执行 /opt/homebrew/bin/python3 sim/e01_stability.py --config config/ --out out/,结果如下(退出码 0):

scenarioCount: 33
top1Set:  ["cocos", "laya"]
top3Set:  ["cocos", "laya", "pixi"]
spanByEngine: {Cocos Creator: 2, LayaAir: 1, Egret: 1, Defold: 0, Phaser: 0, PixiJS: 1, Unity WebGL: 0, 原生小程序 Canvas API: 1}
verdict: "WARN(soft) 6 条"

三条可操作的结论:

  • 榜首会切换,但 Top3 锁定:33 个场景里 Top1 在 Cocos Creator 与 LayaAir 之间切换;Top3 始终在 {Cocos Creator、LayaAir、PixiJS} 三个里。要「稳选」,就在这三个里挑——它们在不同偏好下都能进前三。
  • 中段会小幅摇摆:Cocos(跨度 2)、LayaAir、PixiJS、Egret、原生 Canvas(跨度各 1)都会与紧邻的引擎互换,但不会跳到很远。跨度 1 意味着「选谁都行,但别指望排名给你背书」。
  • 两端完全不动:Defold、Phaser、Unity WebGL 的跨度都是 0。中段的稳在中段,垫底的稳在垫底,权重怎么调都改不了。

这正是「软告警」的用法:6 条告警不是说模型坏了,而是说「有几个引擎的名次不值得当决策依据」。模型给出的排名,稳的部分可以采信(Unity 不该选、Top3 里挑),抖的部分要回到业务判断(前三名的精确次序没有意义)。

小结

本节把 1.1 的中性覆盖数升级成可调权重的加权评分,并用敏感度分析与排名稳定性基准回答了本章最实用的问题:哪些选型结论可以信、哪些不能。在本套权重下,「Unity WebGL 断层垫底」与「Defold / Phaser 稳居中段」两条结论对权重不敏感,可以放心;「Cocos 与 LayaAir 谁第一」则随权重切换,要回到团队判断。模型完全由 config/ 驱动、全整数、可复现,换个团队改一次权重就能重跑一遍。下一节再往前走一步:如果连引擎都不想要呢?自研一条运行时,什么条件下才划算。

阅读导航:上一节:小游戏引擎格局 · 下一节:自研 vs 引擎:什么时候值得自己写 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制