14.2 竞品拆解与本地化机会

竞品拆解不是抄,是找「竞品没做、但玩家在等」的本地化缺口。本节把同品类竞品的本地化深度按五个维度打分,再算每个语言的覆盖率与「需求 × 缺口」机会分,用一段可播种的 Python 仿真产出竞品排序与语言机会榜,并对照缺口口径与需求口径的分歧,看清小语种的两难。

照例先划边界。站内 Steam 发布专题 (166 篇)是 PC 买断制发行口径,与 H5 小游戏无关;卷一「小游戏市场全景」 讲的是国内平台的分发机制与设计约束(包体、首包、能力矩阵),不涉及海外本地化。本节都不重做,只做一件事:拆开同品类竞品的本地化深度,找出「竞品没做、但玩家在等」的语言缺口。卷一 2.2 竞品拆解方法 已经给过一套与本地化无关的竞品坐标系(核心循环 / 变现 / 素材三维打分);本节把维度换成本地化——界面文案、配音、文化美术、商店文案、支付本地化,看的是「竞品把本地化做到什么程度」。

竞品拆解不是抄,是找「没被满足的语言」

上一节把平台读成了排序机制,这一节把镜头拉到竞品。竞品拆解有两种视角:一种是通用视角(玩法、变现、素材做得怎么样),卷一 2.2 已经用三维打分讲透;另一种是本地化视角——同一款竞品,在英语市场做得很好,但它做了泰语吗?它的配音是英语一条到底,还是每语言都配?它的支付接没接本地钱包?机会往往就藏在「需求大、但竞品覆盖少」的交叉处:一个语言若玩家需求高、六个竞品里却只有一两个做了,那就是一块没被占住的空地。

这套视角和「做不做小语种」的取舍不同——那是一个投入产出决策,见「小语种取舍」 。本节先回答更靠前的问题:竞品到底把本地化做到了什么程度,缺口在哪里。

本地化拆解矩阵(D 层)

下面这段是 D 层配置:五个本地化维度的权重(五项之和恒 1000)、八个语言的需求占比(八项之和恒 1000),以及六个竞品的语言支持与各维度深度分。数值是按同品类公开可观察的产品形态整理的建模输入,标为公开可观察(观察时点 2026-10),不是任何竞品后台的真实数据。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "竞品本地化拆解与本地化机会配置。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):把同品类竞品的本地化深度与各语言的市场供需整理成可机读配置,不是任何竞品后台的真实数据。dimensions 五个本地化维度:uiText=界面文案、voice=配音、culturalArt=文化美术、storeListing=商店文案、paymentLocal=支付本地化。dimensionWeights 五项之和恒 1000(千分比)。languages.demandPermille=该语言在目标受众中的需求占比(千分比,八项之和恒 1000)。competitors[].langs=已支持的语言;competitors[].scores=各维度本地化深度(0–1000)。localizationScore(comp)=floor(Σ scores×weight/1000);coveragePermille(lang)=支持该语言的竞品数×1000/竞品总数;opportunity(lang)=demand×(1000−coverage)/1000。真实竞品语言支持与真实需求分布需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "dimensions": ["uiText", "voice", "culturalArt", "storeListing", "paymentLocal"],
  "dimensionWeights": { "uiText": 300, "voice": 100, "culturalArt": 250, "storeListing": 200, "paymentLocal": 150 },
  "languages": [
    { "id": "en", "name": "英语", "demandPermille": 300 },
    { "id": "ja", "name": "日语", "demandPermille": 140 },
    { "id": "pt", "name": "葡萄牙语(巴西)", "demandPermille": 120 },
    { "id": "es", "name": "西班牙语(拉美)", "demandPermille": 100 },
    { "id": "ko", "name": "韩语", "demandPermille": 90 },
    { "id": "de", "name": "德语", "demandPermille": 90 },
    { "id": "fr", "name": "法语", "demandPermille": 80 },
    { "id": "th", "name": "泰语", "demandPermille": 80 }
  ],
  "competitors": [
    { "id": "merge-world", "name": "合成世界", "langs": ["en", "ja", "ko", "de", "fr"], "scores": { "uiText": 900, "voice": 500, "culturalArt": 700, "storeListing": 850, "paymentLocal": 800 } },
    { "id": "idle-tycoon", "name": "放置大亨", "langs": ["en", "ja", "ko", "es", "pt"], "scores": { "uiText": 850, "voice": 300, "culturalArt": 600, "storeListing": 800, "paymentLocal": 700 } },
    { "id": "block-puzzle", "name": "方块谜题", "langs": ["en", "de", "fr", "es"], "scores": { "uiText": 800, "voice": 200, "culturalArt": 550, "storeListing": 750, "paymentLocal": 650 } },
    { "id": "bubble-blast", "name": "泡泡爆破", "langs": ["en", "pt", "es"], "scores": { "uiText": 700, "voice": 100, "culturalArt": 400, "storeListing": 600, "paymentLocal": 500 } },
    { "id": "dragon-merge", "name": "龙岛合成", "langs": ["en", "ja", "ko", "th", "pt"], "scores": { "uiText": 880, "voice": 600, "culturalArt": 750, "storeListing": 820, "paymentLocal": 780 } },
    { "id": "farm-story", "name": "农场物语", "langs": ["en", "de", "fr", "ja"], "scores": { "uiText": 820, "voice": 400, "culturalArt": 620, "storeListing": 700, "paymentLocal": 600 } }
  ]
}

