3.1 平台抽象层与渠道差异化配置

一份代码要同时上微信、抖音、Poki、CrazyGames,差异不该散落在 if-else 里。本节用一层 Host 抽象接口加一份渠道配置,把能力位、激励倍数、插屏节流、离线上限全部收敛成可解析的整数配置,配 Python 仿真与 TypeScript 内核跨语言对拍。

站内 Defold 的跨平台发布指南 讲的是一个引擎自带的多平台导出——同一套 Defold 工程导出 HTML5 与各主机包,重点在导出流程。本节的角度完全不同:它不讲任何引擎的导出按钮,而是讲当《合成小岛》要同时上微信、抖音、Poki、CrazyGames 四个平台、每家的广告 API / 分享 / 云存档都不一样时,代码该怎么组织。结论一句话:一份游戏逻辑 + 一层 Host 抽象接口 + 一份渠道配置——差异全部收敛进配置,逻辑里一个 if (platform === "...") 都不写。这与卷二「一份逻辑、两种宿主」 是同一个信念,只是那节讲宿主(浏览器 / 测试),本节讲渠道。

差异不该写进 if-else

先看反例。如果直接在主循环里写渠道判断,代码会迅速腐化成这样:

if (platform === "wechat") { reward *= 1.2; cooldown = 30; showWxRewarded(); }
else if (platform === "douyin") { reward *= 1.1; showDyRewarded(); }
else if (platform === "poki") { interstitialEvery = 2; showPokiAd(); }
...

问题有三个:一是散,同一件事(比如「激励视频给几倍」)散落在十几处,改一次要翻遍全仓;二是漏,新接一个平台就要在所有 if 链里补一支,漏一处就是线上事故;三是不可测,逻辑和平台 SDK 耦在一起,单测根本跑不起来。

正确的结构是把「平台能做什么」与「游戏要做什么」分开。游戏逻辑只调用一组语义化方法(showRewarded / showInterstitial / vibrate),这组方法构成 Host 抽象接口;接口背后的实现由渠道配置驱动——某个渠道没有震动能力,vibrate 就安全降级返回 false,而不是抛错。于是「差异」从代码变成了数据。

能力位:把「这个平台有没有」变成一位整数

差异的第一类是能力的有无。与其给每个渠道存一堆布尔字段,不如把能力压成一位整数,一个渠道的能力集就是一个掩码:

能力位值含义
rewarded1激励视频
interstitial2插屏
banner4横幅
share8分享
vibrate16震动反馈
cloudSave32云存档
iap64内购
leaderboard128排行榜

一个功能则声明它需要哪些位(requiredMask)。判定极简:(capMask & requiredMask) === requiredMask——所有需要的位都在,功能才开。比如「好友榜」同时要排行榜和分享(128 | 8 = 136),只支持排行榜的平台会被正确挡掉。好处是可枚举、可对拍、无浮点:整个能力矩阵就是一个整数网格,Python 和 TypeScript 算出来必须逐位相同。

一份配置,四种渠道

渠道配置放在 config/g03_channels.json:一份 base 给出所有渠道的公共默认值,每个渠道用 overrides 只覆盖它不同的那几项。注意 overrides 为空 {} 也是合法的(表示完全继承 base)。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测快照。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "渠道差异化配置表。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测快照。capMask 是能力位掩码(rewarded=1/interstitial=2/banner=4/share=8/vibrate=16/cloudSave=32/iap=64/leaderboard=128);overrides 是渠道相对 base 的覆盖;requiredMask 是功能对能力位的最小要求。全整数,无浮点。真实平台能力以各家开发者文档为准,命令行不可复现(X)。",
  "base": { "adCooldownSec": 30, "rewardMultiplierPermille": 1000, "interstitialEveryRuns": 3, "offlineCapHours": 8 },
  "features": [
    { "id": "rewarded_double", "name": "激励翻倍", "requiredMask": 1 },
    { "id": "interstitial_level_end", "name": "过关插屏", "requiredMask": 2 },
    { "id": "share_invite", "name": "分享邀请", "requiredMask": 8 },
    { "id": "haptic", "name": "震动反馈", "requiredMask": 16 },
    { "id": "offline_earnings", "name": "离线收益", "requiredMask": 32 },
    { "id": "paywall", "name": "内购商城", "requiredMask": 64 },
    { "id": "friend_rank", "name": "好友榜", "requiredMask": 136 }
  ],
  "channels": [
    { "id": "wechat", "name": "微信小游戏", "capMask": 255, "overrides": { "rewardMultiplierPermille": 1200, "interstitialEveryRuns": 4 } },
    { "id": "douyin", "name": "抖音小游戏", "capMask": 187, "overrides": { "rewardMultiplierPermille": 1100 } },
    { "id": "poki", "name": "Poki(Web)", "capMask": 7, "overrides": { "interstitialEveryRuns": 2 } },
    { "id": "crazygames", "name": "CrazyGames(Web)", "capMask": 135, "overrides": {} }
  ]
}

