13.2 引导漏斗埋点与仿真

引导漏斗回答的是另一类问题:一万个玩家走到第几步会掉。用可播种 LCG 逐玩家逐步骤推进,每步含跳过、失败重试、直接流失三条分支,再跑两遍做对照——允许可跳过步骤跳过与强制逐步完成的最终完成率差多少,产出 JSON、CSV 与纯 Python SVG。

同样地,站内那篇 小游戏开发综述 是平台能力与留存综述,本节给的是可跑的引导漏斗模型:那篇讲「留存曲线长什么样」,本节把新手引导这一段拆成逐步到达率,用同一份 config/e13_tutorial.json 跑出「每一步还剩多少人」。上一节的状态机管单个人的合法性,本节管一群人的分布。

引导漏斗和常见的「新增 → 次日留存」漏斗不是一回事。留存漏斗的每一层是时间(第 0 天、第 1 天、第 7 天),而引导漏斗的每一层是步骤(点棋盘、合成、开商店、购买、摆放)。步骤级漏斗的好处是可归因:如果完成率整体偏低,你能精确知道是第 3 步掉的,而不是笼统地说「新手留存差」。而且引导漏斗里有两类留存漏斗没有的分支——跳过(玩家主动绕过某步)和卡住重试(失败后重来,可能成功也可能放弃)。

漏斗的三条分支

每一步玩家有四种去向:完成(正常过关)、跳过(该步可跳过且玩家选择绕过)、重试后完成(先失败,重试若干次后成功)、流失(失败次数用尽或直接退出)。把「卡住重试」单独建模很关键:引导里很多流失不是「不会做」,而是「试了两次没成功就走了」——只看完成/未完成会把这类人误判成「不会做」,从而做出错误的引导设计。

S 层:引导漏斗仿真

下面这段是 S 层仿真脚本 sim/e13_funnel_sim.py:它读同一份 config/e13_tutorial.json,用可播种 LCG 逐玩家逐步骤推进,每一步依次掷「跳过 / 失败重试 / 直接流失」三条分支。脚本跑两遍做对照——allow_skip=True(可跳过步骤能跳)与 allow_skip=False(所有步骤强制完成),两次用同一 seed,差异只来自「能不能跳过」这一个变量。产出 JSON + CSV + 纯 Python SVG。注意它没有 import random:随机一律来自配置里的 seed 加整数 LCG,同一 seed 每次得到同一批玩家。

#!/usr/bin/env python3
# file: sim/e13_funnel_sim.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e13_funnel_sim.py --config config/ --out out/
"""S 层仿真:新手引导漏斗。

同一份 config/e13_tutorial.json 被 TS 内核(core/src/e13/tutorial.ts)与本仿真消费:
内核回答「这一步合法吗」,本仿真回答「一万个玩家走到哪一步会掉」。
用可播种 LCG 逐玩家逐步骤推进,每个步骤含三条分支——跳过 / 失败后重试 / 直接流失——
再跑两遍做对照:允许「可跳过步骤跳过」与「强制逐步完成」的最终完成率差异。
产出 JSON + CSV + 纯 Python SVG。全整数、无浮点;随机一律来自 seed + 整数 LCG。
"""

from __future__ import annotations

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

MULT, INC, MASK = 1103515245, 12345, 0x7FFFFFFF


def lcg(state: int) -> int:
    return (state * MULT + INC) & MASK


def load(config_dir: str) -> dict:
    with open(os.path.join(config_dir, "e13_tutorial.json"), encoding="utf-8") as f:
        return json.load(f)


