站内那篇游戏本地化与多语言指南 把「本地化要花多少钱、找谁做、分几期」讲得很细,本节不重复它的预算框架。上一节我们把「哪些素材会踩哪个地区的禁忌」算成了一张命中矩阵,但矩阵只回答要不要换,不回答换了要花多少。本节补的就是这一环:把「换素材」翻译成变体数量,再把变体数量翻译成钱,让「给所有市场都做一套」和「只做性价比最高的那几个」这两条路线在同一张表上对账。
变体是成本项,不是翻译的副产品
很多团队做本地化时只算翻译的钱:一句文案多少钱、一种语言多少钱。美术变体常常被当成「顺手改改」,直到真正开工才发现——角色形象换一套、节庆横幅换一套、货币图标换一套,每一套都是一次完整的美术生产(设计、切图、适配、验收),而且它们不随语言打包,而是随市场走。
更麻烦的是,变体的数量不是线性的。同一个素材在三个市场需要变体,就是 1(底版)+ 3(变体)= 4 套;但两个素材各自需要的市场集合不一定重合,所以「全做」的总套数,必须逐素材算,不能拍脑袋估。本节用一个最朴素的模型把这件事算清楚:每个素材至少一套底版,某市场在它的 variantIn 里就再加一套。
哪些素材需要变体
变体的触发原因分四类,对应不同的素材粒度:
| 触发原因 | 典型素材 | 变体粒度 |
|---|---|---|
| 文化 / 宗教禁忌 | 角色形象、手势插画 | 按市场换形象 |
| 节庆与在地化 | 节庆横幅、活动页 | 按市场换主题 |
| 货币与支付 | 货币图标、支付按钮 | 按市场换符号 |
| 语言排版 | UI 框、字体位图 | 按语言换版式(见 5.3) |
前三类按市场走,第四类按语言走——两者的变体集合不同,成本要分开算。本节只算前三类(按市场),语言排版归 5.3。
S 层:变体数量与成本仿真
下面这段是 S 层仿真脚本 sim/g06_variants.py。它读 config/g06_variants.json,先算「全市场都做」的总套数与总成本,再按 DAU 占比从高到低逐个纳入市场,画出每一步的覆盖与成本,并取累计单位覆盖成本最小的一步作为性价比拐点。产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/g06_variants.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g06_variants.py --config config/ --out out/
"""美术与素材本地化变体:数量与成本仿真。读 config/g06_variants.json。
每个素材至少一套底版;若某市场在它的 variantIn 里,则该市场需要一套专属变体。
给定目标市场集合 S:
variantCount(asset, S) = 1 + |variantIn(asset) ∩ S|
costFen(S) = Σ_asset baseCostFen(asset) × variantCount(asset, S)
coverage(S) = Σ_{m ∈ S} dauSharePermille(m)
按 DAU 占比从高到低逐个纳入市场,得到「覆盖 ↔ 成本」曲线;取累计单位覆盖成本
(costFen × 1000 // coverage)最小的 k 作为「性价比拐点 knee」。
口径:报价与人力成本是「公开可观察机制」的建模假设;真实美术报价与真实人力成本
需真实业务环境,命令行不可复现(X)。产出 out/g06_variants.json + .csv + .svg。
全整数、地板除、无浮点、无随机。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def cost_of(cfg: dict, selected: set[str]) -> tuple[int, int]:
"""返回 (总变体套数, 总成本分) —— 目标市场集合为 selected。"""
variants = 0
cost = 0
for a in cfg["assets"]:
n = 1 + sum(1 for m in a["variantIn"] if m in selected)
variants += n
cost += a["baseCostFen"] * n
return variants, cost
def coverage_of(cfg: dict, selected: set[str]) -> int:
return sum(m["dauSharePermille"] for m in cfg["markets"] if m["id"] in selected)
def build_curve(cfg: dict) -> list[dict]:
"""按 DAU 占比降序逐个纳入市场,产出每一步的覆盖与成本。"""
order = sorted(cfg["markets"], key=lambda m: (-m["dauSharePermille"], m["id"]))
selected: set[str] = set()
rows = []
for k, m in enumerate(order, start=1):
selected.add(m["id"])
variants, cost = cost_of(cfg, selected)
cov = coverage_of(cfg, selected)
rows.append({"k": k, "added": m["id"], "markets": sorted(selected),
"variantCount": variants, "costFen": cost, "coveragePermille": cov,
"efficiencyFenPerPermille": cost * 1000 // cov})
return rows
def draw_svg(path: str, rows: list[dict]) -> int:
"""覆盖(左轴色条)与累计单位覆盖成本(右轴折线)双序列。"""
W, PAD_L, PAD_R, ROW = 760, 150, 210, 34
H = 100 + ROW * len(rows)
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": "28", "font-size": "15",
"fill": "#111827"}).text = "逐市场纳入:覆盖千分比与单位覆盖成本(分/‰)"
span = W - PAD_L - PAD_R
knee = min(rows, key=lambda r: r["efficiencyFenPerPermille"])["k"]
for r in rows:
y = 56 + (r["k"] - 1) * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = f"k={r['k']} +{r['added']}"
w = span * r["coveragePermille"] // 1000
color = "#2563eb" if r["k"] != knee else "#16a34a"
ET.SubElement(svg, "rect", {"x": str(PAD_L), "y": str(y + 4), "width": str(w),
"height": "20", "rx": "3", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD_L + w + 8), "y": str(y + 19), "font-size": "11",
"fill": "#6b7280"}).text = (
f"覆盖 {r['coveragePermille']}‰ · {r['variantCount']} 套 · {r['costFen']}分 · "
f"{r['efficiencyFenPerPermille']}分/‰")
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/")
a = ap.parse_args()
with open(os.path.join(a.config, "g06_variants.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = build_curve(cfg)
all_markets = {m["id"] for m in cfg["markets"]}
full_variants, full_cost = cost_of(cfg, all_markets)
shared = [a_["id"] for a_ in cfg["assets"] if not a_["variantIn"]]
knee = min(rows, key=lambda r: r["efficiencyFenPerPermille"])
digest = (f"assets={len(cfg['assets'])},markets={len(cfg['markets'])},"
f"fullVariants={full_variants},fullCostFen={full_cost},sharedAssets={len(shared)},"
f"kneeK={knee['k']},kneeCoverage={knee['coveragePermille']},kneeCostFen={knee['costFen']}")
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "g06_variants.svg"), rows)
with open(os.path.join(a.out, "g06_variants.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["k", "added", "variantCount", "costFen", "coveragePermille",
"efficiencyFenPerPermille"])
for r in rows:
w.writerow([r["k"], r["added"], r["variantCount"], r["costFen"],
r["coveragePermille"], r["efficiencyFenPerPermille"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"assetCount": len(cfg["assets"]), "marketCount": len(cfg["markets"]),
"fullVariantCount": full_variants, "fullCostFen": full_cost,
"sharedAssets": shared, "knee": knee, "curve": rows, "digest": digest,
"svg": os.path.join(a.out, "g06_variants.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "g06_variants.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "fullVariantCount": full_variants,
"fullCostFen": full_cost, "sharedAssets": shared,
"knee": {"k": knee["k"], "added": knee["added"],
"coveragePermille": knee["coveragePermille"],
"costFen": knee["costFen"],
"efficiencyFenPerPermille": knee["efficiencyFenPerPermille"]},
"curve": [{"k": r["k"], "added": r["added"], "variantCount": r["variantCount"],
"costFen": r["costFen"], "coveragePermille": r["coveragePermille"]}
for r in rows]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
变体表放在 config/g06_variants.json(D 层配置)。它是纯数据、无浮点:市场用 DAU 占比千分比,成本用分,变体集合用市场 id 列表:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "美术与素材本地化变体表。口径是「公开可观察机制」的建模假设:哪些素材在哪些市场需要一套专属变体(换形象 / 换配色 / 换节庆 / 换货币图标),以及每套变体的基础制作成本。baseCostFen=一套变体的制作成本(分,含设计+切图+验收);variantIn=需要专属变体(而非共用底版)的市场 id 列表,空列表表示全市场共用一套。dauSharePermille=该市场的 DAU 占比千分比(合计 1000)。真实美术报价与真实人力成本需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"markets": [
{ "id": "us", "name": "北美", "dauSharePermille": 280 },
{ "id": "in", "name": "印度", "dauSharePermille": 150 },
{ "id": "br", "name": "巴西", "dauSharePermille": 120 },
{ "id": "jp", "name": "日本", "dauSharePermille": 110 },
{ "id": "sea", "name": "东南亚", "dauSharePermille": 100 },
{ "id": "kr", "name": "韩国", "dauSharePermille": 90 },
{ "id": "de", "name": "德国", "dauSharePermille": 80 },
{ "id": "mena", "name": "中东(阿语区)", "dauSharePermille": 70 }
],
"assets": [
{ "id": "ui_frame", "name": "UI 九宫格框", "kind": "ui", "baseCostFen": 8000, "variantIn": [] },
{ "id": "logo", "name": "主图标", "kind": "brand", "baseCostFen": 20000, "variantIn": [] },
{ "id": "charset_main", "name": "主角形象组", "kind": "character", "baseCostFen": 45000, "variantIn": ["jp", "kr", "mena"] },
{ "id": "festival_banner", "name": "节庆横幅", "kind": "banner", "baseCostFen": 15000, "variantIn": ["us", "jp", "br", "mena"] },
{ "id": "icon_currency", "name": "货币支付图标", "kind": "icon", "baseCostFen": 3000, "variantIn": ["us", "jp", "kr", "de", "br", "in", "mena", "sea"] },
{ "id": "tutorial_hands", "name": "引导手势插画", "kind": "illustration", "baseCostFen": 12000, "variantIn": ["mena"] },
{ "id": "color_theme", "name": "主题配色", "kind": "theme", "baseCostFen": 9000, "variantIn": ["mena"] }
]
}
在本机跑 for f in sim/g06_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,全市场都做的结果是(同机相对量级,绝对数值不可移植):
digest = assets=7,markets=8,fullVariants=24,fullCostFen=352000,sharedAssets=2,kneeK=3,kneeCoverage=550,kneeCostFen=151000
7 个素材、8 个市场,全做要 24 套变体、352000 分(3520 元),其中只有 2 个素材(UI 框、主图标)全市场共用一套底版,其余 5 个都要按市场拆。但「全做」不是唯一选择——真正的决策是做到哪一步为止。把市场按 DAU 占比从高到低逐个纳入,就得到一张「覆盖 ↔ 成本」曲线:
| k | 新增市场 | 累计覆盖 | 变体套数 | 累计成本(分) | 边际覆盖成本(分/‰) |
|---|---|---|---|---|---|
| 1 | 北美 | 280‰ | 9 | 130000 | — |
| 2 | 印度 | 430‰ | 10 | 133000 | 20 |
| 3 | 巴西 | 550‰ | 12 | 151000 | 150 |
| 4 | 日本 | 660‰ | 15 | 214000 | 573 |
| 5 | 东南亚 | 760‰ | 16 | 217000 | 30 |
| 6 | 韩国 | 850‰ | 18 | 265000 | 533 |
| 7 | 德国 | 930‰ | 19 | 268000 | 38 |
| 8 | 中东 | 1000‰ | 24 | 352000 | 1200 |
这张表把「全做」的真相摊开了:从 550‰ 覆盖到 1000‰,多花的钱是 352000−151000=201000 分,却只多覆盖 450‰;而从 0 到 550‰ 只花了 151000 分。最贵的一跳在最后:纳入中东要新增「主角形象、节庆横幅、货币图标、引导手势、主题配色」五类变体,边际成本 1200 分/‰,是纳入印度(20 分/‰)的 60 倍。
拐点:不是「全做」,也不是「只做北美」
按累计单位覆盖成本(costFen × 1000 ÷ coveragePermille,越小越划算)排序,最小值出现在 k=3(北美 + 印度 + 巴西):覆盖 550‰、成本 151000 分、单位覆盖成本 274545(即 274.5 分/‰)。这就是本节说的性价比拐点——再往下纳入日本,单位成本从 274.5 跳到 324.2 分/‰,效率开始下滑。
拐点不是「标准答案」,而是一条可解释的决策线:
- 覆盖 550‰(前三个市场)是效率最高的一段,适合预算紧、先验证的阶段;
- 覆盖 760‰(加上东南亚)几乎不额外增加成本(东南亚只在货币图标上要变体,边际仅 30 分/‰),是几乎白捡的一段;
- 覆盖 1000‰ 要付出边际最贵的最后一跳,适合已确认中东留存与付费、且变体复用度高的项目。
把「全做」和「只做拐点」并列,决策就变得具体:是花 151000 分覆盖 55% 的 DAU,还是花 352000 分覆盖 100%——中间的 201000 分,买的是那 45% 的长尾,值不值取决于长尾市场的留存与 ARPU,而这要靠真实的分布数据判断(见第 8 章)。
X 层:真实报价与真实人力不可复现
必须把边界划清。上面算的是模型侧:给定每个素材的基础成本与市场变体集合,套数与总成本是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的美术报价(同一套变体,外包 / 内部 / 不同地区的美术团队,价格能差数倍);
- 真实的变体集合(哪些素材真的需要变体,取决于上一节的文化审查结论与平台要求,是人为判断);
- 真实的人力可用性(变体能不能按期做完,取决于美术产能,不是成本模型能回答的)。
工程上正确的做法与上一节一致:把可复现的成本模型与不可复现的报价分开。模型层回答「如果每套变体是 X 元,全做要多少钱、拐点在哪」;真实报价拿到后反填进 baseCostFen,曲线立刻更新。这也是本节与 6.1 的闭环:6.1 算出哪些素材必须换,6.2 算出换这些要花多少。
自检表
| 检查项 | 合格标准 |
|---|---|
| 底版必有一套 | 每个素材都从 1 套底版起算,不重复计 |
| 变体按市场并集 | 同素材跨市场需要的变体取并集,不重复不遗漏 |
| 覆盖用 DAU 占比 | 覆盖按市场 DAU 占比加权,而非「市场个数」 |
| 有拐点判定 | 给出单位覆盖成本最低的 k,而非只报「全做」总数 |
| 边界清晰 | 真实报价与人力标 X;不写成「实测」 |
到这里,第 6 章的两块「视觉本地化」拼齐了:6.1 决定改哪些,6.2 决定改多少。下一节换一条完全不同的轴——它不碰图片,只碰文字与数字的排布:日期怎么写、千分位怎么隔、货币怎么换算、从右往左的语言怎么排。这是最容易被「翻译正确」掩盖、却最容易被用户一眼看穿的一类 bug。
阅读导航:上一节:6.1 文化禁忌与符号审查 · 下一节:6.3 日期、数字、货币与 RTL 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。