14.1 立项到上线的时间线

《合成小岛》从立项评审到版本对全量用户可见,到底要多久?这一节把整条链路写成一张带前置依赖的任务图,每个任务给乐观 / 最可能 / 悲观三档工期,用确定性关键路径算出排期基准,再用可播种蒙特卡洛两千次采样得到总工期分布与每个任务进入关键路径的频率,把「什么时候能上线」从口头估计变成可跑出数字的模型。

前三卷把《合成小岛》从选品、原型、引擎实现一路讲到变现、买量、留存与合规,第 14 章换一个视角:不再分章讲方法,而是把这款游戏从 0 到 1 走一遍,做一次完整复盘。先说外部分工。本站有一组 小游戏创作平台 的文章(24 篇),那是做平台的 SaaS 创业口径——怎么搭编辑器、怎么设计变现、怎么融资;本章恰好相反,是在一个平台上做一款游戏的完整案例:不写平台怎么做,只写「拿这个平台,把一款叫《合成小岛》的游戏真正做出来并上线」这条路上的时间线、决策与数据。

本章只讲三件事——时间线(14.1)/ 决策复盘(14.2)/ 数据表现与迭代(14.3)。前面各章已经把方法讲透了:立项决策在卷一第 11 章、开发预算在卷一第 14 章、发布流程在卷三第 9 章、留存漏斗在卷三第 7 章、版本列车在卷三第 13 章。本节不重复这些方法,只把它们串成一条可跑的时间线。

时间线不是甘特图,是一张带概率的图

大多数团队排期的方式是画一张甘特图,每个任务写一个数字,加起来就是「大概几周」。这样做有两个隐藏假设:工期是确定的、任务之间没关系。真实的项目里,两样都不成立——工期是一个区间,任务之间有先后依赖(没做完原型就实现不了核心循环)。把这两件事都放进模型,时间线就从一张条状图变成一张有向无环图(DAG)+ 一组工期分布。

《合成小岛》的时间线拆成 12 个任务,跨五个阶段:立项 → 原型 → 生产 → 打磨 → 提审发布。每个任务给三档工期:opt(乐观)、likely(最可能)、pess(悲观)。用 likely 跑一遍最长路径就是排期基准,也就是关键路径;在每个任务的 [opt, pess] 区间里随机采样再跑一遍,重复两千次,就得到总工期的分布。这件事的价值在于:它回答的不是「要几周」,而是「有几成把握在第几周上线」。

D 层:把任务图写进配置

