2.3 平台专属能力:存档、排行榜与多人

云存档、排行榜、实时多人这些平台专属能力,九家给的差别极大:微信、抖音、QQ 六位全有,海外 H5 平台几乎全无。本节把六个能力位摊成一张可算的矩阵,用 Python 仿真算出每个平台的能力覆盖率与降级实现成本,配 TypeScript 能力内核跨语言对拍,并给出「能力缺失时」的降级链——一份逻辑跑九平台。

站内有一组 SaaS 小游戏平台 的文章,那是做平台的口径:怎么设计一套存档、排行榜、联机 API 卖给别人用。本节反过来——作为一款游戏的开发者,去消费这些 API,并且要在九个宿主上消费同一套能力。另有一组小程序工程口径 的内容,讲的是单平台的小程序工程;本节讲的是多平台的能力差异与降级。2.1 解决了广告怎么放、2.2 解决了产品怎么上,这一节解决「上线之后,平台能给你什么、给不了什么」。

平台给的不只是广告位

小游戏上架后,平台往往会附赠一批专属能力:云存档(把进度存在平台侧)、排行榜(全服榜、好友榜)、实时多人(帧同步或房间)、分享(拉新)、云配置(不发版改参数)。这些能力不是每个平台都有:国内头部平台通常给得很全,海外 H5 平台则往往只有「分享」这一项,其余要你自己补。

这就带出一个工程决策:要不要用平台能力? 用了,能力缺失的平台就得写一套降级实现;不用,等于放弃平台侧的社交与留存红利。本节的答案是——用,但把差异关进能力矩阵,把缺失关进降级链。这和卷二「存储与云存档抽象」 是同一个思路,只是把范围从「存档」扩到六种能力。

六个能力位

本节登记六个能力位:云存档(cloudSave)、排行榜(leaderboard)、好友榜(friendRank)、实时多人(realtimeMultiplayer)、分享(share)、云配置(cloudConfig)。每个平台对每一位打 0/1,就能算出能力覆盖率与降级成本:

satisfied    = Σ features[k] for k in featureKeys       # 六个能力位登记值之和
missing      = len(featureKeys) - satisfied
coverage‰    = satisfied * 1000 / len(featureKeys)
fallbackCost = Σ fallbackCost[k] for k in missing features   # 缺失能力的降级实现成本

fallbackCost 是缺失能力时要自己补一套降级实现的工程成本(相对分):云存档缺了要补本地存档 + 导出码(2 分),实时多人缺了要补异步对战(3 分)。它不是「损失了多少钱」,而是「你要多写多少代码」。

降级链:能力缺失怎么办

能力缺失不是「关掉功能」就完事,而是要设计一条降级链——能力在,走平台侧;能力不在,走自建兜底,且两条路对业务层透明:

能力缺失时的降级策略
云存档本地存档 + 导出码
排行榜本地最高分 + 分享截图
好友榜关闭好友榜,仅保留全服榜
实时多人异步对战(幽灵回放)
分享复制口令 / 截图分享
云配置打包内置配置,改配置走发版

降级链的价值在于:业务代码只问「这个能力有没有」,不问「这是哪个平台」。能力探测在适配层完成,业务层拿到的是一个布尔与一个兜底实现。

S 层:能力矩阵仿真

