站内 24 篇的小游戏创作平台专题 关心的是平台怎么支持分包(编辑器架构、开发者生态);本节继续做《合成小岛》这一个项目,核算它自己的构建产物与包体预算。2.1 定下模块边界、2.2 定下分包方案,还差最后一步:这份方案占多少体积、离上限有多近——资源体积在打包前一直在变,本节把「占比核算」与「越线概率」都做成可复算的。
2.3 构建产物与包体预算
2.3.1 构建产物清单:把模块与资源并到一张表
2.1 的模块表记的是代码,2.2 的资源清单记的是资源,但真实构建产物是两者的合并。下面这份是 D 层配置:一份扁平的构建产物清单,每一项记下它属于哪个包、体积、以及类型(代码 / 美术 / 音频 / 配置)。注意 main 里的十项代码模块合计 840KB——正是 2.1 里那个数字。
{
"project": "merge-island",
"observation": "包体限额为公开可观察机制,观察时点 2026-10;数值为可配置参数,以平台最新文档为准",
"limitsKB": {"mainMaxKB": 4096, "subSingleMaxKB": 2048, "subCountMax": 20},
"internalRedlineKB": {"mainMaxKB": 3456, "note": "团队内部打包红线,比平台上限低 640KB,给后续版本内容留位置"},
"packages": {
"main": "首屏:代码内核 + 首屏 UI + 合成台图集 + 背景音",
"sub_decor": "分包:岛屿装扮图集(基础)",
"sub_decor_2": "分包:岛屿装扮图集(扩展)",
"sub_levels": "分包:关卡配置与缩略图",
"sub_merge": "分包:合成特效与音效",
"sub_audio": "分包:岛屿音乐与音效包"
},
"items": [
{"id": "core", "pkg": "main", "kb": 96, "kind": "code"},
{"id": "platform", "pkg": "main", "kb": 64, "kind": "code"},
{"id": "data", "pkg": "main", "kb": 48, "kind": "code"},
{"id": "assets", "pkg": "main", "kb": 88, "kind": "code"},
{"id": "render", "pkg": "main", "kb": 112, "kind": "code"},
{"id": "input", "pkg": "main", "kb": 56, "kind": "code"},
{"id": "audio", "pkg": "main", "kb": 40, "kind": "code"},
{"id": "ui", "pkg": "main", "kb": 132, "kind": "code"},
{"id": "scenes", "pkg": "main", "kb": 168, "kind": "code"},
{"id": "boot", "pkg": "main", "kb": 36, "kind": "code"},
{"id": "ui_atlas", "pkg": "main", "kb": 480, "kind": "art"},
{"id": "i18n_zh", "pkg": "main", "kb": 40, "kind": "config"},
{"id": "merge_tiles", "pkg": "main", "kb": 1240, "kind": "art"},
{"id": "bgm_main", "pkg": "main", "kb": 620, "kind": "audio"},
{"id": "decor_atlas", "pkg": "sub_decor", "kb": 1380, "kind": "art"},
{"id": "decor_extra", "pkg": "sub_decor_2", "kb": 820, "kind": "art"},
{"id": "level_config", "pkg": "sub_levels", "kb": 90, "kind": "config"},
{"id": "level_thumbs", "pkg": "sub_levels", "kb": 640, "kind": "art"},
{"id": "merge_fx", "pkg": "sub_merge", "kb": 700, "kind": "art"},
{"id": "merge_sfx", "pkg": "sub_merge", "kb": 220, "kind": "audio"},
{"id": "island_bgm", "pkg": "sub_audio", "kb": 720, "kind": "audio"},
{"id": "sfx_pack", "pkg": "sub_audio", "kb": 340, "kind": "audio"}
],
"perturb": {"trials": 400, "seed": 20261012, "rngMultiplier": 1103515245, "rngIncrement": 12345, "spreadPermille": 150, "warnOverLimitPermille": 30}
}
这里多了一条 internalRedlineKB:平台上限是 4096KB,但团队内部把打包红线设在 3456KB,比平台低 640KB,给后续版本的内容更新留位置。平台限额是硬约束,内部红线是自我约束——两条都要看。
2.3.2 体积核算:各包占比与堆叠图(S 层)
下面这段是 S 层脚本。它读上面那份清单,按包聚合体积,再按类型(代码 / 美术 / 音频 / 配置)拆开算占比,产出逐包逐类型的 CSV 与一张堆叠柱状 SVG。全程整数运算。
#!/usr/bin/env python3
# file: sim/e02_build_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e02_build_budget.py --config config/ --out out/
"""构建产物体积核算(S 层)。
读 config/e02_build.json 的构建产物清单(代码模块 + 资源),按包聚合体积,再按类型
(code / art / audio / config)拆开算占比,产出逐包逐类型的 CSV 与一张堆叠柱状 SVG。
全程整数运算(体积 KB,占比千分比)。这就是「各模块/资源 → 包体占比」的可复算形式。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
KINDS = ["code", "art", "audio", "config"]
KIND_LABEL = {"code": "代码", "art": "美术", "audio": "音频", "config": "配置"}
KIND_COLOR = {"code": "#2563eb", "art": "#059669", "audio": "#d97706", "config": "#7c3aed"}
def load(d, name):
with open(os.path.join(d, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def aggregate(cfg):
"""按包聚合总量与分类型体积;保持 config 中 packages 的顺序。"""
pkgs = list(cfg["packages"].keys())
by_kind = {p: {k: 0 for k in KINDS} for p in pkgs}
total = {p: 0 for p in pkgs}
for it in cfg["items"]:
by_kind[it["pkg"]][it["kind"]] += it["kb"]
total[it["pkg"]] += it["kb"]
return pkgs, by_kind, total, sum(total.values())
def write_svg(path, pkgs, by_kind, total, title):
W, H, PAD = 760, 420, 64
ymax = max(total.values())
pw, ph = W - 2 * PAD, H - 2 * PAD
bw = pw // (len(pkgs) * 2)
p = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">',
f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
f'<text x="{PAD}" y="{PAD - 28}" font-size="14" fill="#111827">{title}</text>']
for i in range(6):
y = PAD + ph * i // 5
p.append(f'<line x1="{PAD}" y1="{y}" x2="{W - PAD}" y2="{y}" stroke="#e5e7eb"/>')
p.append(f'<text x="{PAD - 8}" y="{y + 4}" font-size="11" text-anchor="end" fill="#6b7280">{ymax - ymax * i // 5}</text>')
for idx, pkg in enumerate(pkgs):
x = PAD + pw * (2 * idx + 1) // (len(pkgs) * 2) - bw // 2
ytop = H - PAD
for k in KINDS:
kb = by_kind[pkg][k]
if kb == 0:
continue
h = ph * kb // max(1, ymax)
ytop -= h
p.append(f'<rect x="{x}" y="{ytop}" width="{bw}" height="{h}" fill="{KIND_COLOR[k]}" stroke="#ffffff"/>')
p.append(f'<text x="{x + bw // 2}" y="{H - PAD + 16}" font-size="11" text-anchor="middle" fill="#374151">{pkg}</text>')
p.append(f'<text x="{x + bw // 2}" y="{ytop - 6}" font-size="11" text-anchor="middle" fill="#111827">{total[pkg]}</text>')
p.append(f'<line x1="{PAD}" y1="{H - PAD}" x2="{W - PAD}" y2="{H - PAD}" stroke="#9ca3af"/>')
p.append(f'<line x1="{PAD}" y1="{PAD}" x2="{PAD}" y2="{H - PAD}" stroke="#9ca3af"/>')
p.append(f'<text x="{W // 2}" y="{H - 12}" font-size="12" text-anchor="middle" fill="#374151">包体体积 (KB),按类型堆叠</text>')
for i, k in enumerate(KINDS):
lx, ly = W - PAD - 200 + (i % 2) * 96, PAD + 6 + (i // 2) * 18
p.append(f'<rect x="{lx}" y="{ly - 9}" width="11" height="11" fill="{KIND_COLOR[k]}"/>')
p.append(f'<text x="{lx + 16}" y="{ly}" font-size="11" fill="#374151">{KIND_LABEL[k]}</text>')
p.append("</svg>")
svg = "\n".join(p)
with open(path, "w", encoding="utf-8") as f:
f.write(svg)
ET.fromstring(svg)
return len(svg.encode())
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config, "e02_build")
pkgs, by_kind, total, grand = aggregate(cfg)
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "e02_build_budget.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["package", "kind", "kb", "pkgKB", "pkgSharePerMille"])
for pkg in pkgs:
for k in KINDS:
if by_kind[pkg][k]:
w.writerow([pkg, k, by_kind[pkg][k], total[pkg], total[pkg] * 1000 // grand])
svg_bytes = write_svg(os.path.join(args.out, "e02_build_budget.svg"), pkgs, by_kind, total,
"《合成小岛》构建产物按包体积堆叠(KB)")
report = {
"source": "sim/e02_build_budget.py", "observation": cfg["observation"], "limitsKB": cfg["limitsKB"],
"packages": [{"pkg": p, "kb": total[p], "sharePerMille": total[p] * 1000 // grand, "byKind": by_kind[p]} for p in pkgs],
"grandKB": grand, "mainSharePerMille": total["main"] * 1000 // grand,
"mainHeadroomKB": cfg["limitsKB"]["mainMaxKB"] - total["main"], "packageCount": len(pkgs),
"csv": os.path.join(args.out, "e02_build_budget.csv"), "svg": os.path.join(args.out, "e02_build_budget.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "e02_build_budget.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"grand={grand}KB packages={len(pkgs)} main={total['main']}KB "
f"mainShare={report['mainSharePerMille']}permille headroom={report['mainHeadroomKB']}KB")
for pkg in pkgs:
parts = " ".join(f"{k}={by_kind[pkg][k]}" for k in KINDS if by_kind[pkg][k])
print(f" {pkg:>12} {total[pkg]:>5}KB share={total[pkg] * 1000 // grand:>3}permille {parts}")
print(f"svg={report['svg']} svgBytes={svg_bytes}")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
grand=8130KB packages=6 main=3220KB mainShare=396permille headroom=876KB
main 3220KB share=396permille code=840 art=1720 audio=620 config=40
sub_decor 1380KB share=169permille art=1380
sub_decor_2 820KB share=100permille art=820
sub_levels 730KB share= 89permille art=640 config=90
sub_merge 920KB share=113permille art=700 audio=220
sub_audio 1060KB share=130permille audio=1060
svg=out/e02_build_budget.svg svgBytes=3740
- 总量 8130KB,六个包,main 3220KB 占 396‰、距平台上限还有 876KB 余量,
sub_decor与sub_decor_2是同组切开的两个纯美术包;main 的构成是代码 840KB、美术 1720KB、音频 620KB、配置 40KB——美术占一半以上,merge_tiles(1240) 一张图集就比全部代码还大,这解释了为什么「压体积」先压美术。这些数字是同机相对量级,绝对数值不可移植,可移植的是「清单 → 占比 → 堆叠图」这条核算流程。
2.3.3 余量不是安全边际:扰动下的越线概率(B 层)
876KB 余量看着很安全,但那是假设体积确定时的结论。真实项目里每项体积都会浮动。下面这段是 B 层基准:把每项体积按 ±15% 抖动,跑 400 轮,统计 main 越过内部红线(3456KB)的比例。扰动用可播种整数 LCG,同一 seed 必得同一结果。
#!/usr/bin/env python3
# file: sim/e02_budget_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e02_budget_bench.py --config config/ --out out/
"""资源体积扰动下的超限概率(B 层,软告警不硬失败)。
真实项目里资源体积在打包前一直会变:图集重新切、音频换格式、代码加功能。与其假设体积
是确定的,不如把每项体积按 ±spreadPermille 抖动,跑多轮看「main 超限」出现的比例。扰动
用可播种整数 LCG,同一 seed 必得同一结果,可复现。产出 out/e02_budget_bench.json。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
def load(d, name):
with open(os.path.join(d, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def lcg(seed, mul, inc):
s = seed & 0xFFFFFFFF
def nxt():
nonlocal s
s = (s * mul + inc) & 0xFFFFFFFF
return s
return nxt
def perturb(kb, spread, rng):
"""把 kb 按 [-spread, +spread] 千分比抖动,整数地板除。"""
return kb * (1000 + (rng() % (2 * spread + 1) - spread)) // 1000
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
ap.add_argument("--trials", type=int, default=0)
args = ap.parse_args()
cfg = load(args.config, "e02_build")
lim, pt = cfg["limitsKB"], cfg["perturb"]
redline = cfg["internalRedlineKB"]["mainMaxKB"]
trials = args.trials or pt["trials"]
rng = lcg(pt["seed"], pt["rngMultiplier"], pt["rngIncrement"])
main_items = [it for it in cfg["items"] if it["pkg"] == "main"]
sub_pkgs = {}
for it in cfg["items"]:
if it["pkg"] != "main":
sub_pkgs.setdefault(it["pkg"], []).append(it)
main_vals, main_over, main_over_platform = [], 0, 0
sub_over = {p: 0 for p in sub_pkgs}
for _ in range(trials):
m = sum(perturb(it["kb"], pt["spreadPermille"], rng) for it in main_items)
main_vals.append(m)
main_over += m > redline
main_over_platform += m > lim["mainMaxKB"]
for p, items in sub_pkgs.items():
s = sum(perturb(it["kb"], pt["spreadPermille"], rng) for it in items)
sub_over[p] += s > lim["subSingleMaxKB"]
permille = lambda c: c * 1000 // trials
warnings = []
if permille(main_over) > pt["warnOverLimitPermille"]:
warnings.append(f"main 超内部红线 {redline}KB 的概率 {permille(main_over)}permille 超过阈值 {pt['warnOverLimitPermille']}")
for p, c in sub_over.items():
if permille(c) > pt["warnOverLimitPermille"]:
warnings.append(f"分包 {p} 超单包上限概率 {permille(c)}permille 超过阈值")
report = {
"source": "sim/e02_budget_bench.py", "trials": trials, "seed": pt["seed"],
"spreadPermille": pt["spreadPermille"], "internalRedlineKB": redline, "platformMainMaxKB": lim["mainMaxKB"],
"mainKB": {"min": min(main_vals), "max": max(main_vals), "mean": sum(main_vals) // trials},
"mainOverRedlinePermille": permille(main_over), "mainOverPlatformPermille": permille(main_over_platform),
"subOverLimitPermille": {p: permille(c) for p, c in sub_over.items()}, "warnings": warnings,
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "e02_budget_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"trials={trials} seed={pt['seed']} spread=±{pt['spreadPermille']}permille redline={redline}KB platformMax={lim['mainMaxKB']}KB")
print(f"mainKB min={report['mainKB']['min']} mean={report['mainKB']['mean']} max={report['mainKB']['max']}")
print(f"mainOverRedline={report['mainOverRedlinePermille']}permille mainOverPlatform={report['mainOverPlatformPermille']}permille")
for p, v in report["subOverLimitPermille"].items():
print(f" subOverLimit[{p}]={v}permille")
for w in warnings:
print(f"WARN {w}")
print("== 体积扰动基准:完成(软告警)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
trials=400 seed=20261012 spread=±150permille redline=3456KB platformMax=4096KB
mainKB min=2892 mean=3222 max=3529
mainOverRedline=37permille mainOverPlatform=0permille
subOverLimit[sub_decor]=0permille
subOverLimit[sub_decor_2]=0permille
subOverLimit[sub_levels]=0permille
subOverLimit[sub_merge]=0permille
subOverLimit[sub_audio]=0permille
WARN main 超内部红线 3456KB 的概率 37permille 超过阈值 30
== 体积扰动基准:完成(软告警)
- main 在 400 轮里从 2892KB 摆到 3529KB,均值 3222KB——单看均值离红线 3456KB 还有 234KB,但上界已经越过红线。越内部红线概率 37‰ 超过 30‰ 阈值,于是打出一条 WARN:876KB 的平台余量并不等于「随便怎么改都不会超」;而越平台上限(4096KB)概率 0‰,五个分包也全部 0‰,风险集中在 main。内部红线的作用正是在平台上限之前提前报警。
这份基准是软告警:它不判失败,只把「3.7%」这个数字摆出来,由人决定是把红线调高、还是把 main 再压一压。真实平台数值需真实业务环境,命令行不可复现,此处只是本机同机相对量级。
小结
- 构建产物清单把代码模块与资源并成一张表(main 代码 840KB 与 2.1 模块表对得上);S 层核算按包与类型聚合体积、算占比、画堆叠图,本机实跑总量 8130KB、main 3220KB / 396‰、余量 876KB。
- B 层用可播种扰动跑 400 轮:main 越内部红线(3456KB)37‰,越平台上限(4096KB)0‰——余量不是安全边际,内部红线是提前报警线。
工程骨架(结构 / 分包 / 预算)到这里就完整了;与站内小游戏创作平台专题 的分工是——那边讲平台的分包能力,这边核算一个项目自己的构建产物。下一章进入画面本身:没有 DOM 的小游戏,渲染环境到底长什么样。
阅读导航:上一节:分包与资源加载管线 · 下一节:小游戏的渲染环境:无 DOM 的 Canvas / WebGL 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。