站内 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,而不是抛错。于是「差异」从代码变成了数据。
能力位:把「这个平台有没有」变成一位整数
差异的第一类是能力的有无。与其给每个渠道存一堆布尔字段,不如把能力压成一位整数,一个渠道的能力集就是一个掩码:
| 能力位 | 值 | 含义 |
|---|---|---|
| rewarded | 1 | 激励视频 |
| interstitial | 2 | 插屏 |
| banner | 4 | 横幅 |
| share | 8 | 分享 |
| vibrate | 16 | 震动反馈 |
| cloudSave | 32 | 云存档 |
| iap | 64 | 内购 |
| leaderboard | 128 | 排行榜 |
一个功能则声明它需要哪些位(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 项 | 7 | 1200 | 4 |
| 抖音小游戏 | 187 | 除内购外 6 项 | 6 | 1100 | 3 |
| Poki(Web) | 7 | 激励 + 插屏 | 2 | 1000 | 2 |
| CrazyGames(Web) | 135 | 激励 + 插屏 | 2 | 1000 | 3 |
这张表本身就是一份发行决策依据。微信能力最全,可以上内购和好友榜;抖音少了内购(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 构建矩阵与产物管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。