读这张表要抓住两点。第一,语言支持差异很大:英语六个竞品全支持,日语四个,德语 / 法语 / 韩语 / 西语 / 葡语各三个,泰语只有一个——小语种在竞品侧基本是空白。第二,各维度深度不齐:六个竞品的界面文案都在 700 分以上,但配音最高只有 600——配音是普遍的短板,因为它的成本最高、收益最难量化。这两点合起来就是本节要找的机会。

S 层:拆竞品 + 算本地化机会

下面这段是 S 层仿真脚本 sim/g14_competitor_localization.py。它读配置,先算每个竞品的本地化加权分与最弱维度,再算每个语言的覆盖率与「需求 × 缺口」机会分,产出 JSON + CSV + SVG:

#!/usr/bin/env python3
# file: sim/g14_competitor_localization.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g14_competitor_localization.py --config config/ --out out/
"""竞品本地化拆解与本地化机会仿真(S 层)。

读 config/g14_competitor_localization.json,做两件事:

  1) 竞品本地化拆解:每个竞品按五个维度的加权分算 localizationScore,并指出其最弱维度
     (soft spot,最低加权贡献)——这是「竞品在本地方案上最薄的一块」。
  2) 本地化机会:每个语言的覆盖率 coveragePermille = 支持它的竞品数×1000/竞品总数;
     opportunity = demand×(1000−coverage)/1000,即「需求大、但竞品都没做」的语言排前面。

再给一个对照口径(demandRank 纯需求排序),展示「缺口口径」与「需求口径」的分歧:
小语种在缺口口径下排前、在需求口径下排后,这正是本地化取舍的两端。
口径:竞品语言支持与需求分布是「公开可观察机制」口径的建模假设(观察时点 2026-10),
非实测;真实数据需真实业务环境,命令行不可复现(X)。产出 out/g14_competitor_localization.json + .csv + .svg。
全整数、地板除,无浮点。
"""
from __future__ import annotations

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

WEIGHT_SUM = 1000


def loc_score(comp: dict, weights: dict) -> int:
    return sum(comp["scores"][d] * weights[d] for d in weights) // WEIGHT_SUM