def run_variant(cfg: dict, allow_skip: bool) -> dict:
    """跑一遍漏斗:allow_skip=False 时忽略 skipPermille,所有步骤强制完成。"""
    fun = cfg["funnel"]
    steps = cfg["steps"]
    players = fun["players"]
    retry_max = fun["retryMax"]
    retry_ok = fun["retrySuccessPermille"]
    per = fun["steps"]

    n = len(steps)
    reached = [0] * (n + 1)          # 到达第 i 步的人数;下标 n = 走完
    completed = [0] * n
    skipped = [0] * n
    retried = [0] * n
    dropped = [0] * n

    state = cfg["seed"]
    for _ in range(players):
        i = 0
        while i < n:
            reached[i] += 1
            step = steps[i]
            p = per[step["id"]]
            state = lcg(state)
            # 分支一:可跳过步骤被玩家主动跳过(对照变体忽略此分支)
            if allow_skip and step["skippable"] and state % 1000 < p["skipPermille"]:
                skipped[i] += 1
                i += 1
                continue
            state = lcg(state)
            # 分支二:失败后重试,重试若干次仍失败则放弃
            if state % 1000 < p["failPermille"]:
                ok = False
                for _r in range(retry_max):
                    state = lcg(state)
                    if state % 1000 < retry_ok:
                        ok = True
                        break
                if ok:
                    retried[i] += 1
                    completed[i] += 1
                    i += 1
                    continue
                dropped[i] += 1
                break
            state = lcg(state)
            # 分支三:非失败情形下的直接流失
            if state % 1000 < p["abandonPermille"]:
                dropped[i] += 1
                break
            completed[i] += 1
            i += 1
        if i >= n:
            reached[n] += 1

    return {
        "allowSkip": allow_skip,
        "reached": reached,
        "completed": completed,
        "skipped": skipped,
        "retried": retried,
        "dropped": dropped,
        "finished": reached[n],
        "finishPermille": reached[n] * 1000 // players,
    }


