上一节解决的是「车怎么发」,这一节解决「车上的货从哪来」。内容产能(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 生命周期与退场策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。