14.1 人力与工时估算

把一款小游戏的人拆成人天:用一份角色配置表,把每个角色的投入人天乘上日单价,汇总出总工时、折合人月、并行工期与人力成本结构,全程整数、金额用分记账,产 CSV 与 SVG 条形图,并讲清「排期」和「估算」这两个词为什么不能混着用,以及为什么并行工期只是个下界。

动手前先划清边界。站内那组游戏引擎平台(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):

角色人天日单价(元)成本(元)占比
产品/策划30120036,00015.3%
客户端开发60150090,00038.4%
服务端开发20150030,00012.8%
美术35110038,50016.4%
测试20100020,0008.5%
项目管理15130019,5008.3%

(表中「占比」由千分比 sharePermille 换算成百分比,加总 99.7% 而非 100%,是各自地板除的舍入残差,属正常。)

一眼能看出两件事:客户端开发占了近四成(60 人天里的大部分花在核心循环、关卡与平台适配),这是小游戏成本的第一大项;测试与项目管理加起来不到 17%,说明这份假设里没有为「充分测试」留太多余量——这正好是下一节和第三节要补的缺口。占比是结构信息,它比绝对金额更抗假设漂移:即使日单价整体估偏,谁是大头基本不变。

五、三个必须分开的口径

「人天」「人月」「日历工期」这三个词经常被混用,混用就会算错。它们的关系是:

  • 人天 → 人月:除以 personDaysPerMonth(21)。这是工作量口径,与团队规模无关。180 人天永远是 8.57 人月。
  • 人天 → 日历工期:除以并行人数(assumedTeamSize),再上取整。这是时间口径,强烈依赖团队规模。4 人并行是 45 天,2 人并行就是 90 天。
  • 两者不可互推:看到「8.57 人月」不能反推出「要干 8.57 个月」——那要先知道有几个人。

还有一条必须写进纪律:45 个工作日是下界,不是承诺。它假设六种角色完全并行、零等待、零返工。真实项目里产品、美术、客户端、测试是串行多于并行的(美术没出图客户端没法接,客户端没交测试没法测),所以真实工期只会比 45 天更长。把 45 天当承诺报出去,几乎必然延期——这也正是下一节要引入缓冲的动机。

六、落到设计上的规则

把这一节收成三条可以直接用的规则:

  1. 先估工作量,再排期。 人天由任务决定、相对稳定;工期由人数决定、随时可调。顺序反了,估算必翻车。
  2. 金额一律用分,占比一律用千分比。 配置里不出现浮点,脚本里不出现浮点除法,预算表就不会有小数误差。人月、工期用整数地板除 / 上取整。
  3. 把「谁是大头」当成结论,而不是把总金额当成结论。 总额随日单价假设漂移,但「客户端是最大项」这种结构判断更稳,也更指导决策——要省钱,先盯最大的那块。

下一节把这份人力表和外包与素材接上:有些素材自己做(占用上面的人天),有些买现成的(计现金),两条腿分开记账,才能看清「人力成本」和「外包现金」各自的边界。

阅读导航:上一节:收入预测与情景分析 · 下一节:外包与素材成本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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