上一节把立项决策压进了一页纸,其中「预算与周期」是最难填的一项。站内做小游戏服务平台的那组内容(小游戏服务平台专题 )里,预算与融资是平台级口径——融多少钱、烧多久、怎么讲故事给投资人听;本节要算的完全不同:单款小游戏首版从立项到上线,到底要花多少钱、多少天。前者是资本决策,后者是项目决策,用的公式和目标都不一样。
一页纸上写「约 34.8 万元、约 78 个工作日」,这两个数字不是拍出来的,是拆出来的。本节把首版拆成七个里程碑,用「人力 × 工时 × 单价」把每个里程碑折算成钱和工期,再一路整数递推累加,最后产出一张排期甘特图。这一节只做首版整体预算与排期这一层;具体到「每个人多少工时、外包素材怎么计价、里程碑之间留多少缓冲」,是第 14 章的活。
预算不是拍出来的,是拆出来的
多数团队估预算的方式是「感觉」:三个人干三个月,一人一月两万,那就是十八万。这种算法有两个致命问题:第一,它没有拆分,所以当有人说「这个月超支了」时,没人知道超在哪一段;第二,它不可检验,因为「三个月」和「两万」都是整数级的口头数字,一旦有变化,整个估算就重来。
可检验的预算估算,必须满足三个条件:拆分(每一段有独立的工时与人力)、递推(累计值由前一段累加得出,而不是另算一遍)、整数(金额用最小单位「分」、工时用整数工作日,杜绝浮点误差)。满足了这三条,预算就从一个「承诺」,变成了一张可追溯、可对比、可增删的表。
里程碑表:把首版拆成七段
《合成小岛》首版拆成七个里程碑,覆盖从立项到上线的全过程。下面这份配置是 **D 层(静态层)**产物:它把里程碑写成一张可被脚本读取的表,每个里程碑带四项数据——工时(workDays)、人数(people)、每日单价(dailyRateCents)、所属阶段(phase)。
注意:这里的工时与单价都是示例假设,用来演示算法,不代表任何真实市场行情或薪资水平。换一个团队、换一个地区,这些数字都应该重填,但算法不变。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "示例假设:以下工时、人数、单价均为占位数据,用于演示「人力 × 工时 × 单价」的整数递推与里程碑排期算法,不代表任何真实市场行情或薪资水平。金额单位统一为「分」(整数),工时单位为「工作日」(整数);本文件不含任何浮点。里程碑按顺序串行排期,startDay 为上一里程碑 endDay + 1。",
"currency": "分",
"dayUnit": "工作日",
"milestones": [
{ "id": "design", "name": "立项与设计", "phase": "规划", "workDays": 5, "people": 2, "dailyRateCents": 200000 },
{ "id": "prototype", "name": "核心玩法原型", "phase": "验证", "workDays": 12, "people": 2, "dailyRateCents": 200000 },
{ "id": "systems", "name": "玩法系统开发", "phase": "开发", "workDays": 20, "people": 3, "dailyRateCents": 180000 },
{ "id": "content", "name": "内容与关卡", "phase": "开发", "workDays": 15, "people": 2, "dailyRateCents": 180000 },
{ "id": "monetize", "name": "变现接入", "phase": "接入", "workDays": 8, "people": 2, "dailyRateCents": 180000 },
{ "id": "polish", "name": "打磨与测试", "phase": "打磨", "workDays": 12, "people": 3, "dailyRateCents": 180000 },
{ "id": "release", "name": "上线准备", "phase": "发布", "workDays": 6, "people": 2, "dailyRateCents": 200000 }
]
}
七个里程碑里,「核心玩法原型」单独成段,是刻意的设计——它是验证段:先把最不确定的「合成手感」做出来试,验证过了才进入后面的系统开发。如果原型阶段就发现玩法不成立,整个项目的止损点就落在这里,而不是等到系统都写完才发现。这也呼应上一节的「止损条件」:把最容易失败的环节排在最前面,是控制风险最省钱的办法。
用 S 层脚本做整数递推
有了这张表,预算与排期就都能算出来。下面这段是 S 层仿真脚本,对每个里程碑算成本(workDays × people × dailyRateCents),再一路累加出累计工期(cumWorkDays)与累计投入(cumCostCents),并画出甘特式条形图。
#!/usr/bin/env python3
# file: sim/ch11_budget_schedule.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch11_budget_schedule.py --config config/ --out out/
"""开发预算与排期估算:人力 × 工时 × 单价,整数递推。
读 config/ch11_budget.json 的里程碑表,对每个里程碑算:
costCents = workDays × people × dailyRateCents (整数乘法)
costYuan = costCents // 100 (整数地板除)
startDay = 上一里程碑 endDay + 1 (整数递推)
endDay = startDay + workDays - 1
cumCostCents = 上一里程碑 cumCostCents + costCents (整数递推)
产出:out/ch11_budget.json + out/ch11_budget_milestones.csv + out/ch11_budget.svg
SVG 为甘特式条形图:每里程碑一根横条,位置与长度按 startDay / workDays 排布。
全整数、无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
PHASE_COLOR = {
"规划": "#94a3b8", "验证": "#2563eb", "开发": "#0891b2",
"接入": "#7c3aed", "打磨": "#d97706", "发布": "#059669",
}
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "ch11_budget.json"), encoding="utf-8") as f:
return json.load(f)
def draw_svg(path: str, rows: list[dict], total_days: int) -> int:
"""甘特式条形图:横轴为日历工作日,纵轴为里程碑。"""
W, PAD_L, PAD_R, ROW = 780, 170, 40, 40
H = 80 + ROW * len(rows)
svg = ET.Element(
"svg",
{"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"},
)
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
f"《合成小岛》首版里程碑排期(共 {total_days} 个工作日)"
)
plot_w = W - PAD_L - PAD_R
for i, r in enumerate(rows):
y = 56 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = r["name"]
x = PAD_L + (plot_w * (r["startDay"] - 1)) // total_days
w = (plot_w * r["workDays"]) // total_days
color = PHASE_COLOR.get(r["phase"], "#2563eb")
ET.SubElement(svg, "rect", {"x": str(x), "y": str(y + 6), "width": str(max(w, 2)), "height": "20",
"rx": "4", "fill": color})
ET.SubElement(svg, "text", {"x": str(x + max(w, 2) + 8), "y": str(y + 21), "font-size": "11",
"fill": "#6b7280"}).text = (
f'D{r["startDay"]}-{r["endDay"]} · {r["workDays"]}日 · {r["costYuan"]}元'
)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
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)
rows, cum_days, cum_cents = [], 0, 0
for m in cfg["milestones"]:
cost_cents = m["workDays"] * m["people"] * m["dailyRateCents"]
start_day = cum_days + 1
end_day = start_day + m["workDays"] - 1
cum_days += m["workDays"]
cum_cents += cost_cents
rows.append({
"id": m["id"], "name": m["name"], "phase": m["phase"],
"workDays": m["workDays"], "people": m["people"], "dailyRateCents": m["dailyRateCents"],
"personDays": m["workDays"] * m["people"],
"costCents": cost_cents, "costYuan": cost_cents // 100,
"startDay": start_day, "endDay": end_day,
"cumWorkDays": cum_days, "cumCostCents": cum_cents,
})
total_person_days = sum(r["personDays"] for r in rows)
report = {
"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
"currency": cfg["currency"], "dayUnit": cfg["dayUnit"],
"milestoneCount": len(rows),
"totalWorkDays": cum_days,
"totalPersonDays": total_person_days,
"totalCostCents": cum_cents,
"totalCostYuan": cum_cents // 100,
"milestones": rows,
}
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch11_budget.svg"), rows, cum_days)
report["svg"] = os.path.join(args.out, "ch11_budget.svg")
report["svgBytes"] = svg_bytes
with open(os.path.join(args.out, "ch11_budget.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch11_budget_milestones.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "phase", "workDays", "people", "dailyRateCents",
"personDays", "costCents", "costYuan", "startDay", "endDay",
"cumWorkDays", "cumCostCents"])
for r in rows:
w.writerow([r["id"], r["name"], r["phase"], r["workDays"], r["people"], r["dailyRateCents"],
r["personDays"], r["costCents"], r["costYuan"], r["startDay"], r["endDay"],
r["cumWorkDays"], r["cumCostCents"]])
print(json.dumps({"milestoneCount": len(rows), "totalWorkDays": cum_days,
"totalPersonDays": total_person_days,
"totalCostYuan": report["totalCostYuan"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"milestoneCount": 7, "totalWorkDays": 78, "totalPersonDays": 188, "totalCostYuan": 347600}
七个里程碑串行排下来是 78 个工作日、188 个人日、总预算 34.76 万元。同时落盘 out/ch11_budget_milestones.csv(每里程碑一行)与 out/ch11_budget.svg(甘特式排期图)。CSV 是这样:
id,name,phase,workDays,people,dailyRateCents,personDays,costCents,costYuan,startDay,endDay,cumWorkDays,cumCostCents
design,立项与设计,规划,5,2,200000,10,2000000,20000,1,5,5,2000000
prototype,核心玩法原型,验证,12,2,200000,24,4800000,48000,6,17,17,6800000
systems,玩法系统开发,开发,20,3,180000,60,10800000,108000,18,37,37,17600000
content,内容与关卡,开发,15,2,180000,30,5400000,54000,38,52,52,23000000
monetize,变现接入,接入,8,2,180000,16,2880000,28800,53,60,60,25880000
polish,打磨与测试,打磨,12,3,180000,36,6480000,64800,61,72,72,32360000
release,上线准备,发布,6,2,200000,12,2400000,24000,73,78,78,34760000
排期读出来的三个结论
数字出来了,读这份 CSV 时注意三件事。
第一,累计列是「整数递推」出来的,不是另算一遍。 cumWorkDays 与 cumCostCents 两列,每一行都是上一行加本行:systems 的 cumCostCents = 17600000,正好是 prototype 的 6800000 加上 systems 自己的 10800000。这样做的意义是:如果某个里程碑的工时改了,只要从那一行往下重新累加,后面的累计值会自动跟着变——不需要手工重算整张表,也就不会出现「改了前一段、忘了改后一段」的错位。
第二,钱的分布比时间的分布更不均匀。 七个里程碑里,「玩法系统开发」一段就花了 10.8 万元,占总量约 31%;加上「打磨与测试」的 6.48 万,两段占了近一半预算。而工期上,这两段合计 32 个工作日,占 78 天的约 41%。钱的集中度高于时间的集中度,原因是这两段用的人数最多(各 3 人)。这提示一个现实:真正吃预算的不是「做多久」,而是「多少人在做」——一个五人的一个月,比三个人的两个月更贵。
第三,costYuan 是 costCents 的地板除,会有极小的舍入。 本套所有金额先算「分」,最后才 // 100 转成元。因为单价都是整百分数(如 200000 分 = 2000 元),本例的除法恰好整除、没有舍入;但换成任意单价,// 100 会向下取整,宁可少算几分,也不引入浮点。这和本套「config 禁浮点、除法一律地板除」的规则一脉相承。
串行排期的局限与依赖关系
上面的模型有一个明显的简化:它假设七个里程碑完全串行——上一段结束,下一段才开始。真实项目里,里程碑之间是有依赖与并行的:原型验证通过后,系统开发和内容制作可以部分并行;变现接入依赖系统开发完成,但打磨测试可以和变现接入重叠。
为什么本模型仍用串行?因为串行是最保守的排期:它给出的是「如果一切都不重叠,需要多少天」的上界。立项阶段要的是这个上界——真实排期只会比它短(有并行)或更长(有返工),而不会既没有并行又不用返工。用一个保守上界去做预算,比用一个乐观的并行估计去承诺,要安全得多。
依赖关系本身也值得写下来,哪怕不建模。七个里程碑的真实依赖大致是:
| 里程碑 | 前置依赖 | 可否并行 |
|---|---|---|
| 立项与设计 | 无 | 否(起点) |
| 核心玩法原型 | 立项与设计 | 否(验证关卡) |
| 玩法系统开发 | 核心玩法原型通过 | 部分(与内容并行) |
| 内容与关卡 | 玩法系统骨架 | 部分(与系统开发并行) |
| 变现接入 | 玩法系统开发 | 与打磨部分重叠 |
| 打磨与测试 | 内容与变现基本完成 | 与变现接入重叠 |
| 上线准备 | 打磨通过 | 否(收尾) |
把这张表和一页纸放在一起,就能回答评审席最常问的一个问题:「如果原型验证不通过,我们已经花了多少?」答案是 design + prototype = 6.8 万元——这就是止损点的成本上界,也是下一节要展开的核心。
与第 14 章的分工
本节做的是首版整体预算与排期:七个里程碑、总体多少钱、总体多少天。它刻意停在「里程碑」这一层,不往下拆到「每个角色多少工时」。往下拆的那一层属于第 14 章:
| 维度 | 本节(11.2) | 第 14 章 |
|---|---|---|
| 粒度 | 里程碑 | 角色 / 岗位 |
| 成本口径 | 人力 × 工时 × 单价 | 人力工时 + 外包素材 + 缓冲 |
| 排期 | 串行上界 | 里程碑缓冲与风险预留 |
| 目标 | 立项预算与工期 | 资源计划与风险预算 |
分工的理由很简单:立项阶段只需要一个可信的量级——大概三十几万、大概两三个月。而进入开发后,才需要把每个里程碑再拆成「主程多少天、美术多少天、外包多少钱、留多少缓冲」。先定量级,再定细节,这是预算估算的顺序。如果一上来就抠到每个角色,往往还没立项就已经陷入细节、忘了大方向。
预算估算的自检表
最后收成一张表。每次估预算时逐条对照。
| 检查项 | 合格标准 | 常见缺陷 |
|---|---|---|
| 是否拆分 | 每个里程碑有独立工时与人力 | 只给一个总数 |
| 是否递推 | 累计值由前一段累加 | 每段另算,易错位 |
| 是否整数 | 金额用分、工时用整数 | 用小数工时/浮点单价 |
| 是否标注假设 | 单价/工时标为示例假设 | 写成真实行情 |
| 是否给出上界 | 用保守串行排期 | 用乐观并行估计 |
| 是否定位止损 | 算出「验证段成本」 | 只算总预算 |
其中最后一条最容易被忽略。预算估算的产出不只是一张花钱的表,它还是止损线的基础:知道「原型验证段花了多少」,才知道「止损要付出多大代价」。下一节就把这个代价,和一页纸里的「成功判据」一起,做成一套可调的止损模型——什么信号出现、在哪个检查点、就该停。
阅读导航:上一节:一页纸设计与立项文档 · 下一节:立项决策与止损线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。