11.2 开发预算与排期估算

把《合成小岛》首版开发从立项到上线拆成七个里程碑,用「人力 × 工时 × 单价」做整数递推算出累计投入与累计工期:S 层脚本产出里程碑 CSV 与甘特式排期条形图,并讲清串行排期的三条读法、里程碑依赖关系与预算估算的常见误区,附一张可复用的自检表。

上一节把立项决策压进了一页纸,其中「预算与周期」是最难填的一项。站内做小游戏服务平台的那组内容(小游戏服务平台专题 )里,预算与融资是平台级口径——融多少钱、烧多久、怎么讲故事给投资人听;本节要算的完全不同:单款小游戏首版从立项到上线,到底要花多少钱、多少天。前者是资本决策,后者是项目决策,用的公式和目标都不一样。

一页纸上写「约 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 章
粒度里程碑角色 / 岗位
成本口径人力 × 工时 × 单价人力工时 + 外包素材 + 缓冲
排期串行上界里程碑缓冲与风险预留
目标立项预算与工期资源计划与风险预算

分工的理由很简单:立项阶段只需要一个可信的量级——大概三十几万、大概两三个月。而进入开发后,才需要把每个里程碑再拆成「主程多少天、美术多少天、外包多少钱、留多少缓冲」。先定量级,再定细节,这是预算估算的顺序。如果一上来就抠到每个角色,往往还没立项就已经陷入细节、忘了大方向。

预算估算的自检表

最后收成一张表。每次估预算时逐条对照。

检查项合格标准常见缺陷
是否拆分每个里程碑有独立工时与人力只给一个总数
是否递推累计值由前一段累加每段另算,易错位
是否整数金额用分、工时用整数用小数工时/浮点单价
是否标注假设单价/工时标为示例假设写成真实行情
是否给出上界用保守串行排期用乐观并行估计
是否定位止损算出「验证段成本」只算总预算

其中最后一条最容易被忽略。预算估算的产出不只是一张花钱的表,它还是止损线的基础:知道「原型验证段花了多少」,才知道「止损要付出多大代价」。下一节就把这个代价,和一页纸里的「成功判据」一起,做成一套可调的止损模型——什么信号出现、在哪个检查点、就该停。

阅读导航:上一节:一页纸设计与立项文档 · 下一节:立项决策与止损线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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