def write_svg(path: str, a: dict, b: dict) -> int:
    W, H, PAD = 520, 300, 44
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": str(PAD), "y": "24", "font-size": "15", "fill": "#0f172a"},
                  ).text = "引导漏斗完成率对照(千分比)"
    bars = [("可跳过", a["finishPermille"], "#38bdf8"), ("强制完成", b["finishPermille"], "#f59e0b")]
    top = 1000
    plot_h = H - 2 * PAD
    bw = (W - 2 * PAD) // len(bars)
    for i, (label, v, color) in enumerate(bars):
        bh = v * plot_h // top
        x = PAD + i * bw + bw // 4
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - PAD - bh), "width": str(bw // 2),
                                    "height": str(bh), "fill": color})
        ET.SubElement(svg, "text", {"x": str(x + bw // 4), "y": str(H - PAD - bh - 6),
                                    "font-size": "13", "text-anchor": "middle", "fill": "#0f172a"}, ).text = str(v)
        ET.SubElement(svg, "text", {"x": str(x + bw // 4), "y": str(H - PAD + 18),
                                    "font-size": "13", "text-anchor": "middle", "fill": "#334155"}, ).text = label
    ET.ElementTree(svg).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/")
    args = ap.parse_args()

    cfg = load(args.config)
    os.makedirs(args.out, exist_ok=True)
    steps = cfg["steps"]
    ids = [s["id"] for s in steps]

    with_skip = run_variant(cfg, True)
    force_all = run_variant(cfg, False)

    report = {
        "version": 1, "source": "sim/e13_funnel_sim.py", "tier": "S",
        "players": cfg["funnel"]["players"], "seed": cfg["seed"], "steps": ids,
        "withSkip": with_skip, "forceAll": force_all,
        "finishDeltaPermille": with_skip["finishPermille"] - force_all["finishPermille"],
    }
    with open(os.path.join(args.out, "e13_funnel_sim.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    with open(os.path.join(args.out, "e13_funnel_sim.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["variant", "step", "reached", "completed", "skipped", "retried", "dropped"])
        for variant, r in (("withSkip", with_skip), ("forceAll", force_all)):
            for i, sid in enumerate(ids):
                w.writerow([variant, sid, r["reached"][i], r["completed"][i],
                            r["skipped"][i], r["retried"][i], r["dropped"][i]])

    svg_bytes = write_svg(os.path.join(args.out, "e13_funnel_sim.svg"), with_skip, force_all)
    report["svgBytes"] = svg_bytes

    print(json.dumps({
        "players": cfg["funnel"]["players"],
        "withSkipFinishPermille": with_skip["finishPermille"],
        "forceAllFinishPermille": force_all["finishPermille"],
        "deltaPermille": report["finishDeltaPermille"],
        "withSkip": {"reached": with_skip["reached"], "skipped": with_skip["skipped"],
                     "retried": with_skip["retried"], "dropped": with_skip["dropped"]},
        "svgBytes": svg_bytes,
    }, ensure_ascii=False, indent=2))
    print(f"== 引导漏斗:可跳过完成率 {with_skip['finishPermille']}‰ / 强制完成 {force_all['finishPermille']}‰"
          f"(差 {report['finishDeltaPermille']}‰,同机相对量级,绝对数值不可移植)")
    return 0


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

本机实跑输出如下(同机相对量级,绝对数值不可移植):

$ /opt/homebrew/bin/python3 sim/e13_funnel_sim.py --config config/ --out out/
{
  "players": 20000,
  "withSkipFinishPermille": 813,
  "forceAllFinishPermille": 787,
  "deltaPermille": 26,
  "withSkip": {
    "reached": [20000, 19306, 18494, 17811, 16952, 16277],
    "skipped": [0, 3546, 2754, 3885, 0],
    "retried": [1260, 1845, 1457, 1865, 1221],
    "dropped": [694, 812, 683, 859, 675]
  },
  "svgBytes": 727
}
== 引导漏斗:可跳过完成率 813‰ / 强制完成 787‰(差 26‰,同机相对量级,绝对数值不可移植)

读这张漏斗

两万玩家进引导,逐步到达人数是 20000 → 19306 → 18494 → 17811 → 16952 → 16277:第一步就掉了 694 人(tap_board 不可跳过,这些人连棋盘都没点就走了),这是引导最贵的一步。看跳过列:merge_two 被跳 3546 人、open_shop 2754、buy_item 3885——三个可跳过步骤各有约 15%–22% 的玩家选择绕过,说明这些装饰性操作对相当一部分人来说是「多余的」。看重试列:每步都有 1200–1900 人是「失败后重试成功」的,如果不单独统计,这批人会被误算成流失。

最有价值的是两遍的对照:可跳过时最终完成率 813‰,强制完成时只有 787‰,差 26‰。这 26‰ 就是「允许跳过」这件事本身的价值——那些本来会卡在 merge_two 或 buy_item 的人,因为能跳过而继续走到了引导结束。这个差值不大(不到 3 个百分点),但它回答了一个具体决策:「给不给玩家跳过权」不是拍脑袋,而是可以量化的一格收益。反过来,如果某个可跳过步骤的 skipped 高得离谱(比如 50%),那说明这一步的设计本身有问题,跳过只是掩盖了它。

out/e13_funnel_sim.svg 是完成率对照柱状图(可跳过 vs 强制完成),可以直接贴进评审材料。

埋点口径

要让这套仿真对得上真实埋点,事件口径必须和状态机的 trigger 一一对应:first_tap、merge_done、shop_open、purchase_done、place_done 五个事件名既是状态机的完成条件,也是埋点上报的 event 字段。这样线上跑出来的每一步到达率,可以直接和仿真里的 reached 数组对照——仿真参数(failPermille / skipPermille / abandonPermille)就是用线上数据反标定出来的。真实平台的埋点后台数据需要真实业务环境,命令行不可复现(标 X);本节给的是可复现的模型侧:改一个千分比参数,立刻能看到完成率怎么动。

小结与下一步

这一节把引导从「一个人的合法性」扩展成「一群人的分布」,并用两遍对照量化了「可跳过」的价值。但漏斗只是测量:它告诉你玩家在第 3 步掉得多,却没告诉你怎么救。下一节给出救法——渐进式教学:失败越多、提示越强,卡住自动给提示,再配一个撤销栈容忍误操作。

阅读导航:上一节:引导系统与状态机 · 下一节:渐进式教学与容错 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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