下面这份是 D 层(静态层)配置,把 12 个任务、它们的阶段归属、负责人、三档工期和前置依赖全写成整数。工期为示例假设值,真实项目工期需真实业务环境,命令行不可复现(X)。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "立项到上线时间线模型(D 层)。任务图是 DAG,deps 指定前置;每个任务给三档工期 opt/likely/pess(天,整数)。确定性排期用 likely 算关键路径;蒙特卡洛在 [opt, pess] 上均匀整数采样。工期为示例假设值,真实项目工期需真实业务环境,命令行不可复现(X)。全整数,禁浮点。",
  "units": {
    "opt": "乐观工期(天)",
    "likely": "最可能工期(天),用于确定性关键路径",
    "pess": "悲观工期(天)",
    "deps": "前置任务 id 列表,空数组表示起点"
  },
  "runs": 2000,
  "seed": 20261012,
  "phases": [
    { "id": "greenlight", "name": "立项" },
    { "id": "prototype", "name": "原型" },
    { "id": "production", "name": "生产" },
    { "id": "polish", "name": "打磨" },
    { "id": "launch", "name": "提审发布" }
  ],
  "tasks": [
    { "id": "g1", "name": "立项评审与一页纸", "phase": "greenlight", "owner": "制作人", "opt": 2, "likely": 3, "pess": 5, "deps": [] },
    { "id": "g2", "name": "核心玩法原型", "phase": "prototype", "owner": "玩法策划", "opt": 7, "likely": 10, "pess": 16, "deps": ["g1"] },
    { "id": "g3", "name": "可玩性验证", "phase": "prototype", "owner": "制作人", "opt": 3, "likely": 5, "pess": 8, "deps": ["g2"] },
    { "id": "p1", "name": "技术选型与工程骨架", "phase": "production", "owner": "客户端", "opt": 5, "likely": 8, "pess": 12, "deps": ["g1"] },
    { "id": "p2", "name": "核心循环实现", "phase": "production", "owner": "客户端", "opt": 10, "likely": 14, "pess": 20, "deps": ["g2", "p1"] },
    { "id": "p3", "name": "关卡与成长系统", "phase": "production", "owner": "客户端", "opt": 8, "likely": 12, "pess": 18, "deps": ["p2"] },
    { "id": "p4", "name": "变现与存档", "phase": "production", "owner": "客户端", "opt": 6, "likely": 9, "pess": 14, "deps": ["p3"] },
    { "id": "a1", "name": "美术素材制作", "phase": "production", "owner": "美术", "opt": 10, "likely": 15, "pess": 24, "deps": ["g3"] },
    { "id": "a2", "name": "音效与动效", "phase": "production", "owner": "美术", "opt": 5, "likely": 7, "pess": 11, "deps": ["p3"] },
    { "id": "q1", "name": "打磨与性能优化", "phase": "polish", "owner": "客户端", "opt": 7, "likely": 10, "pess": 15, "deps": ["p4", "a1", "a2"] },
    { "id": "q2", "name": "内测与缺陷修复", "phase": "polish", "owner": "QA", "opt": 5, "likely": 8, "pess": 13, "deps": ["q1"] },
    { "id": "r1", "name": "提审与上线", "phase": "launch", "owner": "运营", "opt": 5, "likely": 8, "pess": 15, "deps": ["q2"] }
  ]
}

deps 是这张图的关键:p2(核心循环)依赖 g2(原型)与 p1(骨架)两条线,q1(打磨)依赖生产线的三条尾(p4、a1、a2)。一条依赖写错,整张图的排期就全错,所以这份配置值得像代码一样审。

S 层:关键路径 + 蒙特卡洛

下面这段是 S 层仿真脚本,读上面这份配置:先用 likely 工期在 DAG 上算最长路径(关键路径与排期基准),再对每个任务在 [opt, pess] 上做可播种均匀整数采样、重复两千次,统计总工期分布与每个任务进入关键路径的频率。全整数,无浮点。

