15.1 平台组合与接入时间线

前 14 章把选平台、接 SDK、做本地化、算收入的方法都讲完了,本节开始把它们装配成《合成小岛》的真实出海路径。第一段是时间线:五平台组合已经选定,接下来是「按什么顺序接、要多久」。本节把「共享前置任务 + 每平台接入/提审/审核」装成一张依赖图,用关键路径法算工期,并给出可跑的 Python 扫描与 TypeScript 内核的跨语言对拍。

站内有一篇 H5 游戏产业全景 ,那是 H5 产业的行业综述——技术栈、生态、变现、政策一网打尽;卷三《上线变现与长线运营》 讲的是国内上线与长线运营的完整方法。本章不重做行业综述、也不重做国内运营方法论,它只做一件事:把前 14 章的方法装配成《合成小岛》的出海案例——按顺序调用、排出时间线、落地清单、对照结果。本节是案例的第一段:五平台组合已经选定,接下来是「按什么顺序接、要多久」。

案例的装配起点:组合已定,问题变成顺序

第 1 章已经用加权评分 + 预算装箱选出了《合成小岛》的出海组合,见 1.3 平台选择与组合策略 :GameDistribution + Poki + CrazyGames + Facebook Instant Games + YouTube Playables,五个平台、36 个人天。但「选出来」和「接进去」之间还差一件东西——顺序。五平台不是五个孤立的接入任务:它们共享一层平台抽象层 、一套构建矩阵 、一份多语言资源管线。共享任务先做完,平台才能接;而每个平台又要「接入 → 提审 → 审核」三步串行。这些先后关系画出来就是一张依赖图,工期就是图里最长的那条链。

五平台组合与各自的工期假设

沿用第 1 章的组合与接入人天,再补上「提审准备」与「审核等待」两段(口径是公开可观察机制的建模假设,观察时点 2026-10,不是任何平台后台实测):

平台接入人天提审天审核天依赖的共享任务
GameDistribution415抽象层
Poki617抽象层
CrazyGames516抽象层
Facebook Instant Games12210抽象层 + i18n + 合规
YouTube Playables9214抽象层 + 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:

节点工期ESEFslack
abstraction(共享)8080
yt_playables.integrate98170
yt_playables.submit217190
yt_playables.review1419330

其余节点都有松弛: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 本地化与合规落地 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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