13.1 版本列车与内容节奏

把发版从「攒够了再发」改成「到点就发」:每 14 天一班车,冻结线一到就不再收新功能,赶不上的顺延下一班。本节给出一份可被程序消费的版本列车配置、一段做首次适配装车与装载率统计的 S 层脚本,以及一套与 Python 逐位一致的 TS 装车内核,让「这一班车装了什么、下一班还剩多少」变成能算出来的数字。

站内有一篇 小游戏开发综述 ,那是单篇综述口径——96 行讲引擎选型 / 性能 / 变现的横向扫面;本套是成体系 + 可运行,同样谈小游戏,那篇给全景,本节给的是《合成小岛》上线之后怎么排版本的一台可跑机器。边界先说清:第 12 章讲活动与赛季(见 赛季结构与节奏 ),那是「一场活动怎么设计」;本章不碰活动本身,只解决更上游的节拍问题——什么时候发版、一班车装多少、赶不上的怎么办。这两件事经常被混为一谈:赛季是内容,版本列车是内容的运输系统,运输系统错了,再好的内容也堵在路上。

发版不是里程碑,是节拍

大多数小团队的发版方式是「攒够了再发」:想做的功能都做完了,测试一轮,打包提交,等审核,上线。这在项目早期没问题——反正也没几个人玩。但进入长线运营后它会暴露两个致命问题:一是不可预测,市场与买量团队没法提前知道哪一周有新内容,运营排期全部悬空;二是没有截止线,功能会无限膨胀,「再打磨一下」永远比「发出去」更有吸引力,一个版本拖三个月很常见。

版本列车(release train)把发版从「里程碑」改成「节拍」:车到点就开,不等任何人。节奏定死之后反而带来三个好处——团队有了确定的截止线(冻结线一到就不再收功能),运营与买量有了确定的排期锚点,用户有了确定的期待(每周三有新内容)。这一节的核心不是「发得多快」,而是「发得多稳」。

列车的三样东西:节奏、车厢、冻结线

一台版本列车只需要三样东西就能描述清楚。

节奏(cadence):多久发一班车。《合成小岛》定的是每 14 天一班——短到用户不会忘记你,长到团队来得及做出一批可感知的内容。节奏一旦定下,就不因为「这个版本内容多」而提前或延后;内容多就多装几件,内容少就少装几件,车期不变。

车厢(capacity):一班车能装多少研发人日。本节的模型里,不同类型的车车厢不同:补丁版 8 人日、小版本 16 人日、大版本 28 人日。车厢容量是约束,不是目标——它的作用是逼团队在冻结线前做取舍,而不是让功能无限往一个版本里塞。

冻结线(freeze):发车日往回倒的一个时间点,之后不再接收新功能,只做测试与修 bug。大版本提前 14 天冻结、小版本提前 7 天、补丁提前 3 天——版本越大,冻结越早,因为要留出更长的回归测试窗口。冻结线是整台列车纪律的核心:它把「还能不能再加一个功能」这个无休止的争论,变成一个日期。

D 层:把列车写成配置

下面这份是 D 层(静态层)配置,全整数。它声明了节奏(cadenceDays)、车厢类型(kinds,各带容量与冻结提前量),以及 6 槽一循环的发车类型序列(cycleKinds,每 6 槽一个大版本),最后是一份 24 项的功能待办(backlog),每项带优先级、研发人日与价值分。

