先把边界交代清楚。站内那组游戏引擎平台(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):
| 素材项 | 类型 | 来源 | 数量 | 单价(元) | 现金(元) | 人天 |
|---|---|---|---|---|---|---|
| 角色/道具原画 | 美术 | 外包 | 60 | 80 | 4,800 | 0 |
| 特效序列帧 | 美术 | 外包 | 12 | 600 | 7,200 | 0 |
| UI 组件套件 | 美术 | 内制 | 1 | — | 0 | 8 |
| 音效 | 音频 | 外包 | 40 | 30 | 1,200 | 0 |
| 背景音乐 | 音频 | 外包 | 3 | 4,000 | 12,000 | 0 |
| 字体授权 | 授权 | 外包 | 1 | 800 | 800 | 0 |
| 引擎/中间件授权 | 授权 | 外包 | 1 | 0 | 0 | 0 |
按类型汇总:
| 类型 | 项数 | 现金(元) | 占比 |
|---|---|---|---|
| 音频 | 2 | 13,200 | 50.7% |
| 美术 | 3 | 12,000 | 46.1% |
| 授权 | 2 | 800 | 3.0% |
第一眼会盯住「音频占了 50.7%」,因为 3 首 BGM 就花了 12,000 元,比 60 张原画还贵。但别急着砍 BGM——一首 4000 元的音乐是一次性买断的,它会在游戏的整个生命周期里反复播放,摊到每个玩家头上的成本趋近于零。而 60 张原画虽然单价低,却是和玩法强绑定的,砍一张就少一个合成物。判断该不该砍,看的不是「占比」,而是「这项素材对核心体验的边际贡献」——这是第 6 章核心循环拆解留下的判据,成本表只提供数据,不替你拍板。
第二眼要盯住「美术那一栏的 8 人天」。它不在现金里,所以特别容易被漏掉。如果只做现金预算,会得出「美术外包才花 12,000 元」的乐观结论;把 8 人天内制工时加回去,才是美术这条线的全部成本。外包成本的正确读法,是现金表 + 内制工时表一起看——单看任何一张都会失真。
四、外包的三条纪律
成本表能告诉你「花多少」,但外包这件事真正的风险在表外。三条纪律:
- 单价要有锚点。 表里的每个
unitFen都应该能追溯到一次真实询价或历史成交,而不是拍出来的。没有锚点的单价,一遇到交付质量争议就会失控——因为双方对「值多少钱」没有共识。 - 授权边界要写死。 字体、音乐、中间件这三类授权型素材,成本低但法律风险高。买断还是租赁、能否商用、能否用于续作、能否二次分发——这些必须在下单前落到合同里。表里
middleware单价为 0,正说明「授权」这条路可以很便宜,但前提是边界清晰。 - 交付验收要对齐格式。 外包素材最容易在「交付」这一步翻车:原画没分层、特效没给序列帧、音频采样率不对,返工的时间成本往往超过素材本身。验收标准要写进单价约定——验收不过不付款,是唯一能让外包质量可控的杠杆。
这三条都不体现在成本数字里,但它们决定的是「这 26,000 元到底买到了什么」。成本表负责算钱,纪律负责保证钱花得值。
五、落到设计上的规则
- 两条腿分开记账。 外包计现金(分)、内制计人天,永不混算。想看清美术这条线的真实成本,必须现金表 + 人力表一起看。
- 看占比,但别用占比拍板。 占比是结构信息,砍不砍要看「边际贡献」。一次性买断的 BGM 和玩法绑定的原画,不能按同一把尺子裁。
- 授权和验收是表外风险。 单价锚点、授权边界、交付验收这三条不写进合同,成本表再漂亮也守不住。
到这里,「人」和「物」都算清了:180 人天、234,000 元人力(示例假设),加 26,000 元素材现金、8 人天内制工时。但这些都是静态的总额——真实项目里,它们会随着里程碑推进被分段消耗,而每一段都可能超期。下一节把这张静态的账,排成一条带缓冲的时间线。
阅读导航:上一节:人力与工时估算 · 下一节:里程碑与风险缓冲 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。