14.2 外包与素材成本

把素材账分成两条腿:外包的计现金、内制的计人天,用一份素材成本表按「数量 × 单价」汇总出《合成小岛》的现金成本与内制工时,全程整数、金额用分记账,产 CSV 与 SVG 条形图,并讲清为什么「看外包成本不能只看现金」,以及外包单价、授权边界与交付验收三条纪律。

先把边界交代清楚。站内那组游戏引擎平台(24 篇) 讲的是平台型产品的研发组织与工程排期——它关心的是「怎么把编辑器、素材管线、发布流程搭成一套产品」;本章是单款小游戏的人力工时、外包成本与缓冲估算——只关心「做《合成小岛》这一款,素材要花多少现金、占多少工时」。前者的素材管线是给别人用的基础设施,后者是自己这一款要用到的素材清单,颗粒度和目的都不同。

上一节把人力表算清楚了:180 人天、234,000 元(示例假设)。但那笔账里只有「人」,没有「物」——游戏还得有原画、音效、音乐、字体授权。这些素材有的自己做、有的买现成,两条路的记账方式完全不同。这一节把素材账拆成两条腿,看清楚「现金」和「工时」各自的边界。

一、外包还是内制:两条腿分开记账

素材成本最容易算错的地方,是把「外包现金」和「内制工时」混成一个数。它们根本不是同一种东西:

  • 外包(outsourced):花现金换素材。计账单位是分——一个原画 80 元、一首 BGM 4000 元,付出去就是付出去。
  • 内制(inhouse):不花现金,但占用工时。计账单位是人天——UI 套件自己做,省了现金,却从上一节美术的 35 人天里切走 8 天。

混着算就会得出荒谬结论:把「内制 8 人天」也按某个单价折成钱,或者干脆当它「免费」。正确的做法是两条腿各记各的:现金归现金、人天归人天,最后各自汇总。这也是为什么 14.1 和 14.2 要连着看——内制的素材,最终要在人力表里兑现。

下面这份是 D 层配置,它给每个素材项标了类型(美术 / 音频 / 授权)、来源(外包 / 内制)、数量与单价。外包项给 unitFen(分),内制项给 unitDays(人天)。

{
  "version": 1,
  "game": "merge-island",
  "note": "外包与素材成本结构(D 层)。source=outsourced 计现金(单价 unitFen,单位:分);source=inhouse 计人天(unitDays,占用 14.1 的美术工时)。数量与单价均为示例假设,不代表真实市场报价。",
  "items": [
    { "id": "character-art", "name": "角色/道具原画", "type": "art",     "source": "outsourced", "quantity": 60, "unitFen": 8000 },
    { "id": "vfx",           "name": "特效序列帧",     "type": "art",     "source": "outsourced", "quantity": 12, "unitFen": 60000 },
    { "id": "ui-kit",        "name": "UI 组件套件",   "type": "art",     "source": "inhouse",    "quantity": 1,  "unitDays": 8 },
    { "id": "sfx",           "name": "音效",          "type": "audio",   "source": "outsourced", "quantity": 40, "unitFen": 3000 },
    { "id": "bgm",           "name": "背景音乐",       "type": "audio",   "source": "outsourced", "quantity": 3,  "unitFen": 400000 },
    { "id": "font",          "name": "字体授权",       "type": "license", "source": "outsourced", "quantity": 1,  "unitFen": 80000 },
    { "id": "middleware",    "name": "引擎/中间件授权", "type": "license", "source": "outsourced", "quantity": 1,  "unitFen": 0 }
  ]
}

读这份表,注意 ui-kit 的 source 是 inhouse:它没有 unitFen,只有 unitDays: 8。这条 8 人天不产生现金,但它实实在在占掉了美术的工时——所以它是「隐性成本」,必须回到 14.1 的人力表里才算得清。反过来,middleware 的 unitFen 是 0:示例里用了免费 / 自研的中间件,所以它虽是 outsourced,却不花钱。「外包」不等于「一定花钱」,「内制」也不等于「不花工时」——一切都以字段为准。

这里同样要诚实标注:所有单价都是示例假设,不代表真实市场报价。真实的外包价格受画风复杂度、交付格式、版权范围影响极大,需要真实业务环境才能确定,命令行不可复现(X)。本节能确定的是成本结构(钱花在哪个类型上、占比多少)与内制占比(多少工时被素材吃掉)这两个可复现的结论。

二、S 层:数量 × 单价 → 成本结构

有了清单,剩下就是按两条腿分别汇总。这段是 S 层仿真脚本,读 config/ 跑素材成本结构,产 CSV 与 SVG。关键设计是:每条记录只填一条腿——外包项算 cashFen、内制项算 effortDays,另一个字段恒为 0,这样加总时不会互相污染。