{
  "version": 1,
  "series": "《小游戏开发实战》",
  "volume": "卷三《上线变现与长线运营》",
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "版本列车配置(D 层)。列车按固定节奏发车:每 cadenceDays 天一个发车槽(slot),槽的版本类型由 cycleKinds 循环决定(每 6 槽一大版本)。kinds 给每种类型一个研发容量 capacityDevDays 与冻结提前量 freezeLeadDays(冻结日 = 发车日 - 提前量)。backlog 是待排功能,effortDevDays 为研发人日、priority 越小越优先。全整数,禁浮点。容量与工作量均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。",
  "horizonDays": 364,
  "cadenceDays": 14,
  "cycleKinds": ["patch", "minor", "patch", "minor", "patch", "major"],
  "kinds": [
    { "id": "patch", "name": "补丁版本", "capacityDevDays": 8, "freezeLeadDays": 3 },
    { "id": "minor", "name": "小版本", "capacityDevDays": 16, "freezeLeadDays": 7 },
    { "id": "major", "name": "大版本", "capacityDevDays": 28, "freezeLeadDays": 14 }
  ],
  "backlog": [
    { "id": "tutorial-redo", "name": "新手引导重做", "priority": 1, "effortDevDays": 28, "value": 95 },
    { "id": "offline-balance", "name": "离线收益平衡", "priority": 1, "effortDevDays": 16, "value": 80 },
    { "id": "merge-feel", "name": "合成手感优化", "priority": 1, "effortDevDays": 8, "value": 70 },
    { "id": "season-pass", "name": "赛季通行证", "priority": 2, "effortDevDays": 28, "value": 85 },
    { "id": "skin-pack-a", "name": "装扮皮肤包 A", "priority": 2, "effortDevDays": 16, "value": 60 },
    { "id": "daily-quest", "name": "每日任务系统", "priority": 2, "effortDevDays": 24, "value": 75 },
    { "id": "starter-levels", "name": "新手关卡包", "priority": 2, "effortDevDays": 16, "value": 65 },
    { "id": "friend-rank", "name": "好友排行榜", "priority": 3, "effortDevDays": 16, "value": 55 },
    { "id": "tide-levels", "name": "关卡包·潮汐", "priority": 3, "effortDevDays": 28, "value": 50 },
    { "id": "audio-haptic", "name": "音效与震动打磨", "priority": 3, "effortDevDays": 8, "value": 40 },
    { "id": "merge-anim", "name": "合成动画升级", "priority": 3, "effortDevDays": 16, "value": 45 },
    { "id": "share-2", "name": "分享裂变 2.0", "priority": 4, "effortDevDays": 24, "value": 45 },
    { "id": "cloud-save", "name": "云存档迁移", "priority": 4, "effortDevDays": 8, "value": 35 },
    { "id": "achievement", "name": "成就系统", "priority": 4, "effortDevDays": 24, "value": 40 },
    { "id": "idle-showcase", "name": "离线挂机展示", "priority": 4, "effortDevDays": 16, "value": 30 },
    { "id": "skin-pack-b", "name": "装扮皮肤包 B", "priority": 5, "effortDevDays": 16, "value": 30 },
    { "id": "festival-levels", "name": "节日主题关卡", "priority": 5, "effortDevDays": 28, "value": 35 },
    { "id": "world-channel", "name": "世界频道", "priority": 5, "effortDevDays": 8, "value": 25 },
    { "id": "checkin", "name": "签到系统", "priority": 5, "effortDevDays": 16, "value": 25 },
    { "id": "flash-shop", "name": "限时商店", "priority": 5, "effortDevDays": 24, "value": 30 },
    { "id": "memory-album", "name": "回忆相册", "priority": 6, "effortDevDays": 16, "value": 20 },
    { "id": "share-card", "name": "成就分享卡", "priority": 6, "effortDevDays": 8, "value": 15 },
    { "id": "friend-gift", "name": "好友互赠", "priority": 6, "effortDevDays": 16, "value": 15 },
    { "id": "wardrobe", "name": "装扮试衣间", "priority": 6, "effortDevDays": 24, "value": 20 }
  ]
}

S 层:装车仿真

