1.3 平台选择与组合策略

平台选择不是「哪个平台最好」,而是在有限人力下「先接哪几个」。本节用适配度加权评分给每个平台打分,再用收益效率做贪心装箱,在总人天预算内最大化组合净额,并用 B 基准逐条扰动权重与预算,检验组合选择对参数有多敏感,最后给出《合成小岛》的落点组合。

站内有一篇 H5 游戏产业全景 ,它的结论之一是「平台政策会变,要保留多渠道冗余」——但没告诉你在有限人力下到底先接哪几个。本节填的就是这个空。卷一 3.3 品类评分的敏感度 已经示范过一套加权评分 + 敏感度的方法论,本节把它从「选品类」搬到「选平台」:维度换成平台特征,扰动换成权重与预算,产物是一个能在人天预算内最大化净额的平台组合。前两节把平台分了层(1.1)、把条款算了净额(1.2),这一节把两张表合成一个可执行的决策。

平台选择的三个约束

选平台不是比「谁最好」,而是三个约束下的取舍:

约束含义本节的字段
适配度这个平台和《合成小岛》这类轻度合成游戏合不合scores(五维 0-100)
收益接进去能带来多少月净额netFen
成本接入与适配要花多少工程人天manDays

三个约束里,成本最容易被忽略。一个平台净额再高,如果接入要花 20 个人天、而你全公司只有 40 个人天,那它就会挤掉三四个更小的平台。所以「选平台」本质是一个预算受限的组合优化:给定总人天,选一组平台,让净额最大。

组合评分模型(S 层)

下面这段是 S 层仿真脚本 sim/g01_selection.py。它读 config/g01_selection.json,按加权适配度给每个平台打分,算「适配加权净额」与「每工程人天的效率」,再用贪心装箱在预算内选出组合,产出 JSON + CSV + SVG。配置里每个平台的维度原始分、净额、人天都是建模假设(观察时点 2026-10),不是平台数据。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "平台选择与组合策略参数。dimensions=适配度维度与权重(整数,权重是本模型自设的输入,不是平台数据);platforms[].scores=各维度原始分(0-100,建模假设);netFen=该平台满配下的月净额(分,建模假设);manDays=接入与适配的工程人天(建模假设);budgetManDays=总接入预算。全部为「公开可观察机制」口径的建模假设,观察时点 2026-10;真实流量、真实分成与真实接入成本需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "budgetManDays": 40,
  "dimensions": [
    { "id": "share", "name": "分成友好度", "weight": 3 },
    { "id": "reach", "name": "流量规模", "weight": 3 },
    { "id": "integration", "name": "接入成本低", "weight": 2 },
    { "id": "review", "name": "审核宽松", "weight": 2 },
    { "id": "iap", "name": "内购支持", "weight": 1 }
  ],
  "platforms": [
    { "id": "poki", "name": "Poki", "netFen": 3500000, "manDays": 6, "scores": { "share": 70, "reach": 60, "integration": 80, "review": 70, "iap": 10 } },
    { "id": "crazygames", "name": "CrazyGames", "netFen": 2500000, "manDays": 5, "scores": { "share": 50, "reach": 55, "integration": 85, "review": 70, "iap": 10 } },
    { "id": "gamedist", "name": "GameDistribution", "netFen": 3500000, "manDays": 4, "scores": { "share": 70, "reach": 50, "integration": 90, "review": 75, "iap": 10 } },
    { "id": "yt_playables", "name": "YouTube Playables", "netFen": 2750000, "manDays": 9, "scores": { "share": 55, "reach": 80, "integration": 55, "review": 45, "iap": 10 } },
    { "id": "fb_instant", "name": "Facebook Instant Games", "netFen": 4900000, "manDays": 12, "scores": { "share": 70, "reach": 75, "integration": 50, "review": 55, "iap": 70 } },
    { "id": "appstore", "name": "App Store", "netFen": 4900000, "manDays": 20, "scores": { "share": 70, "reach": 90, "integration": 35, "review": 30, "iap": 90 } },
    { "id": "gplay", "name": "Google Play", "netFen": 4900000, "manDays": 18, "scores": { "share": 70, "reach": 95, "integration": 40, "review": 35, "iap": 90 } },
    { "id": "steam", "name": "Steam(PC 对照)", "netFen": 4900000, "manDays": 25, "scores": { "share": 70, "reach": 45, "integration": 30, "review": 40, "iap": 85 } }
  ]
}
#!/usr/bin/env python3
# file: sim/g01_selection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g01_selection.py --config config/ --out out/
"""平台选择与组合策略:读 config/g01_selection.json,按加权适配度给每个平台打分,再用
「收益效率 = 适配加权净额 / 人天」做贪心装箱,在总人天预算内最大化组合净额。全整数。

    weightSum   = Σ dimension.weight
    score       = Σ (weight * platform.scores[dim]) // weightSum       (0-100 适配度)
    priorityFen = netFen * score // 100                                (适配加权后的月净额)
    efficiency  = priorityFen // manDays                               (每工程人天换来的净额)

贪心:按 efficiency 降序取平台,装得下就放,装不下就跳过继续试更小的平台,直到遍历完。
口径:维度权重是本模型自设输入,平台原始分、净额、人天均为建模假设,观察时点 2026-10;
真实流量、真实分成与真实接入成本需真实业务环境,命令行不可复现(X)。
产出 out/g01_selection.json + .csv + .svg。
"""
from __future__ import annotations

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


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


