13.2 内容产能与排期

内容跑步机是长线运营的隐形天花板:玩家消耗内容的速度常常快过团队生产内容的速度。本节给出一份把编制、工时、单件成本与每周需求写成整数的产能配置,一段逐周推进库存、算出自给自足与内容债的 S 层脚本,让「这个团队每周能产出多少、够不够玩家消耗」变成能算出来的数字。

上一节解决的是「车怎么发」,这一节解决「车上的货从哪来」。内容产能(content capacity)是长线运营里最容易被低估的约束:玩家消耗内容的速度,往往快过团队生产内容的速度。这条「内容跑步机」如果一开始没算清楚,会在上线几个月后突然以「玩家说没东西可玩」的形式爆出来。边界:第 12 章讲活动设计(一场活动的奖励与节奏),本节只算团队每周到底能产出多少内容、够不够玩家消耗——它回答的是产能问题,不是内容创意问题。

内容跑步机:为什么产能是隐形天花板

新上线的游戏最容易犯的错,是把「内容够不够」当成一个主观问题,靠感觉回答。真实的机制是:玩家消耗内容的速度是可测的,团队生产内容的速度也是可测的,两者一比就是生死线。玩家每天通关几关、想要几个新皮肤、多久看腻一套装扮,这些都能从数据里算出来;团队每周能产出几个关卡、几套装扮、几个系统功能,也能从编制与工时里算出来。当后者小于前者,游戏就在净消耗库存——一开始靠上线时囤的内容撑一阵,囤货吃光那天,就是「没东西可玩」的爆发点。

这条曲线最阴险的地方在于它的延迟性:产能缺口不会立刻显现,因为上线时总有一批存货垫着。等到玩家开始抱怨,缺口已经积累了几周甚至几个月,而补人、外包、调整排期都需要时间,越晚发现越难补。所以产能必须在排期阶段就算清楚,而不是等运营告急。

产能怎么算:有效工时除以单件工时

产能的计算只有两步,但每一步都藏着容易被高估的地方。

第一步,算有效工时。 一个人每周 40 小时,但真正能产出可交付内容的时间远不到 40 小时——开会、沟通、返工、测试、以及「非产出性」事务都会吃掉时间。所以模型里每个工种带一个效率折扣(efficiencyPermille),比如关卡策划 750‰、美术 700‰、程序 850‰。有效工时 = 编制 × 周工时 × 折扣 ÷ 1000。这个折扣不是拍脑袋,而是从历史工时数据里回填的,高估产能最典型的错误就是直接拿满工时当产能。

第二步,算单件产出。 每类内容都有单件工时(hoursPerUnit):一个关卡 5 人时、一套装扮 12 人时、一个系统功能 30 人时。某工种的周产出 = 该工种有效工时 ÷ 该工种负责内容的单件工时,整数地板除。

两步算完,再和每周需求(demandPerWeek)一比,产能缺口(gap)就出来了。缺口为正说明这类内容自给自足、还能囤货;为负说明在吃库存。关键的一点:产能是分类型的——总的看「人够不够」没有意义,必须按内容类型分开看,因为关卡策划再闲也做不出美术的活儿。

D 层:把产能写成配置

下面这份是 D 层(静态层)配置,全整数。roles 给三个工种的编制、周工时与效率折扣;contentTypes 给三类内容各自的单件工时、负责工种、每周需求与期初库存。全整数地板除,禁浮点。

{
  "version": 1,
  "series": "《小游戏开发实战》",
  "volume": "卷三《上线变现与长线运营》",
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "内容产能配置(D 层)。roles 给每个工种的编制 headcount、每周工时 hoursPerWeek、有效产能折扣 efficiencyPermille;有效工时 = 编制 × 周工时 × 折扣 ÷ 1000。contentTypes 给每类内容的单件工时 hoursPerUnit(由 role 产出)与每周需求 demandPerWeek。initialInventory 为期初内容库存。全整数地板除,禁浮点。编制、工时、需求均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。",
  "horizonWeeks": 26,
  "roles": [
    { "id": "level-designer", "name": "关卡策划", "headcount": 2, "hoursPerWeek": 40, "efficiencyPermille": 750 },
    { "id": "artist", "name": "美术", "headcount": 1, "hoursPerWeek": 40, "efficiencyPermille": 700 },
    { "id": "engineer", "name": "程序", "headcount": 2, "hoursPerWeek": 40, "efficiencyPermille": 850 }
  ],
  "contentTypes": [
    { "id": "level", "name": "关卡", "role": "level-designer", "hoursPerUnit": 5, "demandPerWeek": 8, "initialInventory": 4 },
    { "id": "skin", "name": "装扮", "role": "artist", "hoursPerUnit": 12, "demandPerWeek": 3, "initialInventory": 6 },
    { "id": "feature", "name": "功能", "role": "engineer", "hoursPerUnit": 30, "demandPerWeek": 1, "initialInventory": 2 }
  ]
}