#!/usr/bin/env python3
# file: sim/ch14_outsourcing.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch14_outsourcing.py --config config/ --out out/
"""外包与素材成本结构:读 config/ch14_outsourcing.json 的素材表,
按「数量 × 单价」把现金成本与内制人天分开汇总,产出 JSON + CSV + SVG 条形图。

两条腿分开记账,全程整数:
  outsourced: cashFen = quantity * unitFen     (现金,单位:分)
  inhouse   : effortDays = quantity * unitDays (人天,占用 14.1 的美术工时)
  sharePermille = cashFen * 1000 // totalCashFen
数量与单价均为示例假设,不代表真实市场报价。
"""

from __future__ import annotations

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

TYPE_COLOR = {
    "art": "#f59e0b",
    "audio": "#2563eb",
    "license": "#7c3aed",
}
TYPE_NAME = {"art": "美术", "audio": "音频", "license": "授权"}


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


def item_cash_fen(item):
    """外包现金成本(分);内制项为 0。"""
    return item["quantity"] * item.get("unitFen", 0)


def item_effort_days(item):
    """内制人天;外包项为 0。"""
    return item["quantity"] * item.get("unitDays", 0)


def draw_svg(path, items):
    """纯 Python SVG:每个素材项一根横条,长度正比于现金成本,颜色按类型区分。"""
    W, H, PAD, ROW = 760, 118 + 40 * len(items), 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 = (
        "《合成小岛》各素材项现金成本(元,示例假设)"
    )
    lx = 16
    for tid, color in TYPE_COLOR.items():
        ET.SubElement(svg, "rect", {"x": str(lx), "y": "38", "width": "12", "height": "12", "rx": "2", "fill": color})
        ET.SubElement(svg, "text", {"x": str(lx + 17), "y": "49", "font-size": "11", "fill": "#374151"}).text = TYPE_NAME[tid]
        lx += 76
    ET.SubElement(svg, "text", {"x": str(lx + 8), "y": "49", "font-size": "11", "fill": "#6b7280"}).text = (
        "条长正比于现金成本;内制项现金为 0"
    )
    bar_max = W - PAD - 120
    hi = max((i["cashFen"] for i in items), default=1) or 1
    for i, it in enumerate(items):
        y = 68 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = it["name"]
        w = bar_max * it["cashFen"] // hi
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(max(w, 1)), "height": "20",
                                    "rx": "4", "fill": TYPE_COLOR.get(it["type"], "#6b7280")})
        tail = f'{it["cashFen"] // 100}元' if it["source"] == "outsourced" else f'{it["effortDays"]}人天(内制)'
        ET.SubElement(svg, "text", {"x": str(PAD + max(w, 1) + 8), "y": str(y + 21), "font-size": "12",
                                    "fill": "#6b7280"}).text = tail
    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_cash = sum(item_cash_fen(it) for it in cfg["items"])
    total_days = sum(item_effort_days(it) for it in cfg["items"])

    items = []
    for it in cfg["items"]:
        cash = item_cash_fen(it)
        days = item_effort_days(it)
        items.append({
            "id": it["id"], "name": it["name"], "type": it["type"], "source": it["source"],
            "quantity": it["quantity"], "unitFen": it.get("unitFen", 0), "unitDays": it.get("unitDays", 0),
            "cashFen": cash, "effortDays": days,
            "sharePermille": cash * 1000 // total_cash if total_cash else 0,
        })

    by_type = {}
    for it in items:
        b = by_type.setdefault(it["type"], {"type": it["type"], "name": TYPE_NAME.get(it["type"], it["type"]),
                                            "items": 0, "cashFen": 0, "effortDays": 0})
        b["items"] += 1
        b["cashFen"] += it["cashFen"]
        b["effortDays"] += it["effortDays"]
    types = sorted(by_type.values(), key=lambda b: b["cashFen"], reverse=True)
    for b in types:
        b["sharePermille"] = b["cashFen"] * 1000 // total_cash if total_cash else 0

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch14_outsourcing.svg"), items)
    report = {
        "version": cfg["version"],
        "game": cfg["game"],
        "itemCount": len(items),
        "totalCashFen": total_cash,
        "totalEffortDays": total_days,
        "shareSumPermille": sum(i["sharePermille"] for i in items),
        "items": items,
        "types": types,
        "svg": os.path.join(args.out, "ch14_outsourcing.svg"),
        "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch14_outsourcing.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch14_outsourcing.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "type", "source", "quantity", "unitFen", "unitDays",
                    "cashFen", "effortDays", "sharePermille"])
        for it in items:
            w.writerow([it["id"], it["name"], it["type"], it["source"], it["quantity"], it["unitFen"],
                        it["unitDays"], it["cashFen"], it["effortDays"], it["sharePermille"]])

    print(json.dumps({"itemCount": len(items), "totalCashFen": total_cash, "totalEffortDays": total_days,
                      "shareSumPermille": report["shareSumPermille"],
                      "types": [{"type": b["type"], "cashFen": b["cashFen"], "sharePermille": b["sharePermille"]}
                                for b in types]}, ensure_ascii=False))
    return 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"itemCount": 7, "totalCashFen": 2600000, "totalEffortDays": 8, "shareSumPermille": 997, "types": [{"type": "audio", "cashFen": 1320000, "sharePermille": 507}, {"type": "art", "cashFen": 1200000, "sharePermille": 461}, {"type": "license", "cashFen": 80000, "sharePermille": 30}]}

