站内有一篇 H5 游戏产业全景 ,那是 H5 产业的行业综述——技术栈、生态、变现、政策一网打尽;卷三《上线变现与长线运营》 讲的是国内上线与长线运营的完整方法。本章不重做行业综述、也不重做国内运营方法论,它只做一件事:把前 14 章的方法装配成《合成小岛》的出海案例——按顺序调用、排出时间线、落地清单、对照结果。本节是案例的第一段:五平台组合已经选定,接下来是「按什么顺序接、要多久」。
案例的装配起点:组合已定,问题变成顺序
第 1 章已经用加权评分 + 预算装箱选出了《合成小岛》的出海组合,见 1.3 平台选择与组合策略 :GameDistribution + Poki + CrazyGames + Facebook Instant Games + YouTube Playables,五个平台、36 个人天。但「选出来」和「接进去」之间还差一件东西——顺序。五平台不是五个孤立的接入任务:它们共享一层平台抽象层 、一套构建矩阵 、一份多语言资源管线。共享任务先做完,平台才能接;而每个平台又要「接入 → 提审 → 审核」三步串行。这些先后关系画出来就是一张依赖图,工期就是图里最长的那条链。
五平台组合与各自的工期假设
沿用第 1 章的组合与接入人天,再补上「提审准备」与「审核等待」两段(口径是公开可观察机制的建模假设,观察时点 2026-10,不是任何平台后台实测):
| 平台 | 接入人天 | 提审天 | 审核天 | 依赖的共享任务 |
|---|---|---|---|---|
| GameDistribution | 4 | 1 | 5 | 抽象层 |
| Poki | 6 | 1 | 7 | 抽象层 |
| CrazyGames | 5 | 1 | 6 | 抽象层 |
| Facebook Instant Games | 12 | 2 | 10 | 抽象层 + i18n + 合规 |
| YouTube Playables | 9 | 2 | 14 | 抽象层 + i18n + 合规 |
三个共享前置任务(五平台都要先有):平台抽象层与构建矩阵 8 人天、多语言资源管线 5 人天、合规与同意管理 6 人天。它们互不依赖,可以并行开工;但 Facebook Instant Games 与 Playables 这两家要求最全,必须等三个共享任务都完成才能接入。
D 层:把组合装配成一张依赖图
下面这段是 D 层配置 config/g15_timeline.json。它把共享任务与五平台的三阶段全部写成节点与依赖,是整张时间线的唯一真源。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "出海接入时间线参数:把第 1 章选出的五平台组合装配成一张依赖图。口径是「公开可观察机制」的建模假设,不是任何平台后台实测。sharedTasks=五平台共享的前置任务(无依赖,manDays=工程人天);platforms[].integrateManDays=该平台接入与适配人天(沿用 1.3 组合取值);submitDays=提审材料准备天数;reviewDays=平台审核等待天数;needsShared=该平台接入必须先完成哪些共享任务。真实接入人天、审核时长与驳回往返需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"sharedTasks": [
{ "id": "abstraction", "name": "平台抽象层与构建矩阵", "manDays": 8 },
{ "id": "i18n", "name": "多语言资源管线", "manDays": 5 },
{ "id": "compliance", "name": "合规与同意管理", "manDays": 6 }
],
"platforms": [
{ "id": "gamedistribution", "name": "GameDistribution", "integrateManDays": 4, "submitDays": 1, "reviewDays": 5, "needsShared": ["abstraction"] },
{ "id": "poki", "name": "Poki", "integrateManDays": 6, "submitDays": 1, "reviewDays": 7, "needsShared": ["abstraction"] },
{ "id": "crazygames", "name": "CrazyGames", "integrateManDays": 5, "submitDays": 1, "reviewDays": 6, "needsShared": ["abstraction"] },
{ "id": "fb_instant", "name": "Facebook Instant Games", "integrateManDays": 12, "submitDays": 2, "reviewDays": 10, "needsShared": ["abstraction", "i18n", "compliance"] },
{ "id": "yt_playables", "name": "YouTube Playables", "integrateManDays": 9, "submitDays": 2, "reviewDays": 14, "needsShared": ["abstraction", "i18n", "compliance"] }
]
}
D 层:关键路径扫描
下面这段是 D 层扫描脚本 sim/g15_timeline.py。它读上面那份配置,把每个共享任务与每平台的接入/提审/审核装成节点,用关键路径法(CPM)算最早完成 EF、松弛 slack 与总工期 makespan。
#!/usr/bin/env python3
# file: sim/g15_timeline.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g15_timeline.py --config config/ --out out/
"""出海接入时间线扫描(D 层:计划表 / lint 校验)。
读 config/g15_timeline.json,把「共享前置任务 + 每平台接入→提审→审核」装配成依赖图,
用关键路径法(CPM)算工期与关键路径:
node 集合 = 每个 sharedTask 一个节点 + 每个平台三个节点(integrate / submit / review)
integrate 依赖该平台 needsShared 里的全部共享节点;submit 依赖 integrate;review 依赖 submit
ES(v) = max(EF(前驱)) EF(v) = ES(v) + dur(v)
makespan = max(EF(v))
slack(v) = LS(v) - ES(v) LS(v) = LF(v) - dur(v),LF(v) = min(LS(后继)),无后继节点 LF = makespan
关键路径 = 从 EF 最大的终点回溯 EF(前驱) == ES(当前) 的链
口径:人天、提审与审核时长是「公开可观察机制」口径的建模假设,观察时点 2026-10;真实接入
人天、审核时长与驳回往返需真实业务环境,命令行不可复现(X)。产出 JSON + CSV,全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
def build_nodes(cfg: dict) -> dict:
nodes: dict = {t["id"]: {"id": t["id"], "name": t["name"], "dur": t["manDays"], "deps": []}
for t in cfg["sharedTasks"]}
for p in cfg["platforms"]:
pid = p["id"]
nodes[f"{pid}.integrate"] = {"id": f"{pid}.integrate", "name": f"{p['name']} 接入",
"dur": p["integrateManDays"], "deps": list(p["needsShared"])}
nodes[f"{pid}.submit"] = {"id": f"{pid}.submit", "name": f"{p['name']} 提审",
"dur": p["submitDays"], "deps": [f"{pid}.integrate"]}
nodes[f"{pid}.review"] = {"id": f"{pid}.review", "name": f"{p['name']} 审核",
"dur": p["reviewDays"], "deps": [f"{pid}.submit"]}
return nodes
def cpm(nodes: dict) -> dict:
succ: dict = {nid: [] for nid in nodes}
for nid, n in nodes.items():
for d in n["deps"]:
succ[d].append(nid)
indeg = {nid: len(n["deps"]) for nid, n in nodes.items()}
order, ready = [], sorted(nid for nid, k in indeg.items() if k == 0)
while ready:
nid = ready.pop(0)
order.append(nid)
for s in sorted(succ[nid]):
indeg[s] -= 1
if indeg[s] == 0:
ready.append(s)
ready.sort()
es, ef = {}, {}
for nid in order:
es[nid] = max((ef[d] for d in nodes[nid]["deps"]), default=0)
ef[nid] = es[nid] + nodes[nid]["dur"]
makespan = max(ef.values())
ends = sorted(nid for nid in nodes if not succ[nid])
lf = {nid: makespan for nid in ends}
ls = {}
for nid in reversed(order):
if nid not in lf:
lf[nid] = min(ls[s] for s in succ[nid])
ls[nid] = lf[nid] - nodes[nid]["dur"]
slack = {nid: ls[nid] - es[nid] for nid in nodes}
end = sorted(ends, key=lambda n: (-ef[n], n))[0]
path, cur = [end], end
while nodes[cur]["deps"]:
prev = sorted(d for d in nodes[cur]["deps"] if ef[d] == es[cur])[0]
cur = prev
path.append(cur)
path.reverse()
return {"es": es, "ef": ef, "ls": ls, "slack": slack, "makespan": makespan, "path": path}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
with open(os.path.join(a.config, "g15_timeline.json"), encoding="utf-8") as f:
cfg = json.load(f)
nodes = build_nodes(cfg)
r = cpm(nodes)
rows = [{"id": nid, "name": nodes[nid]["name"], "dur": nodes[nid]["dur"],
"es": r["es"][nid], "ef": r["ef"][nid], "ls": r["ls"][nid], "slack": r["slack"][nid],
"critical": 1 if r["slack"][nid] == 0 else 0} for nid in sorted(nodes)]
digest = (f'makespan={r["makespan"]}|nodes={len(nodes)}|critical={" > ".join(r["path"])}'
f'|zeroSlack={sum(x["critical"] for x in rows)}')
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "g15_timeline.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "dur", "es", "ef", "ls", "slack", "critical"])
for x in rows:
w.writerow([x[k] for k in ("id", "name", "dur", "es", "ef", "ls", "slack", "critical")])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"makespan": r["makespan"], "nodeCount": len(nodes), "criticalPath": r["path"],
"digest": digest, "tasks": rows, "csv": os.path.join(a.out, "g15_timeline.csv")}
with open(os.path.join(a.out, "g15_timeline.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"makespan": r["makespan"], "criticalPath": r["path"], "digest": digest,
"tasks": rows}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g15_timeline.py --config config/ --out out/,结果是(同机相对量级,绝对数值不可移植):总工期 33 天,关键路径是 abstraction → yt_playables.integrate → yt_playables.submit → yt_playables.review。关键路径上的四个节点松弛全为 0:
| 节点 | 工期 | ES | EF | slack |
|---|---|---|---|---|
| abstraction(共享) | 8 | 0 | 8 | 0 |
| yt_playables.integrate | 9 | 8 | 17 | 0 |
| yt_playables.submit | 2 | 17 | 19 | 0 |
| yt_playables.review | 14 | 19 | 33 | 0 |
其余节点都有松弛:fb_instant 三段各 slack 1(几乎贴住关键路径,是第二风险点),poki.integrate slack 11,gamedistribution.integrate slack 15。决定总工期的是两件事:共享的抽象层(8 天,且在关键路径上),以及 Playables 长达 14 天的审核——接入只要 9 天,审核却比接入更久。
P 层内核:关键路径的跨语言对拍
一张表看不出「是算对了」还是「碰巧看着对」。下面这段是 P 层 TypeScript 内核 core/src/g15/timeline.ts:它读同一份 config,用同一套 CPM 复算工期与关键路径,产出与 Python 逐字符一致的摘要。
// file: core/src/g15/timeline.ts
// tier: P
// verify: node --test 'core/test/g15_*.test.ts'
//
// 出海接入时间线内核:与 sim/g15_timeline.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g15_timeline.json,用关键路径法(CPM)算工期与关键路径。
// 全整数、无浮点、无随机(禁 Math.random;只用可擦除语法,禁 enum / namespace)。
export interface SharedTask { readonly id: string; readonly name: string; readonly manDays: number }
export interface PlatformPlan {
readonly id: string; readonly name: string; readonly integrateManDays: number;
readonly submitDays: number; readonly reviewDays: number; readonly needsShared: readonly string[];
}
export interface TimelineConfig {
readonly version: number; readonly game: string;
readonly sharedTasks: readonly SharedTask[]; readonly platforms: readonly PlatformPlan[];
}
export interface Node { readonly id: string; readonly name: string; readonly dur: number; readonly deps: readonly string[] }
export interface TaskRow {
readonly id: string; readonly name: string; readonly dur: number;
readonly es: number; readonly ef: number; readonly ls: number; readonly slack: number;
readonly critical: boolean;
}
export interface CpmResult { readonly tasks: readonly TaskRow[]; readonly makespan: number; readonly criticalPath: readonly string[] }
export function buildNodes(cfg: TimelineConfig): Map<string, Node> {
const nodes = new Map<string, Node>();
for (const t of cfg.sharedTasks) nodes.set(t.id, { id: t.id, name: t.name, dur: t.manDays, deps: [] });
for (const p of cfg.platforms) {
nodes.set(`${p.id}.integrate`, { id: `${p.id}.integrate`, name: `${p.name} 接入`, dur: p.integrateManDays, deps: [...p.needsShared] });
nodes.set(`${p.id}.submit`, { id: `${p.id}.submit`, name: `${p.name} 提审`, dur: p.submitDays, deps: [`${p.id}.integrate`] });
nodes.set(`${p.id}.review`, { id: `${p.id}.review`, name: `${p.name} 审核`, dur: p.reviewDays, deps: [`${p.id}.submit`] });
}
return nodes;
}
export function cpm(nodes: Map<string, Node>): CpmResult {
const succ = new Map<string, string[]>();
const indeg = new Map<string, number>();
for (const id of nodes.keys()) { succ.set(id, []); indeg.set(id, 0); }
for (const n of nodes.values()) for (const d of n.deps) { succ.get(d)!.push(n.id); indeg.set(n.id, indeg.get(n.id)! + 1); }
const order: string[] = [];
const ready = [...indeg.keys()].filter((id) => indeg.get(id) === 0).sort();
while (ready.length) {
const id = ready.shift()!;
order.push(id);
for (const s of [...succ.get(id)!].sort()) {
indeg.set(s, indeg.get(s)! - 1);
if (indeg.get(s) === 0) { ready.push(s); ready.sort(); }
}
}
const es = new Map<string, number>(); const ef = new Map<string, number>();
for (const id of order) {
let start = 0;
for (const d of nodes.get(id)!.deps) start = Math.max(start, ef.get(d)!);
es.set(id, start); ef.set(id, start + nodes.get(id)!.dur);
}
const makespan = Math.max(...[...ef.values()]);
const ends = [...nodes.keys()].filter((id) => succ.get(id)!.length === 0).sort();
const lf = new Map<string, number>(); const ls = new Map<string, number>();
for (const id of ends) lf.set(id, makespan);
for (const id of [...order].reverse()) {
if (!lf.has(id)) lf.set(id, Math.min(...succ.get(id)!.map((s) => ls.get(s)!)));
ls.set(id, lf.get(id)! - nodes.get(id)!.dur);
}
const tasks: TaskRow[] = [...nodes.keys()].sort().map((id) => ({
id, name: nodes.get(id)!.name, dur: nodes.get(id)!.dur, es: es.get(id)!, ef: ef.get(id)!,
ls: ls.get(id)!, slack: ls.get(id)! - es.get(id)!, critical: ls.get(id)! - es.get(id)! === 0,
}));
const endNode = [...ends].sort((a, b) => ef.get(b)! - ef.get(a)!)[0];
const path: string[] = [endNode];
let cur = endNode;
while (nodes.get(cur)!.deps.length) {
cur = nodes.get(cur)!.deps.filter((d) => ef.get(d) === es.get(cur)).sort()[0];
path.push(cur);
}
path.reverse();
return { tasks, makespan, criticalPath: path };
}
/** 跨语言对拍摘要:与 sim/g15_timeline.py 的 digest 逐字符一致。 */
export function digest(r: CpmResult): string {
return `makespan=${r.makespan}|nodes=${r.tasks.length}|critical=${r.criticalPath.join(" > ")}`
+ `|zeroSlack=${r.tasks.filter((t) => t.critical).length}`;
}
配套测试 core/test/g15_timeline.test.ts 把 Python 落盘的摘要写成金标准。在本机跑 node --test 'core/test/g15_timeline.test.ts',6 个用例全过;两套独立实现给出同一个摘要:
digest = makespan=33|nodes=18|critical=abstraction > yt_playables.integrate > yt_playables.submit > yt_playables.review|zeroSlack=4
只要有一边的拓扑排序、松弛方向或关键路径回溯写错,摘要立刻分叉。
时间线怎么压缩:动关键路径,不动松弛
算完时间线,最有用的动作不是「催所有人加班」,而是只在关键路径上想办法。当前关键路径是「抽象层 8 天 → Playables 接入 9 天 → 提审 2 天 → 审核 14 天」,共 33 天。三条压缩路径按性价比排序:
- 共享抽象层先做、并行做:抽象层在关键路径最前端,它晚一天,全部五平台晚一天。把 i18n(slack 3)与合规(slack 2)并行铺开,不要排在抽象层后面串行。
- Playables 最先接:它的审核 14 天最长,接入又依赖三个共享任务。一旦三个共享任务完成,第一件事就是提 Playables,让最长的审核尽早开始。
fb_instant留缓冲:它三段 slack 都只有 1,几乎贴住关键路径;任何一次审核驳回都会把它顶到关键路径上。给它留至少一周缓冲。
非关键平台的松弛是「可以晚开工」的额度,不是「可以摸鱼」的许可——GameDistribution slack 15、Poki slack 11,意味着这两个平台的接入可以排到抽象层之后任意时间点开工,不影响总工期。把工程人力优先堆到关键路径上,才是压缩时间线的正解。
X 层:真实审核时长不可复现
边界必须划清。上面算的是时间线模型:给定人天与审核时长,工期与关键路径是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 各平台真实的接入人天(取决于团队熟悉度、平台文档质量、SDK 版本变更);
- 各平台真实的审核时长与驳回往返(同一平台不同时段差异极大,且随审核队列波动);
- 各平台真实的「必须完成哪些共享任务」清单(平台要求随条款更新而变)。
工程上正确的做法是:把可复现的时间线模型与不可复现的真实时长分开——模型回答「给定这些时长,工期与关键路径是什么」,真实时长用来在接入后反标定(把实测人天与审核天数填回 config,时间线立刻重排)。这也是本章后续两节要做的事:先把组合接进去,再用本地化与合规落地 的门禁把每个平台送审,最后用多平台数据表现 对照结果。
自检表
| 检查项 | 合格标准 |
|---|---|
| 组合有出处 | 五平台组合沿用第 1 章结论,不重新选一遍 |
| 依赖图完整 | 共享任务 + 每平台三阶段都在图里,依赖显式 |
| 关键路径可算 | 给出 makespan 与关键路径节点序列,可复算 |
| 松弛可见 | 非关键节点给出 slack,说明谁可以晚开工 |
| 跨语言一致 | Python 与 TS 两套实现的摘要逐字符相等 |
| 边界清晰 | 真实接入人天与审核时长标 X,不写成「实测」 |
时间线最容易被误解的一点,是把它当成一张甘特图。它真正回答的是**「谁在挡路」**——关键路径上的节点挡路,松弛节点不挡路。下一节我们处理「接进去之后、提审之前」的最后一关:本地化与合规的落地清单,以及只有全部必做项完成才能送审的门禁。
阅读导航:上一节:目录 · 下一节:15.2 本地化与合规落地 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。