6.2 美术与素材的本地化变体

美术变体是出海最容易被漏算的成本项:它不随语言打包,而随市场走,且套数不是线性的。本节把「哪些素材在哪些市场需要专属变体」做成一张可算的表,用 S 层仿真按 DAU 占比逐市场纳入,画出「覆盖 ↔ 成本」曲线并定位性价比拐点,让「全做」和「只做前几个市场」在同一张表上对账。

站内那篇游戏本地化与多语言指南 把「本地化要花多少钱、找谁做、分几期」讲得很细,本节不重复它的预算框架。上一节我们把「哪些素材会踩哪个地区的禁忌」算成了一张命中矩阵,但矩阵只回答要不要换,不回答换了要花多少。本节补的就是这一环:把「换素材」翻译成变体数量,再把变体数量翻译成钱,让「给所有市场都做一套」和「只做性价比最高的那几个」这两条路线在同一张表上对账。

变体是成本项,不是翻译的副产品

很多团队做本地化时只算翻译的钱:一句文案多少钱、一种语言多少钱。美术变体常常被当成「顺手改改」,直到真正开工才发现——角色形象换一套、节庆横幅换一套、货币图标换一套,每一套都是一次完整的美术生产(设计、切图、适配、验收),而且它们不随语言打包,而是随市场走。

更麻烦的是,变体的数量不是线性的。同一个素材在三个市场需要变体,就是 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‰9130000—
2印度430‰1013300020
3巴西550‰12151000150
4日本660‰15214000573
5东南亚760‰1621700030
6韩国850‰18265000533
7德国930‰1926800038
8中东1000‰243520001200

这张表把「全做」的真相摊开了:从 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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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