S 层:把渠道矩阵解析出来

下面这段是 S 层仿真脚本 sim/g03_channel_matrix.py。它读上面那份 config,把每个渠道解析成「有效配置 + 可用功能集」,统计启用功能数并产出 JSON + CSV + SVG。注意它做的事和游戏运行时做的事逐位相同——这正是「一份 config,多种消费」的意义。

#!/usr/bin/env python3
# file: sim/g03_channel_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g03_channel_matrix.py --config config/ --out out/
"""渠道差异化配置解析仿真:读 config/g03_channels.json,把「一份 base + 每渠道覆盖」
解析成每个渠道的「有效配置 + 可用功能集」。

  有效配置 = base 叠加 overrides(缺省继承 base);
  可用功能 = capMask 含 requiredMask 的每一位((capMask & requiredMask) == requiredMask);
  digest    = 每渠道「id:capMask:启用数:激励倍数:冷却秒:插屏间隔:离线小时」串接。

口径:平台能力位是「公开可观察机制」的建模假设,真实能力以各家开发者文档为准,
命令行不可复现(X)。产出 JSON + CSV + SVG,全整数,无浮点。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET

BASE_KEYS = ("adCooldownSec", "rewardMultiplierPermille", "interstitialEveryRuns", "offlineCapHours")
CSV_COLS = ("id", "capMask", "enabledCount", "rewardMultiplierPermille", "adCooldownSec",
            "interstitialEveryRuns", "offlineCapHours", "enabledFeatures")


def feature_enabled(cap_mask: int, required_mask: int) -> bool:
    return (cap_mask & required_mask) == required_mask


def resolve(cfg: dict, ch: dict) -> dict:
    eff = dict(cfg["base"])
    eff.update(ch.get("overrides", {}))
    enabled = [f["id"] for f in cfg["features"] if feature_enabled(ch["capMask"], f["requiredMask"])]
    return {"id": ch["id"], "name": ch["name"], "capMask": ch["capMask"], "enabledFeatures": enabled,
            "enabledCount": len(enabled), **{k: eff[k] for k in BASE_KEYS}}


def channel_digest(r: dict) -> str:
    return (f'{r["id"]}:{r["capMask"]}:{r["enabledCount"]}:{r["rewardMultiplierPermille"]}'
            f':{r["adCooldownSec"]}:{r["interstitialEveryRuns"]}:{r["offlineCapHours"]}')


def draw_svg(path: str, rows: list) -> int:
    """纯 Python SVG:每渠道一条横向堆叠条——启用功能数 vs 被能力位挡掉的功能数。"""
    W, H, PAD, ROW, TOTAL = 760, 70 + 46 * len(rows), 210, 46, 7
    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 = "各渠道可用功能:能力位通过 vs 被挡"
    span = W - PAD - 120
    for i, r in enumerate(rows):
        y = 52 + i * ROW
        on, off = r["enabledCount"], TOTAL - r["enabledCount"]
        w_on = span * on // TOTAL
        w_off = span * off // TOTAL
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                     "fill": "#374151"}).text = r["name"]
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w_on), "height": "22",
                                     "rx": "4", "fill": "#2563eb"})
        ET.SubElement(root, "rect", {"x": str(PAD + w_on), "y": str(y + 6), "width": str(w_off),
                                     "height": "22", "rx": "4", "fill": "#e5e7eb"})
        ET.SubElement(root, "text", {"x": str(PAD + w_on + w_off + 8), "y": str(y + 22), "font-size": "12",
                                     "fill": "#6b7280"}).text = f'{on}/{TOTAL} · cap={r["capMask"]}'
    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/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g03_channels.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    rows = [resolve(cfg, ch) for ch in cfg["channels"]]
    digest = ",".join(channel_digest(r) for r in rows)
    total_enabled = sum(r["enabledCount"] for r in rows)

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g03_channel_matrix.svg"), rows)
    with open(os.path.join(a.out, "g03_channel_matrix.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(CSV_COLS)
        for r in rows:
            w.writerow([r["id"], r["capMask"], r["enabledCount"], r["rewardMultiplierPermille"],
                        r["adCooldownSec"], r["interstitialEveryRuns"], r["offlineCapHours"],
                        "|".join(r["enabledFeatures"])])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "channelCount": len(rows), "featureCount": len(cfg["features"]),
           "totalEnabled": total_enabled, "digest": digest, "channels": rows,
           "svg": os.path.join(a.out, "g03_channel_matrix.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "g03_channel_matrix.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)

    print(json.dumps({"digest": digest, "channelCount": len(rows), "totalEnabled": total_enabled,
                      "channels": [{"id": r["id"], "capMask": r["capMask"],
                                    "enabledCount": r["enabledCount"],
                                    "enabledFeatures": r["enabledFeatures"]} for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/g03_channel_matrix.py --config config/ --out out/,四个渠道的解析结果是(同机相对量级,绝对数值不可移植):

渠道capMask可用功能启用数激励倍数‰插屏间隔
微信小游戏255全部 7 项712004
抖音小游戏187除内购外 6 项611003
Poki(Web)7激励 + 插屏210002
CrazyGames(Web)135激励 + 插屏210003

这张表本身就是一份发行决策依据。微信能力最全,可以上内购和好友榜;抖音少了内购(capMask 里没有 64 位),paywall 自动关闭,逻辑代码一行都不用改;两个 Web 平台只有 7 与 135,连云存档和震动都没有,所以 offline_earnings 与 haptic 都开不起来——功能是「被配置挡掉」的,不是「被注释掉」的。同一个二进制在四个平台跑出的行为差异,全部来自这份 config。

P 层内核:平台抽象层与跨语言对拍

一张表看不出「是解析对」还是「碰巧看着对」。下面这段是 P 层 TypeScript 内核 core/src/g03/platform.ts:它复算同一套能力门控与覆盖合并,还给出游戏逻辑真正依赖的 Host 抽象接口。读同一份 config,产出与 Python 逐字符一致的摘要。

// file: core/src/g03/platform.ts
// tier: P
// verify: node --test 'core/test/g03_*.test.ts'
//
// 平台抽象层 + 渠道差异化配置:一份 config/g03_channels.json 解析出每个渠道的
// 「有效配置 + 可用功能集」,游戏逻辑只依赖 Host 接口,不直接碰平台 API。
// 与 sim/g03_channel_matrix.py 逐位等价的 TypeScript 对偶实现。
// 全整数、无浮点、无 Math.random(只用可擦除语法,禁 enum / namespace)。

export interface ChannelBase {
  readonly adCooldownSec: number;
  readonly rewardMultiplierPermille: number;
  readonly interstitialEveryRuns: number;
  readonly offlineCapHours: number;
}
export interface ChannelOverride {
  readonly adCooldownSec?: number;
  readonly rewardMultiplierPermille?: number;
  readonly interstitialEveryRuns?: number;
  readonly offlineCapHours?: number;
}
export interface ChannelProfile {
  readonly id: string; readonly name: string;
  readonly capMask: number; readonly overrides: ChannelOverride;
}
export interface Feature {
  readonly id: string; readonly name: string; readonly requiredMask: number;
}
export interface ChannelConfig {
  readonly version: number; readonly game: string;
  readonly base: ChannelBase; readonly features: readonly Feature[];
  readonly channels: readonly ChannelProfile[];
}
export interface ResolvedChannel {
  readonly id: string; readonly capMask: number; readonly effective: ChannelBase;
  readonly enabledFeatures: readonly string[]; readonly enabledCount: number;
}

/** 能力位:一处定义,全卷复用。 */
export const CAP = {
  rewarded: 1, interstitial: 2, banner: 4, share: 8,
  vibrate: 16, cloudSave: 32, iap: 64, leaderboard: 128,
} as const;

/** 能力门控:capMask 必须包含 requiredMask 的每一位。 */
export function featureEnabled(capMask: number, requiredMask: number): boolean {
  return (capMask & requiredMask) === requiredMask;
}

/** 解析单个渠道:base 叠加 overrides,再按能力位筛出可用功能。 */
export function resolveChannel(cfg: ChannelConfig, ch: ChannelProfile): ResolvedChannel {
  const effective: ChannelBase = { ...cfg.base, ...ch.overrides };
  const enabled: string[] = [];
  for (const f of cfg.features) if (featureEnabled(ch.capMask, f.requiredMask)) enabled.push(f.id);
  return { id: ch.id, capMask: ch.capMask, effective, enabledFeatures: enabled, enabledCount: enabled.length };
}

export function resolveAll(cfg: ChannelConfig): ResolvedChannel[] {
  return cfg.channels.map((ch) => resolveChannel(cfg, ch));
}

/** 渠道摘要:与 sim/g03_channel_matrix.py 的 digest 逐字符一致。 */
export function channelDigest(r: ResolvedChannel): string {
  const e = r.effective;
  return `${r.id}:${r.capMask}:${r.enabledCount}:${e.rewardMultiplierPermille}`
    + `:${e.adCooldownSec}:${e.interstitialEveryRuns}:${e.offlineCapHours}`;
}
export function digest(rows: readonly ResolvedChannel[]): string {
  return rows.map(channelDigest).join(",");
}

/** 平台抽象层:游戏逻辑只认这个接口,不关心底下是哪个渠道。 */
export interface Host {
  readonly channelId: string;
  showRewarded(): { readonly ok: boolean; readonly rewardMultiplierPermille: number };
  showInterstitial(runIndex: number): { readonly ok: boolean };
  vibrate(ms: number): boolean;
}

/** 按渠道能力位造一个宿主;能力缺失时对应方法安全降级(不抛错、不静默无反馈)。 */
export function createHost(cfg: ChannelConfig, ch: ChannelProfile): Host {
  const r = resolveChannel(cfg, ch);
  const has = (m: number) => featureEnabled(r.capMask, m);
  return {
    channelId: r.id,
    showRewarded: () => has(CAP.rewarded)
      ? { ok: true, rewardMultiplierPermille: r.effective.rewardMultiplierPermille }
      : { ok: false, rewardMultiplierPermille: 0 },
    showInterstitial: (runIndex: number) => ({
      ok: has(CAP.interstitial) && runIndex > 0 && runIndex % r.effective.interstitialEveryRuns === 0,
    }),
    vibrate: (ms: number) => has(CAP.vibrate) && ms > 0,
  };
}

配套测试 core/test/g03_platform.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验能力门控、覆盖合并与宿主降级。在本机跑 node --test 'core/test/g03_*.test.ts',本节 6 个用例全过;两套独立实现给出同一个摘要:

digest = wechat:255:7:1200:30:4:8,douyin:187:6:1100:30:3:8,poki:7:2:1000:30:2:8,crazygames:135:2:1000:30:3:8

只要有一边的位运算、覆盖合并方向或功能门槛写错,摘要立刻分叉。注意 createHost 的价值:游戏逻辑里只有 host.showRewarded(),它在微信返回 1.2 倍、在 Poki 返回 1.0 倍,而这个差异来自 config 而非代码分支——这就是「一份逻辑、多个渠道」真正落地的地方。

X 层:真实平台能力不可复现

必须划清边界。上面算的是模型侧:给定能力位与覆盖,解析出的配置是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的平台能力清单(某渠道到底有没有横幅、有没有震动、分享回调是同步还是异步),以各家开发者文档为准,且会随版本变化;
  • 真实的广告 SDK 行为(激励视频的完成判定、插屏的关闭回调、超时与失败重试策略);
  • 真实的审核红线(某些平台禁止在未成年人场景展示某类广告,这不是 config 能表达的,要靠第 9 章的合规工程)。

工程上正确的做法是:把可复现的解析器与不可复现的平台事实分开——解析器进单测(本节),平台事实进 config/(随平台文档更新而更新,带 observedAt 观察时点)。

自检表

检查项合格标准
逻辑无分支游戏逻辑里不出现渠道字符串判断,全部走 Host 接口
能力可枚举平台能力用整数位掩码表达,功能声明 requiredMask
覆盖可追溯每个渠道的有效值能追到 base 还是 override
缺失安全降级能力不存在时方法返回安全值,不抛错、不崩
跨语言一致Python 与 TS 两套实现的摘要逐字符相等
边界清晰真实平台能力标 X,不写成「实测」

平台抽象层最容易犯的错,是把它做成「一层薄薄的转发」——接口方法原样透传平台 SDK,差异照旧漏进逻辑。真正的抽象层必须把差异变成数据:逻辑只问「能不能做」,配置回答「怎么做」。下一节我们把这套渠道配置送进构建流水线——一份代码,怎么产出多个平台的产物,又怎么保证每个产物都不超包体预算。

阅读导航:上一节:2.3 平台专属能力:存档、排行榜与分享 · 下一节:3.2 构建矩阵与产物管理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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