def evaluate(p: dict, dims: list[dict]) -> dict:
    weight_sum = sum(d["weight"] for d in dims)
    score = sum(d["weight"] * p["scores"][d["id"]] for d in dims) // weight_sum if weight_sum else 0
    priority = p["netFen"] * score // 100
    return dict(p, score=score, priorityFen=priority,
                efficiency=priority // p["manDays"] if p["manDays"] else 0)


def select(rows: list[dict], budget_man_days: int) -> dict:
    """按 efficiency 降序贪心装箱;跳过装不下的,继续试更小的平台。"""
    chosen, used = [], 0
    for r in sorted(rows, key=lambda r: (-r["efficiency"], r["id"])):
        if used + r["manDays"] <= budget_man_days:
            chosen.append(r)
            used += r["manDays"]
    return {"ids": [r["id"] for r in chosen], "count": len(chosen), "manDays": used,
            "netFen": sum(r["netFen"] for r in chosen),
            "priorityFen": sum(r["priorityFen"] for r in chosen),
            "efficiency": sum(r["netFen"] for r in chosen) // used if used else 0,
            "avgScore": sum(r["score"] for r in chosen) // len(chosen) if chosen else 0}


def draw_svg(path: str, rows: list[dict], chosen_ids: set) -> int:
    """每个平台一条横条:适配加权净额(分),入选组合标绿。"""
    W, H, PAD, ROW = 800, 70 + 40 * len(rows), 230, 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": "28", "font-size": "15",
                                "fill": "#111827"}).text = "各平台适配加权净额(分):绿=入选组合"
    span, hi = W - PAD - 170, max([r["priorityFen"] for r in rows] + [1])
    for i, r in enumerate(rows):
        y = 52 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
                                    "fill": "#374151"}).text = r["name"]
        w = span * r["priorityFen"] // hi
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
                                    "height": "18", "rx": "3",
                                    "fill": "#16a34a" if r["id"] in chosen_ids else "#94a3b8"})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 6), "y": str(y + 18), "font-size": "11",
                                    "fill": "#6b7280"}).text = (
            f'分 {r["score"]} · 效率 {r["efficiency"]} · {r["manDays"]}人天')
    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)
    dims = cfg["dimensions"]
    rows = [evaluate(p, dims) for p in cfg["platforms"]]
    port = select(rows, cfg["budgetManDays"])
    chosen_ids = set(port["ids"])
    digest = ",".join(f'{r["id"]}:{r["score"]}:{r["efficiency"]}' for r in rows) + ";" + "|".join(port["ids"])

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g01_selection.svg"), rows, chosen_ids)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "budgetManDays": cfg["budgetManDays"], "weightSum": sum(d["weight"] for d in dims),
              "platformCount": len(rows), "platforms": rows, "portfolio": port, "digest": digest,
              "svg": os.path.join(args.out, "g01_selection.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g01_selection.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g01_selection.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "score", "priorityFen", "efficiency", "netFen", "manDays", "selected"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["score"], r["priorityFen"], r["efficiency"],
                        r["netFen"], r["manDays"], 1 if r["id"] in chosen_ids else 0])

    print(json.dumps({
        "budgetManDays": cfg["budgetManDays"],
        "platforms": [{"id": r["id"], "score": r["score"], "efficiency": r["efficiency"],
                       "manDays": r["manDays"]} for r in rows],
        "portfolio": port, "digest": digest}, ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/g01_selection.py --config config/ --out out/,各平台得分与效率是(同机相对量级,绝对数值不可移植):

平台适配分效率(分/人天)人天入选
GameDistribution635512504✅
Poki633675006✅
CrazyGames572850005✅
Facebook Instant Games6526541612✅
Google Play6617966618—
YouTube Playables551680559✅
App Store6315435020—
Steam(PC 对照)519996025—

贪心装箱在 40 人天预算内选出的组合是 GameDistribution + Poki + CrazyGames + Facebook Instant Games + YouTube Playables:共 5 个平台、用掉 36 人天、组合月净额 17,150,000 分。注意两件事:第一,适配分最高的 Google Play(66)没入选——它 18 人天太贵,效率只有 179666,排到了 YouTube 后面;第二,效率最高的 GameDistribution(551250)不是净额最高的——它靠 4 人天的低成本取胜。这就是「组合优化」和「选最好平台」的区别:预算受限时,便宜的平台反而优先。

B 基准:组合对权重与预算有多敏感

上面这个组合是算出来的,不是确定的——它依赖两个人为输入:维度权重与总人天预算。所以必须问:「如果我把某个权重或预算上下调 20%,组合会翻盘吗?」下面这段是 B 层基准脚本 sim/g01_selection_bench.py,逐条扰动后重跑选择,软告警不硬失败。

#!/usr/bin/env python3
# file: sim/g01_selection_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/g01_selection_bench.py --config config/ --out out/
"""B 基准:平台组合选择对「维度权重」与「人天预算」的稳健性。

选择结论依赖 dimensions.weight——而权重是**人的判断**,不是客观常数。所以要问:
「如果我把某个维度权重上调 / 下调 20%,或者预算多给 / 少给 20%,入选组合会翻盘吗?」
做法:逐维度权重 ×0.8 / ×1.2,以及预算 ×0.8 / ×1.2,各自重跑「打分 → 效率 → 贪心装箱」,
逐平台比较是否入选,记录进出组合的平台。
软告警:任一扰动使组合成员发生变化即计入翻转(说明该选择对参数敏感)。退出码恒为 0。
全整数,无浮点。
"""
from __future__ import annotations

import argparse
import json
import os

from g01_selection import evaluate, load, select

PERTURB_PCT = 20


def perturbed_weights(dims: list[dict], key: str, pct: int) -> list[dict]:
    out = []
    for d in dims:
        w = d["weight"] * (100 + pct) // 100 if d["id"] == key else d["weight"]
        out.append({**d, "weight": max(w, 1)})
    return out


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)
    dims = cfg["dimensions"]
    base_port = select([evaluate(p, dims) for p in cfg["platforms"]], cfg["budgetManDays"])
    base_set = set(base_port["ids"])

    cases, flips = [], []
    for d in dims:
        for pct in (-PERTURB_PCT, PERTURB_PCT):
            dims2 = perturbed_weights(dims, d["id"], pct)
            port = select([evaluate(p, dims2) for p in cfg["platforms"]], cfg["budgetManDays"])
            entered = sorted(set(port["ids"]) - base_set)
            left = sorted(base_set - set(port["ids"]))
            cases.append({"type": "weight", "key": d["id"], "pct": pct,
                          "newWeight": next(x["weight"] for x in dims2 if x["id"] == d["id"]),
                          "portfolio": port["ids"], "entered": entered, "left": left})
            if entered or left:
                flips.append({"type": "weight", "key": d["id"], "pct": pct,
                              "entered": entered, "left": left})
    for pct in (-PERTURB_PCT, PERTURB_PCT):
        budget = cfg["budgetManDays"] * (100 + pct) // 100
        port = select([evaluate(p, dims) for p in cfg["platforms"]], budget)
        entered = sorted(set(port["ids"]) - base_set)
        left = sorted(base_set - set(port["ids"]))
        cases.append({"type": "budget", "key": "budgetManDays", "pct": pct, "newValue": budget,
                      "portfolio": port["ids"], "entered": entered, "left": left})
        if entered or left:
            flips.append({"type": "budget", "key": "budgetManDays", "pct": pct,
                          "entered": entered, "left": left})

    os.makedirs(args.out, exist_ok=True)
    rep = {"version": cfg["version"], "source": "sim/g01_selection_bench.py", "perturbPct": PERTURB_PCT,
           "baseBudgetManDays": cfg["budgetManDays"], "basePortfolio": base_port["ids"],
           "cases": cases, "flipCount": len(flips), "flips": flips,
           "verdict": "PASS(soft)" if not flips else f"WARN(soft) {len(flips)} 条翻转"}
    with open(os.path.join(args.out, "g01_selection_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"basePortfolio": base_port["ids"], "flipCount": len(flips),
                      "flips": flips, "verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
    return 0  # B 层软告警:恒返回 0


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

在本机跑它,结果是 2 条翻转(WARN(soft),退出码仍为 0):

扰动组合变化
预算 −20%(40→32 人天)YouTube Playables 掉出组合
预算 +20%(40→48 人天)Google Play 进入、YouTube Playables 掉出

读这张表要读出两件事。第一,权重扰动一条都没翻盘——说明在当前配置下,组合对「维度权重怎么设」不敏感,排名足够稳,这是好事:意味着你的主观偏好不会轻易改变结论。第二,预算扰动两次都翻盘——预算 −20% 时最贵的小平台先掉,预算 +20% 时更贵的 Google Play 反而挤掉了 YouTube。这说明组合的真正旋钮是预算,不是权重。所以给决策者的建议不是「把权重调准」,而是「把预算说清楚」:预算一变,最优组合就变,结论必须和预算一起交代。

X 层:真实适配与成本不可复现

边界必须划清。上面算的是选择模型:给定适配分、净额与人天,预算内最优组合是哪个。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的平台适配分(同一款游戏在不同团队眼里分数不同,没有客观标准);
  • 真实的接入人天(取决于团队熟悉度、平台文档质量、审核往返次数);
  • 真实的月净额(依赖流量结构、条款、内购可用性,是前三节所有模型的合成结果)。

工程上正确的做法是:把可复现的选择模型与不可复现的真实评估分开——模型回答「给定这些输入,组合是哪个」,真实评估用来在接入后反标定参数(把实测人天与真实净额填回 config,组合立刻重排)。这也是第 15 章「把《合成小岛》发到五个海外平台」要串起来的东西。

自检表

检查项合格标准
三约束齐全适配度 / 收益 / 成本都进模型,不缺成本
预算受限选择在明确的人天预算内做,不是无约束排序
组合而非单选输出是一组平台,不是一个「最优平台」
效率留痕每个平台给出效率(净额/人天),可复算
稳健性有据对权重与预算做 ±20% 扰动,量化翻转数
边界清晰真实适配分与人天标 X,不写成「实测」

平台选择最危险的错觉,是以为存在一个「最好的平台」。真实约束下没有最好的平台,只有在给定预算下最划算的一组平台——而这一组会随预算变化。第 1 章到这里把「平台格局」讲完了:1.1 分层与流量结构、1.2 条款与净额、1.3 选择与组合。下一章进入接入层:同一个功能,在各平台的 SDK 与广告 API 上长得有多不一样。

阅读导航:上一节:1.2 各平台分成与结算条款 · 下一节:2.1 平台 SDK 与广告 API 差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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