动手前先划清边界。站内那组游戏引擎平台(24 篇) 讲的是平台型产品的研发组织与工程排期——怎么把一支团队、一个编辑器、一条发布流水线搭起来,怎么给平台做 Sprint 与里程碑;本章是单款小游戏的人力工时、外包成本与缓冲估算——只回答「做《合成小岛》这一款,要几个人、花几天、掏多少钱、留多少缓冲」。前者是「造工具的组织」,后者是「用工具做产品」的账,框架不通用。
再分清和上一章的边界。第 11 章讲的是立项层面的粗颗粒:一页纸、粗略的预算排期 、止损判据——它只要求你答出「大概几个月、大概几十万」。本章讲的是资源计划的细颗粒度估算:把每个人拆成人天、把每笔外包拆成单价、把每段排期拆出缓冲,粒度细到「谁、几天、多少钱」。两者是同一笔账的粗档和细档,粗档决定「要不要做」,细档决定「怎么做、做得起吗」。
第 13 章把收入算了,这一章把成本算了,两头一对,立项的账才闭合。这一节先算人力,下一节算外包与素材,第三节把它们排成里程碑并加上缓冲。
一、先分清「排期」和「估算」
很多团队把「排期」和「估算」当成一回事,于是出现一个经典事故:老板问「多久能做完」,主程看一眼需求说「三周吧」,三周后没做完,所有人都不开心。问题出在——他给的是排期(日历时间),而心里算的是估算(工作量)。这两个东西单位不同,换算要经过一道除法。
- 估算(effort)的单位是人天 / 人月:这件事一共需要多少「一个人一天」的工作量。它是可加的——两个模块各 5 人天,合起来就是 10 人天。
- 排期(duration)的单位是日历天 / 周:从今天到交活要经过多少个自然日。它是不可加的,因为它取决于有几个人并行。
所以正确的顺序是:先估工作量,再排期。工作量(人天)由任务本身决定,相对稳定;排期由团队规模决定,随时可调。把顺序反过来——先拍日历时间再倒推工作量——是绝大多数「估算翻车」的根源。
本节只做第一步:估算。把《合成小岛》按角色拆出人天,再乘上日单价折成钱。排期(日历工期)只在最后做一个粗略的换算,提醒它是个「下界」。
二、把「一个人」拆成「人天」
「要几个人」这个问题没法直接回答,因为它不是一个数。正确的问法是「每个角色各需要多少人天」。下面这份是 D 层配置,它给出六个角色的投入人天与日单价——注意日单价用分为单位,全程不出现浮点。
{
"version": 1,
"game": "merge-island",
"note": "人力与工时估算(D 层):每个角色给出投入人天 effortDays 与日单价 dayRateFen(单位:分)。所有数字均为示例假设,不代表真实市场行情。",
"personDaysPerMonth": 21,
"assumedTeamSize": 4,
"roles": [
{ "id": "design", "name": "产品/策划", "effortDays": 30, "dayRateFen": 120000 },
{ "id": "client", "name": "客户端开发", "effortDays": 60, "dayRateFen": 150000 },
{ "id": "server", "name": "服务端开发", "effortDays": 20, "dayRateFen": 150000 },
{ "id": "art", "name": "美术", "effortDays": 35, "dayRateFen": 110000 },
{ "id": "qa", "name": "测试", "effortDays": 20, "dayRateFen": 100000 },
{ "id": "pm", "name": "项目管理", "effortDays": 15, "dayRateFen": 130000 }
]
}
读这份表有三个要点。
第一,单位是「人天」,不是「天」。client: 60 表示客户端开发一共需要 60 个「一个人一天」的工作量,不是「客户端开发要干 60 天」。如果两个客户端一起干,可能 30 个工作日就够——这正是「估算」和「排期」的分野。
第二,日单价用分。150000 分就是 1500 元一天。用「分」是为了和后面所有金额口径统一,也是为了让配置里一个浮点数都不出现——整数乘整数永远精确,不会出现 0.1 + 0.2 那种在预算表里很要命的小数误差。
第三,personDaysPerMonth = 21 是把人天折成人月的除数(一个月按 21 个工作日算),assumedTeamSize = 4 是排期时假设的并行人数。这两个都是参数,改一个数字,下面的结论就跟着变——这正是「配置驱动」的意义。
这里必须诚实标注:所有日单价都是示例假设,不代表真实市场行情。真实薪资受地区、经验、雇佣形式影响极大,需要真实业务环境才能确定,命令行不可复现(X)。本节能确定的是成本结构(钱花在哪、占比多少)与换算方法(人天怎么折成人月与工期),这两个是可复现的结论。
三、S 层:把工时折成钱
有了 effortDays 和 dayRateFen,剩下的就是两件事:每个角色的成本 = 人天 × 日单价,全表加总。听起来简单,但有几个坑要一次性踩平:金额用分、占比用千分比、折人月与折工期都用整数地板除(或上取整)。下面这段是 S 层仿真脚本,读 config/ 跑汇总,产 CSV 与 SVG。
#!/usr/bin/env python3
# file: sim/ch14_staffing.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch14_staffing.py --config config/ --out out/
"""人力与工时估算:读 config/ch14_staffing.json 的角色表,
按「人天 × 日单价」汇总人力成本与工时结构,产出 JSON + CSV + SVG 条形图。
全程整数运算,金额用「分」记账:
costFen = effortDays * dayRateFen
sharePermille = costFen * 1000 // totalCostFen
personMonthsTimes100 = totalDays * 100 // personDaysPerMonth
calendarDays = (totalDays + teamSize - 1) // teamSize # 上取整,整数
所有薪资均为示例假设,不代表真实市场行情。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
ROLE_COLOR = {
"design": "#2563eb",
"client": "#7c3aed",
"server": "#0ea5e9",
"art": "#f59e0b",
"qa": "#10b981",
"pm": "#ef4444",
}
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch14_staffing.json"), encoding="utf-8") as f:
return json.load(f)
def cost_fen(effort_days, day_rate_fen):
"""人力成本(分),整数乘法。"""
return effort_days * day_rate_fen
def ceil_div(a, b):
"""整数上取整:ceil(a / b)。"""
return (a + b - 1) // b if b else 0
def draw_svg(path, roles, total_cost_fen):
"""纯 Python SVG:每个角色一根横条,长度正比于人力成本,颜色按角色区分。"""
W, H, PAD, ROW = 760, 108 + 40 * len(roles), 168, 40
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 = (
"《合成小岛》各角色人力成本(元,示例假设)"
)
ET.SubElement(svg, "text", {"x": "16", "y": "46", "font-size": "11", "fill": "#6b7280"}).text = (
"条长正比于人力成本;括号内为人天与占比"
)
bar_max = W - PAD - 120
hi = max((r["costFen"] for r in roles), default=1) or 1
for i, r in enumerate(roles):
y = 68 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = r["name"]
w = bar_max * r["costFen"] // hi
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "20",
"rx": "4", "fill": ROLE_COLOR.get(r["id"], "#6b7280")})
ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 21), "font-size": "12",
"fill": "#6b7280"}).text = (
f'{r["costFen"] // 100}元 · {r["effortDays"]}人天 · {r["sharePermille"]}‰'
)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
total_days = sum(r["effortDays"] for r in cfg["roles"])
total_cost = sum(cost_fen(r["effortDays"], r["dayRateFen"]) for r in cfg["roles"])
roles = []
for r in cfg["roles"]:
c = cost_fen(r["effortDays"], r["dayRateFen"])
roles.append({
"id": r["id"], "name": r["name"],
"effortDays": r["effortDays"], "dayRateFen": r["dayRateFen"],
"costFen": c,
"sharePermille": c * 1000 // total_cost if total_cost else 0,
})
person_months_times100 = total_days * 100 // cfg["personDaysPerMonth"] if cfg["personDaysPerMonth"] else 0
calendar_days = ceil_div(total_days, cfg["assumedTeamSize"])
avg_day_rate = total_cost // total_days if total_days else 0
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch14_staffing.svg"), roles, total_cost)
report = {
"version": cfg["version"],
"game": cfg["game"],
"roleCount": len(roles),
"totalDays": total_days,
"totalCostFen": total_cost,
"personMonthsTimes100": person_months_times100,
"assumedTeamSize": cfg["assumedTeamSize"],
"calendarDays": calendar_days,
"avgDayRateFen": avg_day_rate,
"shareSumPermille": sum(r["sharePermille"] for r in roles),
"roles": roles,
"svg": os.path.join(args.out, "ch14_staffing.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch14_staffing.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch14_staffing.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "effortDays", "dayRateFen", "costFen", "sharePermille"])
for r in roles:
w.writerow([r["id"], r["name"], r["effortDays"], r["dayRateFen"], r["costFen"], r["sharePermille"]])
print(json.dumps({"totalDays": total_days, "totalCostFen": total_cost,
"personMonthsTimes100": person_months_times100,
"calendarDays": calendar_days, "avgDayRateFen": avg_day_rate,
"shareSumPermille": report["shareSumPermille"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"totalDays": 180, "totalCostFen": 23400000, "personMonthsTimes100": 857, "calendarDays": 45, "avgDayRateFen": 130000, "shareSumPermille": 997}
四、读表:钱花在哪、工期多长
把输出翻译成人话:
- 总工时 180 人天:六个角色的人天加总。这是这件事真正的「工作量」,不管派几个人来,它都不变。
- 总人力成本 23,400,000 分 = 234,000 元:人天 × 日单价加总。同样是示例假设下的量级,不是报价。
- 折合 8.57 人月:
180 × 100 ÷ 21 = 857,即 8.57 人月。这个口径适合和「一个人干几个月」的直觉对齐。 - 并行工期 45 个工作日:
ceil(180 ÷ 4) = 45。这是假设 4 个人完全并行、无等待时的下界,约 9 个自然周。 - 平均日单价 1300 元:
23400000 ÷ 180 = 130000分。
逐角色看成本结构(out/ch14_staffing.csv):
| 角色 | 人天 | 日单价(元) | 成本(元) | 占比 |
|---|---|---|---|---|
| 产品/策划 | 30 | 1200 | 36,000 | 15.3% |
| 客户端开发 | 60 | 1500 | 90,000 | 38.4% |
| 服务端开发 | 20 | 1500 | 30,000 | 12.8% |
| 美术 | 35 | 1100 | 38,500 | 16.4% |
| 测试 | 20 | 1000 | 20,000 | 8.5% |
| 项目管理 | 15 | 1300 | 19,500 | 8.3% |
(表中「占比」由千分比 sharePermille 换算成百分比,加总 99.7% 而非 100%,是各自地板除的舍入残差,属正常。)
一眼能看出两件事:客户端开发占了近四成(60 人天里的大部分花在核心循环、关卡与平台适配),这是小游戏成本的第一大项;测试与项目管理加起来不到 17%,说明这份假设里没有为「充分测试」留太多余量——这正好是下一节和第三节要补的缺口。占比是结构信息,它比绝对金额更抗假设漂移:即使日单价整体估偏,谁是大头基本不变。
五、三个必须分开的口径
「人天」「人月」「日历工期」这三个词经常被混用,混用就会算错。它们的关系是:
- 人天 → 人月:除以
personDaysPerMonth(21)。这是工作量口径,与团队规模无关。180 人天永远是 8.57 人月。 - 人天 → 日历工期:除以并行人数(
assumedTeamSize),再上取整。这是时间口径,强烈依赖团队规模。4 人并行是 45 天,2 人并行就是 90 天。 - 两者不可互推:看到「8.57 人月」不能反推出「要干 8.57 个月」——那要先知道有几个人。
还有一条必须写进纪律:45 个工作日是下界,不是承诺。它假设六种角色完全并行、零等待、零返工。真实项目里产品、美术、客户端、测试是串行多于并行的(美术没出图客户端没法接,客户端没交测试没法测),所以真实工期只会比 45 天更长。把 45 天当承诺报出去,几乎必然延期——这也正是下一节要引入缓冲的动机。
六、落到设计上的规则
把这一节收成三条可以直接用的规则:
- 先估工作量,再排期。 人天由任务决定、相对稳定;工期由人数决定、随时可调。顺序反了,估算必翻车。
- 金额一律用分,占比一律用千分比。 配置里不出现浮点,脚本里不出现浮点除法,预算表就不会有小数误差。人月、工期用整数地板除 / 上取整。
- 把「谁是大头」当成结论,而不是把总金额当成结论。 总额随日单价假设漂移,但「客户端是最大项」这种结构判断更稳,也更指导决策——要省钱,先盯最大的那块。
下一节把这份人力表和外包与素材接上:有些素材自己做(占用上面的人天),有些买现成的(计现金),两条腿分开记账,才能看清「人力成本」和「外包现金」各自的边界。
阅读导航:上一节:收入预测与情景分析 · 下一节:外包与素材成本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。