三、读表:音频是大头,但别急着砍

把输出翻译成人话:

  • 现金成本 2,600,000 分 = 26,000 元:7 个素材项里,外包项的数量 × 单价加总。
  • 内制工时 8 人天:只有 ui-kit 一项内制,它不花现金,却从 14.1 美术的 35 人天里切走 8 天。
  • 类型结构:音频 50.7%、美术 46.1%、授权 3.0%。

逐项看明细(out/ch14_outsourcing.csv):

素材项类型来源数量单价(元)现金(元)人天
角色/道具原画美术外包60804,8000
特效序列帧美术外包126007,2000
UI 组件套件美术内制1—08
音效音频外包40301,2000
背景音乐音频外包34,00012,0000
字体授权授权外包18008000
引擎/中间件授权授权外包1000

按类型汇总:

类型项数现金(元)占比
音频213,20050.7%
美术312,00046.1%
授权28003.0%

第一眼会盯住「音频占了 50.7%」,因为 3 首 BGM 就花了 12,000 元,比 60 张原画还贵。但别急着砍 BGM——一首 4000 元的音乐是一次性买断的,它会在游戏的整个生命周期里反复播放,摊到每个玩家头上的成本趋近于零。而 60 张原画虽然单价低,却是和玩法强绑定的,砍一张就少一个合成物。判断该不该砍,看的不是「占比」,而是「这项素材对核心体验的边际贡献」——这是第 6 章核心循环拆解留下的判据,成本表只提供数据,不替你拍板。

第二眼要盯住「美术那一栏的 8 人天」。它不在现金里,所以特别容易被漏掉。如果只做现金预算,会得出「美术外包才花 12,000 元」的乐观结论;把 8 人天内制工时加回去,才是美术这条线的全部成本。外包成本的正确读法,是现金表 + 内制工时表一起看——单看任何一张都会失真。

四、外包的三条纪律

成本表能告诉你「花多少」,但外包这件事真正的风险在表外。三条纪律:

  1. 单价要有锚点。 表里的每个 unitFen 都应该能追溯到一次真实询价或历史成交,而不是拍出来的。没有锚点的单价,一遇到交付质量争议就会失控——因为双方对「值多少钱」没有共识。
  2. 授权边界要写死。 字体、音乐、中间件这三类授权型素材,成本低但法律风险高。买断还是租赁、能否商用、能否用于续作、能否二次分发——这些必须在下单前落到合同里。表里 middleware 单价为 0,正说明「授权」这条路可以很便宜,但前提是边界清晰。
  3. 交付验收要对齐格式。 外包素材最容易在「交付」这一步翻车:原画没分层、特效没给序列帧、音频采样率不对,返工的时间成本往往超过素材本身。验收标准要写进单价约定——验收不过不付款,是唯一能让外包质量可控的杠杆。

这三条都不体现在成本数字里,但它们决定的是「这 26,000 元到底买到了什么」。成本表负责算钱,纪律负责保证钱花得值。

五、落到设计上的规则

  1. 两条腿分开记账。 外包计现金(分)、内制计人天,永不混算。想看清美术这条线的真实成本,必须现金表 + 人力表一起看。
  2. 看占比,但别用占比拍板。 占比是结构信息,砍不砍要看「边际贡献」。一次性买断的 BGM 和玩法绑定的原画,不能按同一把尺子裁。
  3. 授权和验收是表外风险。 单价锚点、授权边界、交付验收这三条不写进合同,成本表再漂亮也守不住。

到这里,「人」和「物」都算清了:180 人天、234,000 元人力(示例假设),加 26,000 元素材现金、8 人天内制工时。但这些都是静态的总额——真实项目里,它们会随着里程碑推进被分段消耗,而每一段都可能超期。下一节把这张静态的账,排成一条带缓冲的时间线。

阅读导航:上一节:人力与工时估算 · 下一节:里程碑与风险缓冲 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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