下面这段是 S 层仿真脚本 sim/g02_feature_matrix.py。它读 config/g02_platform_features.json,算出九个平台各自的能力覆盖率、缺失位数与降级实现成本,并额外算出「每个能力位有多少平台提供」,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g02_feature_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g02_feature_matrix.py --config config/ --out out/
"""平台专属能力矩阵仿真:算出每个平台缺哪些能力、要补多少降级实现的成本。

读 config/g02_platform_features.json:

    satisfied    = Σ features[k] for k in featureKeys         # 六个能力位登记值之和
    missing      = len(featureKeys) - satisfied
    coverage‰    = satisfied * 1000 // len(featureKeys)
    fallbackCost = Σ fallbackCost[k] for k in missing features # 缺失能力的降级实现成本

再按能力位给出「多少平台提供该能力」的覆盖率。产出 out/g02_feature_matrix.json + .csv + .svg。
全程整数、地板除,无浮点。能力位是「公开可观察机制」口径的建模假设;真实平台能力与配额、
真实多人联机服务需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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


def evaluate(p: dict, feat_keys: list[str], cost: dict) -> dict:
    have = [k for k in feat_keys if p["features"].get(k, 0) == 1]
    missing = [k for k in feat_keys if p["features"].get(k, 0) == 0]
    satisfied = len(have)
    fallback = sum(cost.get(k, 0) for k in missing)
    return {
        "id": p["id"], "name": p["name"], "region": p["region"],
        "have": have, "missing": missing, "satisfied": satisfied,
        "missingCount": len(missing),
        "coveragePermille": satisfied * 1000 // len(feat_keys),
        "fallbackCostSum": fallback,
    }


def feature_support(cfg: dict) -> list[dict]:
    """按能力位统计:九个平台里有多少个提供该能力。"""
    out = []
    for k in cfg["featureKeys"]:
        n = sum(1 for p in cfg["platforms"] if p["features"].get(k, 0) == 1)
        out.append({"feature": k, "platforms": n})
    out.sort(key=lambda x: (-x["platforms"], x["feature"]))
    return out


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个平台的能力覆盖率(千分比)横条 + 降级成本标注。"""
    W, H, PAD, ROW = 800, 92 + 40 * len(rows), 190, 40
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》九平台专属能力覆盖率(千分比)与降级实现成本")
    span = W - PAD - 24
    for i, r in enumerate(rows):
        y = 56 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
                                     "fill": "#374151"}).text = f'{r["name"]}({r["region"]})'
        w = span * r["coveragePermille"] // 1000
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
                                     "height": "20", "rx": "3", "fill": "#7c3aed"})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 18), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{r["coveragePermille"]}‰ · 缺 {r["missingCount"]} 位 · 降级成本 {r["fallbackCostSum"]}')
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    with open(os.path.join(args.config, "g02_platform_features.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = [evaluate(p, cfg["featureKeys"], cfg["fallbackCost"]) for p in cfg["platforms"]]
    support = feature_support(cfg)
    most_equipped = max(rows, key=lambda r: r["satisfied"])
    most_degraded = max(rows, key=lambda r: r["fallbackCostSum"])

    digest = ",".join(f'{r["id"]}:{r["satisfied"]}:{r["missingCount"]}:'
                      f'{r["fallbackCostSum"]}:{r["coveragePermille"]}' for r in rows)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g02_feature_matrix.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "featureKeys": cfg["featureKeys"], "fallbackCost": cfg["fallbackCost"],
              "platforms": rows, "featureSupport": support,
              "mostEquipped": most_equipped["id"], "mostDegraded": most_degraded["id"],
              "digest": digest, "svg": os.path.join(args.out, "g02_feature_matrix.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g02_feature_matrix.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g02_feature_matrix.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "region", "satisfied", "missingCount", "coveragePermille", "fallbackCostSum")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"digest": digest, "mostEquipped": most_equipped["id"],
                      "mostDegraded": most_degraded["id"], "featureSupport": support,
                      "platforms": rows}, ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/g02_platform_features.json(D 层配置)。能力位是「公开可观察机制」口径的建模假设,不是任何平台的官方能力清单;真实平台能力与配额属于 X 层。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "九平台专属能力(存档 / 排行榜 / 多人)登记表。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):features 是六个能力位的 0/1 登记值;fallbackCost 是「该能力缺失时要自己补一套降级实现」的工程成本(相对分)。satisfied/missing/coveragePermille/fallbackCostSum 由登记值推导,仅用于工程排期与降级设计,不代表任何平台官方能力清单。真实平台能力与配额、真实多人联机服务需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "featureKeys": ["cloudSave", "leaderboard", "friendRank", "realtimeMultiplayer", "share", "cloudConfig"],
  "fallbackCost": { "cloudSave": 2, "leaderboard": 1, "friendRank": 2, "realtimeMultiplayer": 3, "share": 1, "cloudConfig": 1 },
  "platforms": [
    { "id": "wechat", "name": "微信小游戏", "region": "cn", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 1, "realtimeMultiplayer": 1, "share": 1, "cloudConfig": 1 } },
    { "id": "douyin", "name": "抖音小游戏", "region": "cn", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 1, "realtimeMultiplayer": 1, "share": 1, "cloudConfig": 1 } },
    { "id": "kuaishou", "name": "快手小游戏", "region": "cn", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 0, "realtimeMultiplayer": 0, "share": 1, "cloudConfig": 1 } },
    { "id": "qq", "name": "QQ 小游戏", "region": "cn", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 1, "realtimeMultiplayer": 1, "share": 1, "cloudConfig": 1 } },
    { "id": "baidu", "name": "百度小游戏", "region": "cn", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 0, "realtimeMultiplayer": 0, "share": 1, "cloudConfig": 0 } },
    { "id": "alipay", "name": "支付宝小游戏", "region": "cn", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 0, "realtimeMultiplayer": 0, "share": 1, "cloudConfig": 1 } },
    { "id": "poki", "name": "Poki", "region": "global", "features": { "cloudSave": 0, "leaderboard": 0, "friendRank": 0, "realtimeMultiplayer": 0, "share": 1, "cloudConfig": 0 } },
    { "id": "crazygames", "name": "CrazyGames", "region": "global", "features": { "cloudSave": 1, "leaderboard": 1, "friendRank": 0, "realtimeMultiplayer": 0, "share": 1, "cloudConfig": 0 } },
    { "id": "gamedistribution", "name": "GameDistribution", "region": "global", "features": { "cloudSave": 0, "leaderboard": 0, "friendRank": 0, "realtimeMultiplayer": 0, "share": 0, "cloudConfig": 0 } }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/g02_feature_matrix.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

