1.1 广告形态与场景设计

《合成小岛》靠广告变现的第一步,是把「广告」拆成激励视频、插屏、横幅、原生四种可配置形态,再安放到离线收益翻倍、失败复活、合成双倍、日常任务、关卡结算、主界面、商城七个场景。本节给出一张 D 层形态与场景配置表、一段按「触发次数 × 填充率 × eCPM」估算各场景日均收益的 S 层脚本,以及一套按 optIn、每日上限、冷却三条规则判定放行的 P 层广告闸门内核。

卷三从这里开始。前两卷把《合成小岛》从设计案写成了能跑的代码,卷三要回答更现实的问题:它靠什么赚钱、怎么赚得久。第 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、填充率与收益模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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