配置写完,让机器来装车。下面这段是 S 层脚本:先生成 26 个发车槽,再把待办按优先级升序做首次适配(first-fit)装车——每个功能装进「剩余容量够」的最早一班车,装不下的顺延,一年内都装不下的记为滞留(carryover)。产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l13_train.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l13_train.py --config config/ --out out/
"""版本列车仿真:读 config/l13_train.json。

按固定节奏生成发车槽(每 cadenceDays 天一个,类型由 cycleKinds 循环决定),
再把 backlog 按 priority 升序做首次适配(first-fit)装车:每个功能装进
「剩余容量 >= 工作量」的最早一个槽;装不下的顺延到后面的槽,全部装不下则记
为滞留(carryover)。全整数地板除。产出 JSON + CSV + SVG。

容量与工作量均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

KIND_COLOR = {"patch": "#10b981", "minor": "#2563eb", "major": "#f59e0b"}


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


def build_trains(cfg: dict) -> list[dict]:
    """按 cadenceDays 生成发车槽,类型由 cycleKinds 循环决定。"""
    kinds = {k["id"]: k for k in cfg["kinds"]}
    cycle = cfg["cycleKinds"]
    slots, day, i = [], 0, 0
    while day < cfg["horizonDays"]:
        kid = cycle[i % len(cycle)]
        k = kinds[kid]
        slots.append({
            "index": i, "day": day, "kind": kid, "name": k["name"],
            "capacity": k["capacityDevDays"], "freezeDay": day - k["freezeLeadDays"],
            "used": 0, "features": [],
        })
        i += 1
        day = i * cfg["cadenceDays"]
    return slots


def pack(cfg: dict, slots: list[dict]) -> list[dict]:
    """priority 升序(同优先级按 id 升序)首次适配装车。"""
    order = sorted(cfg["backlog"], key=lambda f: (f["priority"], f["id"]))
    for f in order:
        for s in slots:
            if s["capacity"] - s["used"] >= f["effortDevDays"]:
                s["used"] += f["effortDevDays"]
                s["features"].append(f["id"])
                break
    return order


def summarize(cfg: dict, slots: list[dict], order: list[dict]) -> dict:
    placed = {fid for s in slots for fid in s["features"]}
    carry = [f for f in order if f["id"] not in placed]
    total_value = sum(f["value"] for f in order)
    shipped_value = sum(f["value"] for f in order if f["id"] in placed)
    total_cap = sum(s["capacity"] for s in slots)
    total_used = sum(s["used"] for s in slots)
    util = [s["used"] * 1000 // s["capacity"] for s in slots]
    return {
        "trainCount": len(slots),
        "capacityDevDays": total_cap,
        "usedDevDays": total_used,
        "avgUtilizationPermille": total_used * 1000 // total_cap if total_cap else 0,
        "shippedCount": len(placed),
        "carryoverCount": len(carry),
        "carryoverIds": [f["id"] for f in carry],
        "valueCapturePermille": shipped_value * 1000 // total_value if total_value else 0,
        "idleSlots": sum(1 for s in slots if s["used"] == 0),
        "fullSlots": sum(1 for s in slots if util[s["index"]] >= 950),
    }


def draw_svg(path: str, slots: list[dict], summary: dict) -> int:
    W, L, BAR, GAP = 780, 150, 18, 5
    H = 70 + len(slots) * (BAR + GAP) + 30
    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"《合成小岛》版本列车发车图(平均装载 {summary['avgUtilizationPermille']}‰,滞留 {summary['carryoverCount']} 项,示例参数)"
    span = W - L - 90
    for s in slots:
        y = 46 + s["index"] * (BAR + GAP)
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 14), "font-size": "11", "fill": "#374151"}).text = \
            f"D{s['day']:>3} {s['kind']}"
        ET.SubElement(svg, "rect", {"x": str(L), "y": str(y), "width": str(span * s["capacity"] // 36),
                                    "height": str(BAR), "rx": "3", "fill": "#e5e7eb"})
        w = span * s["used"] // 36
        ET.SubElement(svg, "rect", {"x": str(L), "y": str(y), "width": str(max(w, 1)),
                                    "height": str(BAR), "rx": "3", "fill": KIND_COLOR.get(s["kind"], "#6b7280")})
        ET.SubElement(svg, "text", {"x": str(L + span * s["capacity"] // 36 + 6), "y": str(y + 14),
                                    "font-size": "11", "fill": "#6b7280"}).text = f"{s['used']}/{s['capacity']}"
    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)
    slots = build_trains(cfg)
    order = pack(cfg, slots)
    summary = summarize(cfg, slots, order)
    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l13_train.svg"), slots, summary)
    with open(os.path.join(a.out, "l13_train_schedule.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["index", "day", "freezeDay", "kind", "capacity", "used", "utilizationPermille", "features"])
        for s in slots:
            w.writerow([s["index"], s["day"], s["freezeDay"], s["kind"], s["capacity"], s["used"],
                        s["used"] * 1000 // s["capacity"], "|".join(s["features"])])
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "cadenceDays": cfg["cadenceDays"], **summary, "svgBytes": sb,
              "trains": [{"index": s["index"], "day": s["day"], "kind": s["kind"], "used": s["used"],
                          "capacity": s["capacity"]} for s in slots]}
    with open(os.path.join(a.out, "l13_train.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({k: summary[k] for k in (
        "trainCount", "capacityDevDays", "usedDevDays", "avgUtilizationPermille",
        "shippedCount", "carryoverCount", "carryoverIds", "valueCapturePermille",
        "idleSlots", "fullSlots")} | {"svgBytes": sb}, ensure_ascii=False, indent=2))
    return 0


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

在本机跑它(假设参数),输出如下(同机相对量级,绝对数值不可移植):

{
  "trainCount": 26,
  "capacityDevDays": 360,
  "usedDevDays": 292,
  "avgUtilizationPermille": 811,
  "shippedCount": 18,
  "carryoverCount": 6,
  "carryoverIds": [
    "achievement",
    "share-2",
    "festival-levels",
    "flash-shop",
    "memory-album",
    "wardrobe"
  ],
  "valueCapturePermille": 824,
  "idleSlots": 8,
  "fullSlots": 17,
  "svgBytes": 7351
}

读这组数字:26 班车总容量 360 人日,实际装载 292,平均装载率 811‰;24 项待办里 18 项上了车、6 项滞留,价值捕获 824‰(滞留的主要是中低优先级的大件)。更值得看的是另外两个数:8 个空槽、17 个满槽。空槽全是补丁版的小车厢——待办里的小功能装完后,小车厢就没人上了;满槽则是大版本被高优先级大件一次占满。这正暴露了固定节奏的一个真实副作用:车厢容量与功能粒度不匹配,会同时出现「有车没货」和「有货没车」。

还有一条藏在结果里的纪律。滞留的 6 项里,memory-album(回忆相册,16 人日)明明能装进小版本车厢,为什么也滞留了?因为按优先级升序装车时,排在它前面的功能先把它能上的空位占满了,等轮到它,全年车厢都已装满。首次适配的代价是低优先级功能可能永远上不了车——这不是 bug,是「优先级」这个抽象在真实排期里必须落到「具体哪一班车」的必然结果。真正上线的团队会在冻结前用这份表做一次人工重排。

P 层:装车内核

同一套装车逻辑,真到排期看板上要反复试算「加一个功能会挤掉谁」,就不适合每次跑 Python 脚本了。下面这段是 P 层 TypeScript 内核,与上面的 Python 同一份逻辑:buildTrains 生成发车槽、packTrains 首次适配装车、summarize 汇总装载率与滞留。全整数地板除,结果与 Python 逐位一致——avgUtilizationPermille 811、carryoverCount 6、valueCapturePermille 824,两边差一位就说明有一边写错了。

// file: core/src/l13/train.ts
// tier: P
// verify: node --test 'core/test/l13_*.test.ts'
//
// 版本列车内核:与 sim/l13_train.py 同一份 config、同一套首次适配(first-fit)装车逻辑,
// 结果逐位一致(容量 / 装载 / 滞留 / 价值捕获)。也是 13.1 排期看板的唯一逻辑源。
// 全整数、无 Math.random()、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface TrainKind { readonly id: string; readonly name: string; readonly capacityDevDays: number; readonly freezeLeadDays: number; }
export interface TrainFeature { readonly id: string; readonly name: string; readonly priority: number; readonly effortDevDays: number; readonly value: number; }
export interface TrainConfig {
  readonly horizonDays: number; readonly cadenceDays: number; readonly cycleKinds: readonly string[];
  readonly kinds: readonly TrainKind[]; readonly backlog: readonly TrainFeature[];
}
export interface Slot { index: number; day: number; kind: string; name: string; capacity: number; freezeDay: number; used: number; features: string[]; }
export interface TrainSummary {
  readonly trainCount: number; readonly capacityDevDays: number; readonly usedDevDays: number;
  readonly avgUtilizationPermille: number; readonly shippedCount: number; readonly carryoverCount: number;
  readonly carryoverIds: string[]; readonly valueCapturePermille: number; readonly idleSlots: number; readonly fullSlots: number;
}

const fdiv = (a: number, b: number): number => Math.floor(a / b);

/** 按 cadenceDays 生成发车槽,类型由 cycleKinds 循环决定;冻结日 = 发车日 - 提前量。 */
export function buildTrains(cfg: TrainConfig): Slot[] {
  const kinds = new Map(cfg.kinds.map((k) => [k.id, k]));
  const cycle = cfg.cycleKinds, slots: Slot[] = [];
  for (let i = 0, day = 0; day < cfg.horizonDays; i += 1, day = i * cfg.cadenceDays) {
    const kid = cycle[i % cycle.length], k = kinds.get(kid)!;
    slots.push({ index: i, day, kind: kid, name: k.name, capacity: k.capacityDevDays,
      freezeDay: day - k.freezeLeadDays, used: 0, features: [] });
  }
  return slots;
}

/** priority 升序(同优先级按 id 升序)首次适配装车:装进「剩余容量 >= 工作量」的最早一个槽。 */
export function packTrains(cfg: TrainConfig, slots: Slot[]): TrainFeature[] {
  const order = [...cfg.backlog].sort((a, b) => a.priority - b.priority || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  for (const f of order) {
    for (const s of slots) {
      if (s.capacity - s.used >= f.effortDevDays) { s.used += f.effortDevDays; s.features.push(f.id); break; }
    }
  }
  return order;
}

/** 汇总:装载率、滞留、价值捕获、空槽 / 满槽数。 */
export function summarize(cfg: TrainConfig, slots: Slot[], order: TrainFeature[]): TrainSummary {
  const placed = new Set(slots.flatMap((s) => s.features));
  const carry = order.filter((f) => !placed.has(f.id));
  const totalValue = order.reduce((a, f) => a + f.value, 0);
  const shippedValue = order.reduce((a, f) => a + (placed.has(f.id) ? f.value : 0), 0);
  const cap = slots.reduce((a, s) => a + s.capacity, 0);
  const used = slots.reduce((a, s) => a + s.used, 0);
  return {
    trainCount: slots.length, capacityDevDays: cap, usedDevDays: used,
    avgUtilizationPermille: cap ? fdiv(used * 1000, cap) : 0,
    shippedCount: placed.size, carryoverCount: carry.length, carryoverIds: carry.map((f) => f.id),
    valueCapturePermille: totalValue ? fdiv(shippedValue * 1000, totalValue) : 0,
    idleSlots: slots.filter((s) => s.used === 0).length,
    fullSlots: slots.filter((s) => fdiv(s.used * 1000, s.capacity) >= 950).length,
  };
}

配套的 core/test/l13_train.test.ts 把四件事钉死:发车槽数量与冻结日、装载 / 滞留 / 价值捕获与 Python 逐位一致、高优先级大件抢占大版本槽、以及「同配置两次装车结果相同」。跑 node --test 'core/test/l13_*.test.ts',4 个用例全过。

小结与下一步

这一节把发版从「攒够了再发」改成「到点就发」:节奏 14 天一班、车厢按版本类型分 8 / 16 / 28 人日、冻结线越大的版本越早。装车仿真给出了 811‰ 的平均装载率、6 项滞留、824‰ 的价值捕获,也暴露了「车厢与功能粒度不匹配」的真实副作用。**版本列车的纪律只有一条:车期不变,功能排队。**下一节解决「车上的货从哪来」——内容产能与排期。

阅读导航:上一节:目录 · 下一节:13.2 内容产能与排期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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