平台满足位数缺失位数覆盖率‰降级成本
微信小游戏6010000
抖音小游戏6010000
快手小游戏426665
QQ 小游戏6010000
百度小游戏335006
支付宝小游戏426665
Poki151669
CrazyGames335006
GameDistribution06010

按能力位再看一层,脚本还给出每个能力位的支持度:分享最普及(8 个平台都有),云存档与排行榜各 7 个,云配置 5 个,而好友榜与实时多人只有 3 个。这两层合起来给出清晰结论:能力越「社交」和越「实时」,跨平台越稀缺。微信 / 抖音 / QQ 六位全有(覆盖率 1000‰、零降级成本),可以放心用满;Poki 只有分享(166‰、降级成本 9),GameDistribution 六位全无(0‰、降级成本 10)——在这两个平台上,你要么自己补一套后端,要么就把社交与联机功能整体砍掉。

P 层:能力探测与降级

S 层算出了矩阵,但落地时业务层要的是一个能力探测入口:问一句「这个平台有没有云存档」,拿到布尔;没有就走降级。下面这段是 P 层 TypeScript 内核 core/src/g02/features.ts,它读同一份 config/g02_platform_features.json,复算同一套覆盖率与降级成本——和 Python 逐字符一致。

// file: core/src/g02/features.ts
// tier: P
// verify: node --test 'core/test/g02_features.test.ts'
//
// 平台专属能力内核:探测某平台提供哪些能力(云存档 / 排行榜 / 多人等),
// 算出能力覆盖率与降级实现成本,并给出缺失能力对应的降级策略——一份逻辑跑九平台。
// 与 sim/g02_feature_matrix.py 逐位等价;读同一份 config/g02_platform_features.json。
// 全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface RawPlatformFeature {
  readonly id: string; readonly name: string; readonly region: string;
  readonly features: Readonly<Record<string, number>>;
}
export interface FeatureConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly featureKeys: readonly string[];
  readonly fallbackCost: Readonly<Record<string, number>>;
  readonly platforms: readonly RawPlatformFeature[];
}

export interface FeatureResult {
  readonly id: string; readonly name: string; readonly region: string;
  readonly have: readonly string[]; readonly missing: readonly string[];
  readonly satisfied: number; readonly missingCount: number;
  readonly coveragePermille: number; readonly fallbackCostSum: number;
}