def weakest_dim(comp: dict, weights: dict) -> str:
    """最弱维度 = 原始深度分最低者;并列取 dimensions 顺序首个。"""
    return min(weights, key=lambda d: comp["scores"][d])


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个语言的本地化机会分横条。"""
    W, ROW, PAD, BAR = 780, 34, 200, 430
    H = 66 + 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 = "各语言的本地化机会分(需求 × 竞品缺口)"
    hi = max([r["opportunity"] for r in rows] + [1])
    for i, r in enumerate(rows):
        y = 50 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
                                    "fill": "#374151"}).text = f'{r["name"]}(覆盖 {r["coveragePermille"]}‰)'
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 3), "height": "16", "rx": "3",
                                    "width": str(BAR * r["opportunity"] // hi), "fill": "#0d9488"})
        ET.SubElement(svg, "text", {"x": str(PAD + BAR + 8), "y": str(y + 16), "font-size": "11",
                                    "fill": "#6b7280"}).text = f'{r["opportunity"]}'
    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, "g14_competitor_localization.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    dims = cfg["dimensions"]
    weights = cfg["dimensionWeights"]
    comps = cfg["competitors"]
    n = len(comps)

    # ---- 1) 竞品本地化拆解 ----
    comp_rows = []
    for c in comps:
        wd = weakest_dim(c, weights)
        comp_rows.append({"id": c["id"], "name": c["name"], "langCount": len(c["langs"]),
                          "locScore": loc_score(c, weights), "weakestDim": wd,
                          "weakestScore": c["scores"][wd]})
    comp_rows.sort(key=lambda r: (-r["locScore"], r["id"]))

    # ---- 2) 本地化机会 ----
    lang_rows = []
    for lang in cfg["languages"]:
        covered = sum(1 for c in comps if lang["id"] in c["langs"])
        coverage = covered * WEIGHT_SUM // n
        gap = WEIGHT_SUM - coverage
        opportunity = lang["demandPermille"] * gap // WEIGHT_SUM
        lang_rows.append({"id": lang["id"], "name": lang["name"], "demandPermille": lang["demandPermille"],
                          "covered": covered, "coveragePermille": coverage, "gapPermille": gap,
                          "opportunity": opportunity})
    opp_order = sorted(lang_rows, key=lambda r: (-r["opportunity"], r["id"]))
    demand_order = sorted(lang_rows, key=lambda r: (-r["demandPermille"], r["id"]))

    comp_digest = ",".join(f'{r["id"]}:{r["locScore"]}' for r in comp_rows)
    opp_digest = ",".join(f'{r["id"]}:{r["opportunity"]}' for r in opp_order)
    digest = f"loc:{comp_digest}|opp:{opp_digest}"

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g14_competitor_localization.svg"), opp_order)
    with open(os.path.join(a.out, "g14_competitor_localization.csv"), "w", encoding="utf-8",
              newline="") as f:
        w = csv.writer(f)
        w.writerow(["lang", "name", "demandPermille", "covered", "coveragePermille",
                    "gapPermille", "opportunity", "oppRank", "demandRank"])
        for i, r in enumerate(opp_order):
            dr = next(j for j, x in enumerate(demand_order) if x["id"] == r["id"]) + 1
            w.writerow([r["id"], r["name"], r["demandPermille"], r["covered"], r["coveragePermille"],
                        r["gapPermille"], r["opportunity"], i + 1, dr])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "dimensions": dims, "dimensionWeights": weights,
           "competitors": comp_rows, "languages": lang_rows,
           "opportunityOrder": [r["id"] for r in opp_order],
           "demandOrder": [r["id"] for r in demand_order],
           "digest": digest, "svg": os.path.join(a.out, "g14_competitor_localization.svg"),
           "svgBytes": svg}
    with open(os.path.join(a.out, "g14_competitor_localization.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "competitors": comp_rows,
                      "opportunityOrder": [r["id"] for r in opp_order],
                      "demandOrder": [r["id"] for r in demand_order],
                      "languages": [{"id": r["id"], "coveragePermille": r["coveragePermille"],
                                     "opportunity": r["opportunity"]} for r in opp_order]},
                     ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/g14_competitor_localization.py --config config/ --out out/,六个竞品 + 八个语言的结果是(同机相对量级,绝对数值不可移植):

digest = loc:dragon-merge:792,merge-world:785,idle-tycoon:700,farm-story:671,block-puzzle:645,bubble-blast:515|opp:th:66,pt:60,es:50,ja:46,de:45,ko:45,fr:40,en:0

逐竞品看本地化拆解(最弱维度 是原始深度分最低的一项):

竞品支持语言本地化分最弱维度读法
龙岛合成5792配音(600)综合最强,但配音仍是短板
合成世界5785配音(500)商店文案强(850),配音拖后腿
放置大亨5700配音(300)文化美术中等(600)
农场物语4671配音(400)语言少一种
方块谜题4645配音(200)配音几乎没做
泡泡爆破3515配音(100)各维度全面偏弱

这张表最该记住的是最弱维度一列全是「配音」——六个竞品无一例外。这不是巧合:配音的成本是界面文案的几十倍、收益却最难量化,所以几乎所有竞品都只做「文案本地化」不做「配音本地化」。对一款轻度小游戏来说,这恰恰是一个可选的差异化点(当然要先算清成本,见「本地化成本与排期」 )。

再看语言侧的机会榜(覆盖率 = 支持该语言的竞品占比):

语言需求‰覆盖竞品数覆盖率‰缺口‰机会分
泰语80116683466
葡语(巴西)120350050060
西语(拉美)100350050050
日语140466633446
德语90350050045
韩语90350050045
法语80350050040
英语3006100000

缺口口径 vs 需求口径:小语种的两难

这张机会榜有一个刺眼的结论:泰语排第一(机会分 66),英语排最后(0)。但换个口径看,结论完全反过来——按纯需求排序,英语(300)第一、日语(140)第二,泰语(80)垫底。同一个语言,在两个口径下一个排第一、一个排最后,这就是本地化取舍的两难:

  • 缺口口径(opportunity = 需求 × 缺口)告诉你「哪里没人做」——泰语缺口 834‰、几乎无人覆盖,所以机会分最高;
  • 需求口径(demandPermille)告诉你「哪里盘子大」——英语需求 300‰,是泰语的近四倍,但六个竞品全做了、缺口为 0。

工程上的做法是两个口径一起看,而不是二选一:opportunity 高 + demand 不低的语言(如葡语 120‰、西语 100‰)是性价比最高的落点——既有足够的盘子,又有明显的缺口;而泰语这种「缺口极大但盘子很小」的语言,要么配合平台侧的小语种扶持一起做,要么先放进观察队列。这正是「小语种取舍」 要回答的投入产出问题——本节只负责把「缺口在哪」算清楚。

这也接上了上一节的结论:平台按什么排序决定谁上榜(14.1),竞品在哪个语言留了缺口决定你从哪进(14.2)。两者合起来回答「去哪打」。

X 层:真实竞品语言支持与需求不可复现

必须把边界划清。上面算的是拆解模型:给定一份竞品语言表与需求分布,机会分是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各竞品的真实语言支持与本地化深度(产品会随版本更新扩语言,本节只作「公开可观察机制」引用并标注观察时点 2026-10);
  • 各语言的真实玩家需求分布(同一款游戏在不同地区、不同时期的语言需求差异极大,无法用一组常数刻画);
  • 真实的竞品本地化投入(配音、文化美术的实际成本与团队规模,竞品不公开)。

工程上正确的做法是:把可复现的拆解模型与不可复现的真实数据分开——模型回答「如果竞品覆盖是这样,机会在哪」,真实数据用来在发行后反标定配置(把实测的语言占比与竞品覆盖填回 config,机会榜立刻反映现实)。

自检表

检查项合格标准
拆解有维度五个本地化维度齐备,权重和恒 1000,非单一「语言数」
缺口可算覆盖率与缺口都用整数千分比,可复算
双口径对照缺口口径与需求口径并列,不把两难藏起来
最弱维度可见每个竞品给出最弱维度,暴露共性短板
与取舍分工只算「缺口在哪」,投入产出交 8.3,不越界
边界清晰真实语言支持与需求标 X,不写成「实测」

竞品拆解最危险的错觉,是把「竞品语言多」当成「没机会了」。真正决定你从哪进的,是竞品没覆盖、但玩家需求不低的那块交叉空地——缺口大而盘子也够大的语言,才是性价比最高的落点。下一节把视角从「竞品」抬到「品类」:同一个品类,在北美、日本、韩国、东南亚的表现差异有多大,以及这个差异到底稳不稳。

阅读导航:上一节:14.1 各平台榜单结构 · 下一节:14.3 品类在海外市场的差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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