卷三从这里开始。前两卷把《合成小岛》从设计案写成了能跑的代码,卷三要回答更现实的问题:它靠什么赚钱、怎么赚得久。第 1 章只讲广告变现(IAA)。本节先解决最上游的一件事——把「广告」这个笼统的词,拆成一张能被程序读进来做收益估算、也能被内核逐次判定该不该放行的形态与场景表。形态是「放什么」,场景是「放在哪一步」,两者合起来才决定「这一局玩家会看到几次广告、每次值多少」。
先说分工。本站有一篇小游戏广告变现设计清单 ,那是清单式短文,列出「该做哪些广告位」;本节给的是可运行的收益估算脚本与可配置的广告闸门内核——清单告诉你「放哪里」,这里告诉你「放几次、每次值多少、闸门怎么判定」。本站另有一篇独立开发者的手游广告失败复盘 ,那是从失败视角复盘广告踩过的坑;本节从正向的可复现模型出发,把同样的坑变成可调参、可断言的数字。一个讲「别人怎么翻车」,一个讲「怎么把车开稳」。
四种形态,四种「体验成本」
形态不是「哪种最好」,而是「哪种放在哪一步」。本书把《合成小岛》会用到的广告归成四类:激励视频(玩家主动点、看完换奖励,不打断操作,单次收益最高)、插屏(在关卡结算等自然停顿点弹出的全屏,打断操作,收益中等)、横幅(常驻底部或顶部的窄条,不打断,单次收益最低但展示频繁)、原生(嵌进界面的样式化广告位,观感最好,库存与收益居中)。判据不是画面大小,而是打断性:激励视频让玩家用「看广告」换「省时间」,插屏用「强制停顿」换「更高单价」,横幅几乎零打扰但最便宜。形态选错,后面所有频次与收益讨论都是错的。
把形态与场景写成一张可被程序消费的表(D 层)
下面这段是 D 层静态配置:四种形态各带 optIn(是否需玩家主动同意)、interruptive、每日上限、冷却秒数与单次收益;七个场景各声明用哪种形态、日均触发几次、填充率与 eCPM。要点:参数都是示例假设值,真实 eCPM 与填充率需真实业务环境,命令行不可复现(X);收益一律用 0.1 分(deciFen) 记账,全程整数无浮点。
{
"version": 1,
"series": "《小游戏开发实战》",
"volume": "卷三《上线变现与长线运营》",
"game": "merge-island",
"observedAt": "2026-10",
"note": "广告形态与场景布局表(D 层)。fillPermille / ecpmFen / triggersPerDau / dailyCap / cooldownSec 均为示例假设参数;真实 eCPM、填充率与平台频次上限需真实业务环境,命令行不可复现(X)。收益用 0.1 分(deciFen)记账。",
"units": {
"fillPermille": "填充率,千分比 0–1000",
"ecpmFen": "每千次展示收益(eCPM),单位分",
"triggersPerDau": "每 DAU 日均触发次数",
"revDeciFenPerImp": "每次展示收益,单位 0.1 分(= ecpmFen / 100)",
"revenueDeciFen": "每 DAU 日均收益,单位 0.1 分"
},
"formats": [
{ "id": "rewarded", "name": "激励视频", "optIn": true, "interruptive": false, "dailyCap": 12, "cooldownSec": 30, "revDeciFenPerImp": 26, "ecpmBandFen": [2200, 3000] },
{ "id": "interstitial", "name": "插屏", "optIn": false, "interruptive": true, "dailyCap": 6, "cooldownSec": 90, "revDeciFenPerImp": 12, "ecpmBandFen": [900, 1500] },
{ "id": "banner", "name": "横幅", "optIn": false, "interruptive": false, "dailyCap": 24, "cooldownSec": 60, "revDeciFenPerImp": 3, "ecpmBandFen": [200, 400] },
{ "id": "native", "name": "原生", "optIn": false, "interruptive": false, "dailyCap": 3, "cooldownSec": 120, "revDeciFenPerImp": 15, "ecpmBandFen": [1200, 1800] }
],
"scenes": [
{ "id": "offline-earn", "name": "离线收益翻倍", "format": "rewarded", "triggersPerDau": 3, "fillPermille": 920, "ecpmFen": 2600 },
{ "id": "revive", "name": "失败复活", "format": "rewarded", "triggersPerDau": 2, "fillPermille": 900, "ecpmFen": 2800 },
{ "id": "double-reward", "name": "合成双倍奖励", "format": "rewarded", "triggersPerDau": 4, "fillPermille": 900, "ecpmFen": 2400 },
{ "id": "daily-task", "name": "日常任务领取", "format": "rewarded", "triggersPerDau": 2, "fillPermille": 880, "ecpmFen": 2200 },
{ "id": "level-end", "name": "关卡结算", "format": "interstitial", "triggersPerDau": 6, "fillPermille": 850, "ecpmFen": 1200 },
{ "id": "home-banner", "name": "主界面横幅", "format": "banner", "triggersPerDau": 8, "fillPermille": 950, "ecpmFen": 300 },
{ "id": "shop-native", "name": "商城原生位", "format": "native", "triggersPerDau": 1, "fillPermille": 700, "ecpmFen": 1500 }
]
}
用一段 S 层脚本把场景算成钱
下面这段是 S 层仿真脚本,读上面那份配置,按「触发次数 × 填充率 × eCPM」估算每个场景的日均展示机会与收益,按形态聚合,产出 JSON + CSV + SVG。它的作用不是给出「正确答案」,而是把七个场景的相对贡献排成一张可排序的表——让「该重点优化哪个广告位」从直觉变成数字。
#!/usr/bin/env python3
# file: sim/l01_ads.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l01_ads.py --config config/ --out out/
"""IAA 广告形态与场景布局:读 config/l01_ads.json,按「日均触发次数 × 填充率 × eCPM」
估算每个场景的展示机会与收益结构,产出 JSON + CSV + SVG。全整数,收益用 0.1 分记账。
revenueDeciFen = triggersPerDau * fillPermille * ecpmFen // 100000
triggersPerDau / fillPermille / ecpmFen 均为示例假设参数,真实 eCPM 与填充率需真实
业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
COLOR = {"rewarded": "#2563eb", "interstitial": "#f59e0b",
"banner": "#10b981", "native": "#7c3aed"}
def rev_df(triggers: int, fill: int, ecpm: int) -> int:
"""日均收益,单位 0.1 分:triggers*(fill/1000)*(ecpm/1000) 分 ×10。"""
return triggers * fill * ecpm // 100000
def txt(parent: ET.Element, x: float, y: float, s: str, size: int = 12,
fill: str = "#374151", anchor: str | None = None) -> None:
a = {"x": str(x), "y": str(y), "font-size": str(size), "fill": fill}
if anchor:
a["text-anchor"] = anchor
ET.SubElement(parent, "text", a).text = s
def svg(path: str, scenes: list[dict], fmt_name: dict) -> int:
"""纯 Python SVG:每个场景一根横条,长度正比于日均收益,颜色按形态区分。"""
W, PAD, ROW = 720, 176, 40
H = 76 + ROW * len(scenes)
r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
txt(r, 16, 26, "《合成小岛》各广告场景日均收益(0.1 分/DAU,示例参数)", 15, "#111827")
for k, (fid, c) in enumerate(COLOR.items()):
ET.SubElement(r, "rect", {"x": str(16 + k * 96), "y": "40", "width": "12",
"height": "12", "rx": "2", "fill": c})
txt(r, 33 + k * 96, 51, fmt_name[fid], 11)
bmax = W - PAD - 96
hi = max((s["revenueDeciFen"] for s in scenes), default=1) or 1
for i, s in enumerate(scenes):
y = 76 + i * ROW
w = bmax * s["revenueDeciFen"] // hi
txt(r, 16, y + 20, s["name"])
ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "20",
"rx": "4", "fill": COLOR.get(s["format"], "#6b7280")})
txt(r, PAD + w + 8, y + 21, f'{s["revenueDeciFen"] / 10:.1f}分 · {s["sharePermille"]}‰', 12, "#6b7280")
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
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, "l01_ads.json"), encoding="utf-8") as f:
cfg = json.load(f)
fmt_name = {f["id"]: f["name"] for f in cfg["formats"]}
total = sum(rev_df(s["triggersPerDau"], s["fillPermille"], s["ecpmFen"]) for s in cfg["scenes"])
scenes = []
for s in cfg["scenes"]:
rev = rev_df(s["triggersPerDau"], s["fillPermille"], s["ecpmFen"])
scenes.append({"id": s["id"], "name": s["name"], "format": s["format"],
"triggersPerDau": s["triggersPerDau"], "fillPermille": s["fillPermille"],
"ecpmFen": s["ecpmFen"], "impressionsPerDauMilli": s["triggersPerDau"] * s["fillPermille"],
"revenueDeciFen": rev, "sharePermille": rev * 1000 // total if total else 0})
by_format: dict[str, dict] = {}
for s in scenes:
b = by_format.setdefault(s["format"], {"format": s["format"],
"name": fmt_name.get(s["format"], s["format"]),
"scenes": 0, "revenueDeciFen": 0})
b["scenes"] += 1
b["revenueDeciFen"] += s["revenueDeciFen"]
formats = sorted(by_format.values(), key=lambda b: b["revenueDeciFen"], reverse=True)
for b in formats:
b["sharePermille"] = b["revenueDeciFen"] * 1000 // total if total else 0
os.makedirs(a.out, exist_ok=True)
sb = svg(os.path.join(a.out, "l01_ads.svg"), scenes, fmt_name)
with open(os.path.join(a.out, "l01_ads.json"), "w", encoding="utf-8") as f:
json.dump({"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"sceneCount": len(scenes), "arpdauDeciFen": total,
"shareSumPermille": sum(s["sharePermille"] for s in scenes),
"scenes": scenes, "formats": formats, "svgBytes": sb}, f, ensure_ascii=False, indent=2)
with open(os.path.join(a.out, "l01_ads.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "format", "triggersPerDau", "fillPermille", "ecpmFen",
"impressionsPerDauMilli", "revenueDeciFen", "sharePermille"])
for s in scenes:
w.writerow([s["id"], s["name"], s["format"], s["triggersPerDau"], s["fillPermille"],
s["ecpmFen"], s["impressionsPerDauMilli"], s["revenueDeciFen"], s["sharePermille"]])
print(json.dumps({"sceneCount": len(scenes), "arpdauDeciFen": total,
"shareSumPermille": sum(s["sharePermille"] for s in scenes),
"byFormat": {b["name"]: b["revenueDeciFen"] for b in formats}, "svgBytes": sb},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机跑出来的输出(同机相对量级,绝对数值不可移植):
{
"sceneCount": 7,
"arpdauDeciFen": 338,
"shareSumPermille": 997,
"byFormat": {
"激励视频": 245,
"插屏": 61,
"横幅": 22,
"原生": 10
},
"svgBytes": 2369
}
四个激励视频场景合计贡献了 245‰(约四分之三),插屏 61‰,横幅与原生各只有 22‰ 和 10‰。注意横幅的展示次数最多(8 次/DAU,七个场景里最高)却只值 22‰——这正是「频繁 ≠ 值钱」的量化证据,也是下一节要展开的 eCPM 与填充率权衡的入口。
用一套 P 层闸门内核回答「这一次该不该放行」
收益估算回答「值多少」,真机上还要回答「这一次广告请求到底放不放行」。下面这段是 P 层 TypeScript 内核:它不碰任何平台 API,只按 optIn / 每日上限 / 冷却时间 三条可配置规则,对每次请求返回 shown 或一个拒绝理由。这份内核既服务收益模型(算准展示次数),也被 1.2 节的单文件 HTML 原型直接内联复用——一份逻辑,两处消费。
// file: core/src/l01/adgate.ts
// tier: P
// verify: node --test 'core/test/l01_*.test.ts'
//
// 广告闸门:业务逻辑只回答「这一次广告请求该不该放行」,不碰任何平台 API。
// 规则可配置:optIn 形态需要玩家主动同意;每个形态有每日上限与冷却时间。
// 同一份闸门逻辑既服务收益模型(算准展示次数),也服务单文件 HTML 原型。
// 全整数、无 Math.random、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface AdFormatCfg {
readonly id: string;
readonly optIn: boolean;
readonly dailyCap: number;
readonly cooldownSec: number;
}
export interface AdGateCfg { readonly formats: readonly AdFormatCfg[]; }
export interface GateState {
readonly day: number;
readonly counts: Record<string, number>;
readonly lastSec: Record<string, number>;
readonly impressions: number;
readonly skips: number;
}
export interface AdRequest {
readonly tSec: number;
readonly formatId: string;
readonly optedIn: boolean;
}
export type GateReason = "shown" | "optInRequired" | "dailyCap" | "cooldown" | "unknownFormat";
export interface GateResult {
readonly state: GateState;
readonly reason: GateReason;
readonly formatId: string;
}
export function newGate(day = 1): GateState {
return { day, counts: {}, lastSec: {}, impressions: 0, skips: 0 };
}
function bumpSkip(state: GateState): GateState {
return { day: state.day, counts: state.counts, lastSec: state.lastSec,
impressions: state.impressions, skips: state.skips + 1 };
}
/** 判定一次广告请求。放行则计数 +1、记录时间、展示 +1;拒绝只把 skips +1。 */
export function tryShow(state: GateState, formatId: string, nowSec: number,
optedIn: boolean, cfg: AdGateCfg): GateResult {
const fmt = cfg.formats.find((f) => f.id === formatId);
if (!fmt) return { state: bumpSkip(state), reason: "unknownFormat", formatId };
const count = state.counts[formatId] ?? 0;
if (fmt.optIn && !optedIn) return { state: bumpSkip(state), reason: "optInRequired", formatId };
if (count >= fmt.dailyCap) return { state: bumpSkip(state), reason: "dailyCap", formatId };
const last = state.lastSec[formatId];
if (last !== undefined && nowSec - last < fmt.cooldownSec) {
return { state: bumpSkip(state), reason: "cooldown", formatId };
}
return { formatId, reason: "shown", state: { day: state.day,
counts: { ...state.counts, [formatId]: count + 1 },
lastSec: { ...state.lastSec, [formatId]: nowSec },
impressions: state.impressions + 1, skips: state.skips } };
}
/** 新的一天:清空每日计数与冷却时间,展示 / 拒绝计数跨天累加。 */
export function rollDay(state: GateState, day: number): GateState {
return { day, counts: {}, lastSec: {}, impressions: state.impressions, skips: state.skips };
}
/** 按时间顺序跑一串请求,返回终态(确定性:同输入必同输出)。 */
export function runRequests(state: GateState, reqs: readonly AdRequest[], cfg: AdGateCfg): GateState {
let s = state;
for (const r of reqs) s = tryShow(s, r.formatId, r.tSec, r.optedIn, cfg).state;
return s;
}
配套的 P 层测试覆盖五条边界——optIn 拒绝与放行、冷却边界(恰好等于冷却秒数放行)、每日上限到顶、跨天清空、以及请求流可复现(同输入两次结果逐位相同)。node --test 'core/test/l01_*.test.ts' 五条全部通过,为下一节的浏览器原型提供可信内核。
阅读导航:上一节:目录 · 下一节:1.2 eCPM、填充率与收益模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。