S 层:逐周库存仿真

配置写完,让机器逐周推库存。下面这段是 S 层脚本:先算每类内容的周产出,再逐周推进 inventory[t] = inventory[t-1] + production - demand;库存转负即出现「内容债」(玩家把库存吃光后无新内容可消费)。脚本还会为每个亏损类型算出「补齐缺口需要几个编制」。产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l13_capacity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l13_capacity.py --config config/ --out out/
"""内容产能与排期仿真:读 config/l13_capacity.json。

每个工种有效工时 = 编制 × 周工时 × 折扣 // 1000;每类内容周产出 = 有效工时 // 单件工时。
逐周推进库存:inventory[t] = inventory[t-1] + production - demand。库存转负即出现「内容债」
(玩家把库存吃光后无新内容可消费)。产出 JSON + CSV + SVG。全整数地板除。

编制、工时、需求均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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

TYPE_COLOR = {"level": "#10b981", "skin": "#2563eb", "feature": "#f59e0b"}


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


def effective_hours(role: dict) -> int:
    return role["headcount"] * role["hoursPerWeek"] * role["efficiencyPermille"] // 1000


def production(cfg: dict) -> dict[str, int]:
    """每类内容的周产出(件/周)。"""
    eff = {r["id"]: effective_hours(r) for r in cfg["roles"]}
    return {c["id"]: eff[c["role"]] // c["hoursPerUnit"] for c in cfg["contentTypes"]}


def headcount_needed(cfg: dict, ct: dict) -> int:
    """满足该类内容周需求所需的最少编制(向上取整,纯整数)。"""
    role = next(r for r in cfg["roles"] if r["id"] == ct["role"])
    need_hours = ct["demandPerWeek"] * ct["hoursPerUnit"]
    per_head = role["hoursPerWeek"] * role["efficiencyPermille"] // 1000
    return (need_hours + per_head - 1) // per_head


def simulate(cfg: dict) -> dict:
    prod = production(cfg)
    types = cfg["contentTypes"]
    inv = {c["id"]: c["initialInventory"] for c in types}
    weeks = []
    for w in range(cfg["horizonWeeks"]):
        row = {"week": w + 1}
        for c in types:
            inv[c["id"]] += prod[c["id"]] - c["demandPerWeek"]
            row[c["id"]] = inv[c["id"]]
        weeks.append(row)
    verdicts = []
    for c in types:
        gap = prod[c["id"]] - c["demandPerWeek"]
        deplete = next((r["week"] for r in weeks if r[c["id"]] < 0), 0)
        verdicts.append({
            "id": c["id"], "name": c["name"], "production": prod[c["id"]],
            "demand": c["demandPerWeek"], "gap": gap, "sustainable": gap >= 0,
            "depleteWeek": deplete, "endInventory": inv[c["id"]],
            "headcountHave": next(r["headcount"] for r in cfg["roles"] if r["id"] == c["role"]),
            "headcountNeed": headcount_needed(cfg, c),
        })
    return {"production": prod, "weeks": weeks, "verdicts": verdicts,
            "sustainableCount": sum(1 for v in verdicts if v["sustainable"])}


def draw_svg(path: str, res: dict, cfg: dict) -> int:
    types = cfg["contentTypes"]
    weeks = res["weeks"]
    W, L, R, B = 780, 60, 30, 40
    H = 90 + len(weeks) * R + B
    hi = max(max(r[c["id"]] for r in weeks) for c in types)
    lo = min(min(r[c["id"]] for r in weeks) for c in types)
    span = max(hi - lo, 1)
    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": "24", "font-size": "15", "fill": "#111827"}).text = \
        f"《合成小岛》内容库存曲线({res['sustainableCount']}/{len(types)} 类自给自足,示例参数)"
    for w_i, r in enumerate(weeks):
        y = 50 + w_i * R
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 14), "font-size": "10", "fill": "#6b7280"}).text = f"W{r['week']:>2}"
        for c in types:
            x = L + (r[c["id"]] - lo) * (W - L - 60) // span
            ET.SubElement(svg, "circle", {"cx": str(x), "cy": str(y + 8), "r": "3", "fill": TYPE_COLOR[c["id"]]})
    ET.SubElement(svg, "line", {"x1": str(L + (0 - lo) * (W - L - 60) // span), "y1": "46",
                                "x2": str(L + (0 - lo) * (W - L - 60) // span), "y2": str(46 + len(weeks) * R),
                                "stroke": "#ef4444", "stroke-dasharray": "4 3"})
    ET.SubElement(svg, "text", {"x": str(L), "y": str(46 + len(weeks) * R + 20), "font-size": "11", "fill": "#6b7280"}).text = \
        "蓝=装扮 绿=关卡 橙=功能;红虚线=库存 0(内容债)"
    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)
    res = simulate(cfg)
    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l13_capacity.svg"), res, cfg)
    with open(os.path.join(a.out, "l13_capacity_inventory.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = [c["id"] for c in cfg["contentTypes"]]
        w.writerow(["week"] + cols)
        for r in res["weeks"]:
            w.writerow([r["week"]] + [r[c] for c in cols])
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "horizonWeeks": cfg["horizonWeeks"], "production": res["production"],
              "sustainableCount": res["sustainableCount"], "typeCount": len(cfg["contentTypes"]),
              "verdicts": res["verdicts"], "svgBytes": sb,
              "endInventory": {c["id"]: res["verdicts"][i]["endInventory"] for i, c in enumerate(cfg["contentTypes"])}}
    with open(os.path.join(a.out, "l13_capacity.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"production": res["production"], "sustainableCount": res["sustainableCount"],
                      "typeCount": len(cfg["contentTypes"]), "verdicts": res["verdicts"],
                      "svgBytes": sb}, ensure_ascii=False, indent=2))
    return 0


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

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

{
  "production": {
    "level": 12,
    "skin": 2,
    "feature": 2
  },
  "sustainableCount": 2,
  "typeCount": 3,
  "verdicts": [
    {
      "id": "level",
      "name": "关卡",
      "production": 12,
      "demand": 8,
      "gap": 4,
      "sustainable": true,
      "depleteWeek": 0,
      "endInventory": 108,
      "headcountHave": 2,
      "headcountNeed": 2
    },
    {
      "id": "skin",
      "name": "装扮",
      "production": 2,
      "demand": 3,
      "gap": -1,
      "sustainable": false,
      "depleteWeek": 7,
      "endInventory": -20,
      "headcountHave": 1,
      "headcountNeed": 2
    },
    {
      "id": "feature",
      "name": "功能",
      "production": 2,
      "demand": 1,
      "gap": 1,
      "sustainable": true,
      "depleteWeek": 0,
      "endInventory": 28,
      "headcountHave": 2,
      "headcountNeed": 1
    }
  ],
  "svgBytes": 5914
}

读这组数字,三类内容里 2 类自给自足、1 类在吃库存。关卡每周产出 12、需求 8,富余 4 件,26 周后库存 108——关卡策划其实是闲的。功能每周产出 2、需求 1,富余 1 件,也自给自足。真正出问题的是装扮:美术只有 1 人,有效工时 28 人时/周,一套装扮 12 人时,一周只能出 2 套,而需求是 3 套——每周净亏 1 套,期初库存 6 套只够撑 7 周,26 周末变成 -20 套的内容债。脚本算出的 headcountNeed 也很直接:装扮要自给自足需要 2 名美术,现在只有 1 名。

这组数字最有价值的不是「缺人」,而是缺在哪个工种、缺多少。总编制 5 个人看起来不少,但瓶颈精确地落在美术上:关卡策划再多也补不上装扮的缺口。这正是「按类型算产能」的意义——如果不分类,你会得到「人力充足」的错误结论,然后在第 7 周被玩家的抱怨打脸。补齐方式有三种,脚本的数字直接支持决策:补 1 名美术(最直接)、把装扮单件工时从 12 压到 9 人时(提效,等于周产出 3 套)、或者把装扮需求从每周 3 套降到 2 套(改需求)。哪一种都行,但不能不选。

小结与下一步

这一节把「内容够不够」从主观感觉变成可算的数字:产能 = 编制 × 周工时 × 效率折扣 ÷ 单件工时,按内容类型分开算,再和每周需求一比。仿真给出了 2 类自给自足、装扮每周净亏 1 套、第 7 周库存归零、26 周末 20 套内容债,以及「补齐需要 2 名美术」的精确结论。**产能是隐形天花板,缺口有延迟性,必须按类型算。**下一节换个视角:当一款游戏走到生命周期尾段,怎么用数据决定它什么时候该退场。

阅读导航:上一节:13.1 版本列车与内容节奏 · 下一节:13.3 生命周期与退场策略 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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