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 引擎:什么时候值得自己写 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。