#!/usr/bin/env python3
# file: sim/l14_timeline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l14_timeline.py --config config/ --out out/
"""立项到上线时间线仿真:读 config/l14_timeline.json。

任务图是 DAG(deps 指定前置),每个任务给三档工期 opt/likely/pess(天)。
- 确定性关键路径:用 likely 工期在 DAG 上算最长路径,得到排期基准与关键路径。
- 蒙特卡洛:每个任务在 [opt, pess] 上做均匀整数采样(可播种 LCG),每次重算最长路径,
  跑 N 次得到「立项到上线总工期」的分布(p50 / p90 / 均值 / 最小 / 最大),并统计每个任务
  进入关键路径的频率(criticality)。全整数,无浮点。
产出 JSON + CSV + SVG。工期为示例假设值,真实项目工期需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
from collections import deque


def load(d: str) -> dict:
    with open(os.path.join(d, "l14_timeline.json"), encoding="utf-8") as f:
        return json.load(f)


def make_rng(seed: int):
    """可播种 LCG(Numerical Recipes 参数),返回 [0, 2^31) 整数。"""
    state = seed & 0x7FFFFFFF

    def nxt() -> int:
        nonlocal state
        state = (1103515245 * state + 12345) & 0x7FFFFFFF
        return state

    return nxt


def topo_order(tasks: list) -> list:
    """Kahn 拓扑排序,返回任务 id 的线性序。"""
    ids = [t["id"] for t in tasks]
    indeg = {i: 0 for i in ids}
    adj = {i: [] for i in ids}
    for t in tasks:
        for d in t["deps"]:
            adj[d].append(t["id"])
            indeg[t["id"]] += 1
    q = deque([i for i in ids if indeg[i] == 0])
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    return order


def longest_path(order: list, dur: dict, by_id: dict):
    """在 DAG 上求最长路径:返回 (总工期, 关键路径 id 列表, 每个任务的最早完成)。"""
    finish = {}
    prev = {}
    for i in order:
        best, bp = 0, None
        for d in by_id[i]["deps"]:
            if finish[d] > best:
                best, bp = finish[d], d
        finish[i] = best + dur[i]
        prev[i] = bp
    end = max(order, key=lambda i: finish[i])
    path, cur = [], end
    while cur is not None:
        path.append(cur)
        cur = prev[cur]
    path.reverse()
    return finish[end], path, finish


def percentile(sorted_vals: list, p: int) -> int:
    if not sorted_vals:
        return 0
    return sorted_vals[(p * (len(sorted_vals) - 1)) // 100]


def draw_svg(path: str, totals: list, p50: int, p90: int) -> int:
    W, H, PAD = 720, 320, 48
    bins = 10
    lo, hi = min(totals), max(totals)
    span = hi - lo + 1
    counts = [0] * bins
    for v in totals:
        counts[min(bins - 1, (v - lo) * bins // span)] += 1
    peak = max(counts) or 1
    plot_w, plot_h = W - PAD * 2, H - 90
    bw = plot_w // bins
    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 = \
        "立项到上线总工期分布(柱高 = 次数,天)"
    for i, c in enumerate(counts):
        h = plot_h * c // peak
        x, y = PAD + i * bw, H - 40 - h
        ET.SubElement(svg, "rect", {"x": str(x + 2), "y": str(y), "width": str(bw - 4),
                                    "height": str(h), "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(x + bw // 2 - 10), "y": str(H - 24), "font-size": "11",
                                    "fill": "#6b7280"}).text = str(lo + i * span // bins)
    ET.SubElement(svg, "text", {"x": str(PAD), "y": str(H - 8), "font-size": "12",
                                "fill": "#374151"}).text = f"p50={p50}d  p90={p90}d"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    tasks = cfg["tasks"]
    by_id = {t["id"]: t for t in tasks}
    order = topo_order(tasks)

    # 确定性:用 likely 工期算关键路径
    base_dur = {t["id"]: t["likely"] for t in tasks}
    base_total, base_path, base_finish = longest_path(order, base_dur, by_id)

    # 蒙特卡洛
    rng = make_rng(cfg["seed"])
    crit = {t["id"]: 0 for t in tasks}
    totals = []
    for _ in range(cfg["runs"]):
        dur = {}
        for t in tasks:
            span = t["pess"] - t["opt"] + 1
            dur[t["id"]] = t["opt"] + rng() % span
        total, path, _ = longest_path(order, dur, by_id)
        totals.append(total)
        for i in path:
            crit[i] += 1
    totals.sort()
    p50, p90 = percentile(totals, 50), percentile(totals, 90)
    mean = sum(totals) // len(totals)
    criticality = {i: crit[i] * 1000 // cfg["runs"] for i in crit}
    top_crit = sorted(criticality.items(), key=lambda kv: -kv[1])[:4]

    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l14_timeline.svg"), totals, p50, p90)
    with open(os.path.join(a.out, "l14_timeline.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "phase", "owner", "opt", "likely", "pess", "deps",
                    "likelyFinish", "criticalityPermille"])
        for t in tasks:
            w.writerow([t["id"], t["name"], t["phase"], t["owner"], t["opt"], t["likely"], t["pess"],
                        "|".join(t["deps"]), base_finish[t["id"]], criticality[t["id"]]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "seed": cfg["seed"], "runs": cfg["runs"], "taskCount": len(tasks),
           "baselineTotalDays": base_total, "baselineCriticalPath": base_path,
           "simulation": {"p50": p50, "p90": p90, "mean": mean, "min": totals[0], "max": totals[-1],
                          "bufferDaysP90": p90 - base_total},
           "topCriticalTasks": [{"id": i, "criticalityPermille": c} for i, c in top_crit],
           "svgBytes": sb}
    with open(os.path.join(a.out, "l14_timeline.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"baselineTotalDays": base_total, "criticalPath": base_path,
                      "p50": p50, "p90": p90, "mean": mean, "max": totals[-1],
                      "bufferDaysP90": p90 - base_total,
                      "topCritical": [[i, c] for i, c in top_crit]}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

脚本的核心是 longest_path:它按拓扑序逐个任务推进,finish[i] = max(finish[前置]) + 工期[i],最后取最大者即总工期,回溯 prev 得到关键路径。在本机跑它,输出如下(同机相对量级,绝对数值不可移植):

{"baselineTotalDays": 74, "criticalPath": ["g1", "g2", "p2", "p3", "p4", "q1", "q2", "r1"], "p50": 83, "p90": 92, "mean": 82, "max": 110, "bufferDaysP90": 18, "topCritical": [["g1", 1000], ["q1", 1000], ["q2", 1000], ["r1", 1000]]}

用 likely 工期算出的排期基准是 74 天,关键路径是 g1 → g2 → p2 → p3 → p4 → q1 → q2 → r1——原型线(g2)与生产线(p1→p2)会合后一路走到上线,美术线(a1)不在关键路径上。两千次采样后,中位数 83 天、p90 是 92 天、均值 82 天、最长 110 天。也就是说,排期不能只报 74 天:那是最乐观的确定值,要按 p90 留出 18 天缓冲,才有约九成把握不超期。

三个真正决定上线的数字

回到输出,时间线上真正要盯的是三样东西。第一是基准与 p90 的差:74 天是「每件事都按最可能走」的结果,92 天才是有把握的日子,中间这 18 天就是该主动留的缓冲——不留在计划里,它就会以「又延期了」的形式出现。第二是每个任务的 criticality:g1、q1、q2、r1 的频率都是 1000‰,意味着两千次里它们几乎每次都在关键路径上——这几个任务一延期,整条线就延期;而 g3(可玩性验证)与 a1(美术素材)只有 8‰,说明它们有充足的浮动,晚几天不致命。第三是工期区间的宽度:g2 的区间是 [7, 16]、a1 是 [10, 24],区间越宽的任务越该早做、早暴露风险——不确定性高的任务要往前排,而不是往后拖。

同一份「任务图 + 最长路径」逻辑,我们在工作区里还放了一份 P 层 TypeScript 内核(core/src/l14/timeline.ts)与配套测试,把时间线内核钉死在六个断言上(node --test 'core/test/l14_*.test.ts'),总工期与关键路径与上面的 Python 逐位一致。把它接进 CI,任务图一改就自动重算关键路径,「这次改动会不会把上线日推后」变成一次函数调用,而不是开会吵。

小结与下一步

这一节把「立项到上线要多久」做成了一个可跑模型:12 个任务写成带依赖的 DAG,用 likely 算关键路径得基准 74 天,用蒙特卡洛得 p90 92 天。三件事记住就够——排期要报 p90 而不是基准值;关键路径上的任务(g1/q1/q2/r1)一延期全线延期,非关键路径的(a1/g3)有浮动;工期区间越宽的任务越该往前排。

时间线只回答了「什么时候做完」,回答不了「当时为什么这么做、现在看做得对不对」。下一节就做关键决策复盘:把引擎选型、变现结构、包体裁剪、增长方式、放量方式这些岔路口逐个摊开,量化每条决策的预测偏差与机会成本。

阅读导航:上一节:目录 · 下一节:14.2 关键决策复盘 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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