先把边界交代清楚。站内那组游戏引擎平台(24 篇) 讲的是平台型产品的研发组织与工程排期——它排的是「平台什么时候发下一个大版本」这种面向多用户、多团队的工程节奏;本章是单款小游戏的人力工时、外包成本与缓冲估算——它排的是「做《合成小岛》这一款,每个里程碑什么时候必须收口、要留多少缓冲才不至于全线延期」。前者的里程碑服务于平台迭代,后者服务于单款交付,风险来源和缓冲口径都不一样。
前两节把账算成了静态的总额:180 人天、234,000 元人力(示例假设),加 26,000 元素材现金、8 人天内制工时。但真实项目不是一次性花完这些——它们被分段消耗,每一段(里程碑)都可能超期,而早期超期会一路顶穿后面的所有节点。这一节把静态的账排成一条带缓冲的时间线,并回答一个很硬的问题:缓冲到底该留多少?
一、为什么排期必须带缓冲
上一节算出的 45 个工作日工期,是「六种角色完全并行、零等待、零返工」的下界。真实项目里,估算是会飘的——每个任务「大概几天」的估计,天然带着不确定性。如果不给这份不确定性留余地,排期就成了一句空头承诺。
缓冲(buffer)不是「偷懒的借口」,而是为不确定性预留的时间预算。它有两种加法:
- 全局缓冲:在总工期末尾加一截。简单,但风险发生时你才知道是哪一段超的,已经来不及救。
- 里程碑缓冲:在每个里程碑节点上留缓冲,超期在当段就被发现,可以当段调整。
本节用的是里程碑缓冲:每个里程碑都算出「最晚完成时间」,真实进度一旦逼近它,就是一个可观测的预警信号。
二、里程碑与三点估算的简化版
标准的三点估算(PERT)给每个任务三个值:乐观、最可能、悲观。本节把它简化成两个值:乐观人天与悲观人天,最可能值取两者的中点。这份简化够用,因为我们关心的是区间宽度(不确定性有多大),而不是精确的众数。
下面这份是 D 层配置,它给出五个里程碑的乐观/悲观区间、缓冲比例参数、抽样次数与随机种子。
{
"version": 1,
"game": "merge-island",
"note": "里程碑与风险缓冲模型(S 层)。每个里程碑给出乐观/悲观人天的整数区间 [bestDays, worstDays];bufferPct 是缓冲比例参数。用可播种 LCG 在区间上均匀抽样 trials 次,统计「任一里程碑超出其最晚完成时间」的比例(万分比 permyriad)= 缓冲耗尽概率。seed 固定,回放确定。",
"seed": 20261012,
"trials": 20000,
"bufferPct": 20,
"benchBufferPct": [0, 10, 20, 30, 40],
"milestones": [
{ "id": "m1", "name": "可玩原型", "bestDays": 12, "worstDays": 24 },
{ "id": "m2", "name": "核心循环闭环", "bestDays": 20, "worstDays": 40 },
{ "id": "m3", "name": "内容与关卡", "bestDays": 25, "worstDays": 55 },
{ "id": "m4", "name": "变现接入", "bestDays": 10, "worstDays": 22 },
{ "id": "m5", "name": "测试与提审", "bestDays": 15, "worstDays": 30 }
]
}
几个设计点。区间用整数人天,worstDays 普遍是 bestDays 的两倍左右——这是「估算经常翻倍」的经验抽象,属于示例假设,不是从真实项目数据标定出来的。seed 固定:随机数用可播种的整数 LCG,同一份配置回放出的结果逐位一致,这是小游戏工程「确定性回放」要求的延伸——排期模型也必须可复现,否则没人信。trials = 20000 是抽样次数,越大越稳。
三、S 层:算最晚完成时间与缓冲耗尽概率
有了区间和缓冲比例,就能算两个核心量:每个里程碑的最晚完成时间(累计期望 × 缓冲系数)和缓冲耗尽概率(真实工期超过最晚完成时间的比例)。下面这段是 S 层仿真脚本,全程整数、可播种、产 JSON 与 CSV。
#!/usr/bin/env python3
# file: sim/ch14_milestone.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch14_milestone.py --config config/ --out out/
"""里程碑与风险缓冲模型:把缓冲比例当参数,算出各里程碑最晚完成时间与缓冲耗尽概率。
整数化口径(全程整数地板除,无浮点):
expectedDays = (bestDays + worstDays) // 2
cumulativeExp = 前 i 个里程碑的 expectedDays 之和
deadlineDays = cumulativeExp * (100 + bufferPct) // 100 # 各里程碑最晚完成时间
bufferDays = deadlineDays - cumulativeExp
每个里程碑的真实工期在 [bestDays, worstDays] 上均匀抽样(可播种 LCG,回放确定);
一次试验中只要任一里程碑的累计实际工期 > 它的 deadlineDays,就记一次「缓冲耗尽」。
缓冲耗尽概率 = 耗尽次数 * 10000 // trials (万分比 permyriad)
产出:out/ch14_milestone.json + out/ch14_milestone.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import os
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch14_milestone.json"), encoding="utf-8") as f:
return json.load(f)
def make_rng(seed):
"""可播种整数 LCG:给定 seed,序列完全确定,用于确定性回放。"""
state = [seed & 0x7FFFFFFF]
def nxt(mod):
state[0] = (state[0] * 1103515245 + 12345) & 0x7FFFFFFF
return state[0] % mod
return nxt
def plan(cfg, buffer_pct):
"""给定缓冲比例,算出各里程碑的累计期望工期与最晚完成时间。"""
ms = cfg["milestones"]
cum_exp, deadlines, buffers = [], [], []
acc = 0
for m in ms:
acc += (m["bestDays"] + m["worstDays"]) // 2
cum_exp.append(acc)
dl = acc * (100 + buffer_pct) // 100
deadlines.append(dl)
buffers.append(dl - acc)
return cum_exp, deadlines, buffers
def exhaust_permyriad(cfg, buffer_pct):
"""蒙特卡洛:返回 (任一里程碑耗尽万分比, 各里程碑耗尽万分比列表)。"""
ms = cfg["milestones"]
_, deadlines, _ = plan(cfg, buffer_pct)
rng = make_rng(cfg["seed"])
trials = cfg["trials"]
miss = [0] * len(ms)
any_miss = 0
for _ in range(trials):
acc = 0
hit = False
for i, m in enumerate(ms):
span = m["worstDays"] - m["bestDays"] + 1
acc += m["bestDays"] + rng(span)
if acc > deadlines[i]:
miss[i] += 1
hit = True
if hit:
any_miss += 1
any_pm = any_miss * 10000 // trials
per_ms = [c * 10000 // trials for c in miss]
return any_pm, per_ms
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
buffer_pct = cfg["bufferPct"]
cum_exp, deadlines, buffers = plan(cfg, buffer_pct)
any_pm, per_ms = exhaust_permyriad(cfg, buffer_pct)
milestones = []
for i, m in enumerate(cfg["milestones"]):
milestones.append({
"id": m["id"], "name": m["name"],
"bestDays": m["bestDays"], "worstDays": m["worstDays"],
"expectedDays": (m["bestDays"] + m["worstDays"]) // 2,
"cumulativeExpected": cum_exp[i],
"deadlineDays": deadlines[i],
"bufferDays": buffers[i],
"missPermyriad": per_ms[i],
})
os.makedirs(args.out, exist_ok=True)
report = {
"version": cfg["version"],
"game": cfg["game"],
"seed": cfg["seed"],
"trials": cfg["trials"],
"bufferPct": buffer_pct,
"totalExpectedDays": cum_exp[-1],
"totalDeadlineDays": deadlines[-1],
"totalBufferDays": buffers[-1],
"exhaustPermyriad": any_pm,
"milestones": milestones,
}
with open(os.path.join(args.out, "ch14_milestone.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch14_milestone.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "bestDays", "worstDays", "expectedDays",
"cumulativeExpected", "deadlineDays", "bufferDays", "missPermyriad"])
for m in milestones:
w.writerow([m["id"], m["name"], m["bestDays"], m["worstDays"], m["expectedDays"],
m["cumulativeExpected"], m["deadlineDays"], m["bufferDays"], m["missPermyriad"]])
print(json.dumps({"bufferPct": buffer_pct, "totalExpectedDays": cum_exp[-1],
"totalDeadlineDays": deadlines[-1], "totalBufferDays": buffers[-1],
"exhaustPermyriad": any_pm,
"perMilestonePermyriad": per_ms}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"bufferPct": 20, "totalExpectedDays": 126, "totalDeadlineDays": 151, "totalBufferDays": 25, "exhaustPermyriad": 2960, "perMilestonePermyriad": [2303, 1047, 624, 419, 231]}
四、读表:20% 缓冲其实不够
把输出翻译成人话:五个里程碑的期望工期合计 126 人天;按 20% 缓冲,最晚完成时间是第 151 天(总缓冲 25 天);但在这个假设下,缓冲耗尽概率高达 2960‱,也就是 29.6%——将近三成的概率,至少有一个里程碑会顶穿它的最晚完成时间。
逐里程碑看(out/ch14_milestone.csv):
| 里程碑 | 乐观 | 悲观 | 期望 | 累计期望 | 最晚完成 | 缓冲 | 耗尽概率 |
|---|---|---|---|---|---|---|---|
| 可玩原型 | 12 | 24 | 18 | 18 | 21 | 3 | 23.0% |
| 核心循环闭环 | 20 | 40 | 30 | 48 | 57 | 9 | 10.5% |
| 内容与关卡 | 25 | 55 | 40 | 88 | 105 | 17 | 6.2% |
| 变现接入 | 10 | 22 | 16 | 104 | 124 | 20 | 4.2% |
| 测试与提审 | 15 | 30 | 22 | 126 | 151 | 25 | 2.3% |
这里藏着一个非常反直觉、但极其重要的结论:耗尽概率最高的不是最后那个大里程碑,而是最前面的「可玩原型」(23.0%)。原因不难想:缓冲比例是统一的 20%,而「可玩原型」的区间是 [12, 24],期望 18、最晚完成 21——一个均匀分布在 [12, 24] 上的变量,超过 21 的概率就是 3 ÷ 13 ≈ 23%。短里程碑的相对波动更大,统一比例对它反而不够用。
这直接推翻了一个流行做法:「总工期加 20% 缓冲就万事大吉」。统一缓冲比例对早段里程碑是偏薄的,而早段超期又会顺延到后面所有节点。要压住整体风险,缓冲要么分段给足,要么整体抬高比例——后者正是下一节 B 层要量化的。
五、B 层:缓冲比例敏感性
「20% 不够」是个定性判断,B 层把它量化:缓冲比例每加 10 个点,耗尽概率降多少? 下面这段是 B 层基准脚本,把缓冲比例扫过 [0, 10, 20, 30, 40],对每个值重跑蒙特卡洛。B 层是软告警层:当前配置的耗尽概率超阈值只打 WARN,不判失败。
#!/usr/bin/env python3
# file: sim/ch14_buffer_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch14_buffer_bench.py --config config/ --out out/
"""B 基准:缓冲比例敏感性。
把 config/ch14_milestone.json 里的 bufferPct 扫过 benchBufferPct 一列取值,
对每个比例重跑里程碑蒙特卡洛,看「缓冲耗尽概率」随缓冲比例怎么变。
软告警:当前配置的 bufferPct 下耗尽概率超过阈值只打 WARN,不返回非 0。
产出:out/ch14_buffer_bench.json
"""
from __future__ import annotations
import argparse
import json
import os
from ch14_milestone import exhaust_permyriad, load, plan
WARN_EXHAUST_PERMYRIAD = 1000 # 耗尽概率超过 10%(1000‱)就打软告警
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
base_pct = cfg["bufferPct"]
rows = []
for pct in cfg["benchBufferPct"]:
any_pm, _ = exhaust_permyriad(cfg, pct)
_, deadlines, buffers = plan(cfg, pct)
rows.append({"bufferPct": pct, "totalDeadlineDays": deadlines[-1],
"totalBufferDays": buffers[-1], "exhaustPermyriad": any_pm})
base_row = next(r for r in rows if r["bufferPct"] == base_pct)
within = base_row["exhaustPermyriad"] <= WARN_EXHAUST_PERMYRIAD
os.makedirs(args.out, exist_ok=True)
report = {
"version": cfg["version"],
"seed": cfg["seed"],
"trials": cfg["trials"],
"baseBufferPct": base_pct,
"baseExhaustPermyriad": base_row["exhaustPermyriad"],
"cases": rows,
"warnThresholdPermyriad": WARN_EXHAUST_PERMYRIAD,
"withinTolerance": within,
}
with open(os.path.join(args.out, "ch14_buffer_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"baseBufferPct": base_pct, "baseExhaustPermyriad": base_row["exhaustPermyriad"],
"cases": rows, "withinTolerance": within}, ensure_ascii=False))
if not within:
print(f"WARN 缓冲 {base_pct}% 下耗尽概率 {base_row['exhaustPermyriad']}‱ > {WARN_EXHAUST_PERMYRIAD}‱")
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"baseBufferPct": 20, "baseExhaustPermyriad": 2960, "cases": [{"bufferPct": 0, "totalDeadlineDays": 126, "totalBufferDays": 0, "exhaustPermyriad": 7761}, {"bufferPct": 10, "totalDeadlineDays": 138, "totalBufferDays": 12, "exhaustPermyriad": 5723}, {"bufferPct": 20, "totalDeadlineDays": 151, "totalBufferDays": 25, "exhaustPermyriad": 2960}, {"bufferPct": 30, "totalDeadlineDays": 163, "totalBufferDays": 37, "exhaustPermyriad": 807}, {"bufferPct": 40, "totalDeadlineDays": 176, "totalBufferDays": 50, "exhaustPermyriad": 0}], "withinTolerance": false}
WARN 缓冲 20% 下耗尽概率 2960‱ > 1000‱
扫出来的曲线很干净:
| 缓冲比例 | 最晚完成 | 缓冲天数 | 耗尽概率 |
|---|---|---|---|
| 0% | 126 | 0 | 77.6% |
| 10% | 138 | 12 | 57.2% |
| 20% | 151 | 25 | 29.6% |
| 30% | 163 | 37 | 8.1% |
| 40% | 176 | 50 | 0.0% |
(40% 那一行的 0.0% 是万分比取整的结果,代表「在 20000 次抽样里没抽到一次耗尽」,不等于数学上的绝对零风险。)
三条结论:
- 缓冲的收益是递减的。 从 0% 到 10%,耗尽概率从 77.6% 降到 57.2%,只降 20 个点;从 20% 到 30%,从 29.6% 降到 8.1%,降了 21 个点。在 20% 附近每加一个点缓冲,换来的风险下降比在 0% 附近更大——因为曲线在这里最陡。
- 配置里的 20% 确实不够。 它对应的 29.6% 远超 B 层 10% 的告警线,脚本因此打出
WARN。这是一个真实被触发的软告警,不是摆设。 - 要压到 10% 以下,需要约 30% 缓冲。 30% 对应 8.1%,第一次落到告警线以下。也就是说,本假设下「可接受的缓冲」大约是三成,而不是常见直觉里的两成。
六、结论:该配多少缓冲
把这一节的结论收成一条可执行的建议:在本节的示例假设下,把缓冲从 20% 提到 30%——最晚完成时间从第 151 天推到第 163 天(多 12 天缓冲),换来耗尽概率从 29.6% 降到 8.1%。多花的 12 天,买的是「三成变八成」的按期交付概率。
但必须再强调一次口径:这条建议依赖「工期在区间上均匀分布」这个简化假设。真实的工期分布需要真实项目的历史数据标定,属于「需真实业务环境,命令行不可复现」(X)。本节能确定的是方法(怎么算最晚完成时间与耗尽概率)和相对关系(缓冲越多、耗尽概率越低,且在 20% 附近收益最陡)——这两个结论换一组假设仍然成立,只是具体数字会漂。
七、落到设计上的规则
- 排期必须带缓冲,缓冲要挂在里程碑上。 全局缓冲让你「事后才知道超了」,里程碑缓冲让你「当段就能救」。早段超期会顶穿后面所有节点,所以要盯住最前面的里程碑。
- 别用统一的缓冲比例糊弄所有里程碑。 短里程碑的相对波动更大,统一比例对它偏薄。「可玩原型」23.0% 的耗尽概率就是证据——它比最后一个大里程碑更危险。
- 用 B 层量化,而不是靠直觉拍缓冲。 「加 20% 就够」是直觉,「20% 对应 29.6%、30% 才降到 8.1%」才是决策依据。敏感性扫描把「该留多少」从争论变成了计算。
到这里,一卷的成本侧就闭合了:14.1 算人、14.2 算物、14.3 把人财物排成带缓冲的时间线。三张表——人力表、素材表、里程碑表——加上第 13 章的收入预测,构成了一整套立项决策的数字底座。下一章(第 15 章)把这些数字装进评审材料,交付出去。
阅读导航:上一节:外包与素材成本 · 下一节:立项评审材料与流程 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。