/** 缺失能力 → 降级策略(工程侧的可执行映射)。 */
export const DEGRADATION: Readonly<Record<string, string>> = {
  cloudSave: "本地存档 + 导出码",
  leaderboard: "本地最高分 + 分享截图",
  friendRank: "关闭好友榜,仅保留全服榜",
  realtimeMultiplayer: "异步对战(幽灵回放)",
  share: "复制口令 / 截图分享",
  cloudConfig: "打包内置配置,改配置走发版",
};

export function evaluate(p: RawPlatformFeature, featKeys: readonly string[],
                         cost: Readonly<Record<string, number>>): FeatureResult {
  const have = featKeys.filter((k) => (p.features[k] ?? 0) === 1);
  const missing = featKeys.filter((k) => (p.features[k] ?? 0) === 0);
  let fallback = 0;
  for (const k of missing) fallback += cost[k] ?? 0;
  return { id: p.id, name: p.name, region: p.region, have, missing,
    satisfied: have.length, missingCount: missing.length,
    coveragePermille: Math.floor((have.length * 1000) / featKeys.length),
    fallbackCostSum: fallback };
}

export function evaluateAll(cfg: FeatureConfig): FeatureResult[] {
  return cfg.platforms.map((p) => evaluate(p, cfg.featureKeys, cfg.fallbackCost));
}

/** 跨语言对拍摘要:与 sim/g02_feature_matrix.py 的 digest 逐字符一致。 */
export function digest(rows: readonly FeatureResult[]): string {
  return rows.map((r) => `${r.id}:${r.satisfied}:${r.missingCount}:${r.fallbackCostSum}:${r.coveragePermille}`)
    .join(",");
}

/** 某个能力位在九个平台里的支持度(从高到低)。 */
export function featureSupport(cfg: FeatureConfig): { feature: string; platforms: number }[] {
  const out = cfg.featureKeys.map((k) => ({
    feature: k,
    platforms: cfg.platforms.filter((p) => (p.features[k] ?? 0) === 1).length,
  }));
  return out.sort((a, b) => (b.platforms - a.platforms) || (a.feature < b.feature ? -1 : 1));
}

/** 某平台的能力是否可用(业务层探测入口,缺失即走降级)。 */
export function hasFeature(cfg: FeatureConfig, platformId: string, feature: string): boolean {
  const p = cfg.platforms.find((x) => x.id === platformId);
  return !!p && (p.features[feature] ?? 0) === 1;
}

/** 缺失能力对应的降级策略。 */
export function degradationFor(feature: string): string {
  return DEGRADATION[feature] ?? "人工评估";
}

配套测试 core/test/g02_features.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并验证能力探测与降级策略。在本机跑 node --test 'core/test/g02_features.test.ts',7 个用例全过;两套独立实现给出同一个摘要:

digest = wechat:6:0:0:1000,douyin:6:0:0:1000,kuaishou:4:2:5:666,qq:6:0:0:1000,baidu:3:3:6:500,alipay:4:2:5:666,poki:1:5:9:166,crazygames:3:3:6:500,gamedistribution:0:6:10:0

hasFeature 与 degradationFor 是这份内核的对外接口:业务层写 if (hasFeature(cfg, platform, "cloudSave")) { 用平台存档 } else { 用本地存档 },同一份逻辑在九个平台都能跑。这正是本卷的核心主张——一份逻辑、多种宿主——在「平台专属能力」这一层的具体落地,也为第 3 章的「平台抽象层」铺好了地基。

自检表

检查项合格标准
口径清晰能力位标「公开可观察机制」+ 观察时点,不写「实测」
覆盖率可复算coverage 由登记值唯一确定,Python / TS 结果一致
降级有策略每个能力缺失都有明确兜底,不只「关掉功能」
探测统一业务层只调 hasFeature,不判断具体平台
成本可排序降级成本可直接比较,用于排期
边界清晰真实能力与配额标 X,不写成「实测」

到此,第 2 章的三件事都齐了:广告怎么接(2.1)、产品怎么上(2.2)、平台能力怎么用(2.3)。但你会发现,三节都在讲差异——差异越多,适配层越厚。第 3 章就来收口:把这些差异收敛成一套平台抽象层与渠道配置,用构建矩阵把「一份代码」自动产出九个平台的产物。

阅读导航:上一节:2.2 上架资料、审核与驳回 · 下一节:3.1 平台抽象层与渠道配置 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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