6.3 新手引导的信息架构

把新手引导建模为「前置链 + 状态机」:每一步有前置步骤与基础通过率,前置为 AND 语义时瓶颈由最难过的一步决定。用脚本仿真分步流失漏斗,算出最终转化率并定位流失最大的步骤,输出 CSV 与漏斗图,含可跑的 Python 仿真与 TypeScript 状态机原型。只谈信息架构与步骤编排,不谈视觉表现。

前面两节优化的都是老玩家的体验:找得到功能(6.1 )、点得动操作(6.2 )。但新玩家的第一个小时完全是另一回事——他对界面一无所知,引导要一步一步把他从「不知道该点哪」带到「自己会玩」。这一节把新手引导建模成一台状态机,把每一步的流失当成成本,用脚本仿真出一条分步流失漏斗。

引导是状态机,不是一页说明书

新手引导最常见的写法是一长串任务清单:「建造伐木场 → 升级主城 → 打开世界地图 → ……」。清单能描述要做什么,但描述不了玩家卡在哪。等到数据回来发现首日流失高,你只知道人走了,不知道是哪一步走的。

换成状态机,一切可量化:每一步是一个状态,前置步骤是转移条件,基础通过率是这一步的存活概率。于是「引导做得好不好」变成了一个可以仿真出来的数字:10000 个进入引导的玩家,最后有多少人走完。

前置链与 AND 语义

状态机的关键在前置(prereq)。一个步骤的前置是一个列表,语义是 AND:列表里所有步骤都完成,玩家才能进入这一步。这条语义决定了「进入人数」怎么算:

  • 无前置(引导起点):进入人数 = 初始队列 cohort。
  • 有前置:进入人数 = min(各前置步骤的存活人数)。瓶颈由最难过的那一步决定——只要有一个前置卡住了一半人,后面的步骤最多也只剩一半。

然后这一步的存活人数是 进入 × passPct // 100(整数地板除)。用整数是为了让 Python 与 TypeScript 逐位一致:同一个 cohort、同一组 passPct,两边算出的漏斗一模一样。

前置链必须是一张有向无环图(DAG)。脚本会做拓扑排序,一旦成环(A 需要 B、B 又需要 A),直接报错——这种配置是写错了,跑都跑不起来。

引导配置

引导是一份配置,config/ch06_onboarding.json 是唯一真源。cohort 是初始队列,每个 step 有前置列表、基础通过率和目标时间点:

{
  "version": 1,
  "note": "新手引导步骤:prereq 为前置步骤列表(全部满足才进入该步,AND 语义)。passPct 为该步的基础通过率(整数百分比)。cohort 为进入引导的初始人数。minuteMark 为该步的目标时间点(分钟)。",
  "cohort": 10000,
  "steps": [
    { "id": "s01", "name": "进入主城", "prereq": [], "passPct": 100, "minuteMark": 0 },
    { "id": "s02", "name": "建造伐木场", "prereq": ["s01"], "passPct": 97, "minuteMark": 1 },
    { "id": "s03", "name": "升级主城", "prereq": ["s02"], "passPct": 94, "minuteMark": 2 },
    { "id": "s04", "name": "打开世界地图", "prereq": ["s03"], "passPct": 92, "minuteMark": 3 },
    { "id": "s05", "name": "占领相邻地块", "prereq": ["s04"], "passPct": 88, "minuteMark": 5 },
    { "id": "s06", "name": "招募第一名英雄", "prereq": ["s03"], "passPct": 90, "minuteMark": 4 },
    { "id": "s07", "name": "编队出征", "prereq": ["s05", "s06"], "passPct": 84, "minuteMark": 6 },
    { "id": "s08", "name": "训练部队", "prereq": ["s03"], "passPct": 89, "minuteMark": 4 },
    { "id": "s09", "name": "加入联盟", "prereq": ["s07"], "passPct": 76, "minuteMark": 8 },
    { "id": "s10", "name": "完成首日任务", "prereq": ["s09", "s08"], "passPct": 70, "minuteMark": 10 }
  ]
}

这是完整配置,10 步。注意 s07 的前置是 ["s05", "s06"]——要出征,必须先占过地(s05)并且招到英雄(s06),两条支线缺一不可。passPct 是「基础通过率」,它来自关卡设计的目标,不是拍脑袋填的。

仿真流失漏斗

下面这段是 S 层仿真脚本,读 config/ch06_onboarding.json,按 AND 前置语义逐层推进,算出每步的进入人数、存活人数、流失人数与流失率,并画出漏斗 SVG。

# file: sim/ch06_onboarding.py
# tier: S
# verify: python3 sim/ch06_onboarding.py --config config/ --out out/
"""第 6 章新手引导:把引导建模为「前置链 + 状态机」,仿真分步流失漏斗。

语义:某步的「进入人数」= min(各前置步骤的存活人数)(AND 前置,瓶颈由最难过的一步决定);
      该步存活 = 进入人数 * passPct // 100(整数地板除,跨语言逐位一致)。
产出:
  out/ch06_onboarding.json  每步进入/存活/流失、最差步骤、总转化
  out/ch06_onboarding.csv   分步明细
  out/ch06_onboarding.svg   漏斗图
"""

from __future__ import annotations

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

SVG_NS = "http://www.w3.org/2000/svg"


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


def check_dag(steps: list[dict]) -> list[str]:
    """校验前置链:前置必须存在,且不构成环(拓扑可达性)。"""
    ids = {s["id"] for s in steps}
    errs = []
    for s in steps:
        for p in s["prereq"]:
            if p not in ids:
                errs.append(f"{s['id']} 的前置 {p} 不存在")
    # 拓扑排序检测环
    indeg = {s["id"]: 0 for s in steps}
    adj: dict[str, list[str]] = {s["id"]: [] for s in steps}
    for s in steps:
        for p in s["prereq"]:
            if p in ids:
                adj[p].append(s["id"])
                indeg[s["id"]] += 1
    q = [i for i, d in indeg.items() if d == 0]
    seen = 0
    while q:
        cur = q.pop()
        seen += 1
        for nxt in adj[cur]:
            indeg[nxt] -= 1
            if indeg[nxt] == 0:
                q.append(nxt)
    if seen != len(steps):
        errs.append("前置链存在环,无法拓扑排序")
    return errs


def simulate(cfg: dict) -> dict:
    steps = cfg["steps"]
    cohort = cfg["cohort"]
    order = [s["id"] for s in steps]
    by_id = {s["id"]: s for s in steps}
    survive: dict[str, int] = {}
    enter: dict[str, int] = {}
    rows = []
    for sid in order:
        s = by_id[sid]
        if not s["prereq"]:
            e = cohort
        else:
            e = min(survive[p] for p in s["prereq"])
        surv = e * s["passPct"] // 100
        enter[sid] = e
        survive[sid] = surv
        rows.append({
            "id": sid, "name": s["name"], "minuteMark": s["minuteMark"],
            "prereq": s["prereq"], "passPct": s["passPct"],
            "enter": e, "survive": surv, "drop": e - surv,
            "dropPct": round((e - surv) * 100 / e, 2) if e else 0.0,
        })
    # 最差步骤:按绝对流失人数
    worst_abs = max(rows, key=lambda r: r["drop"])
    worst_rate = max(rows, key=lambda r: r["dropPct"])
    return {
        "version": 1,
        "cohort": cohort,
        "steps": rows,
        "finalSurvive": rows[-1]["survive"] if rows else 0,
        "finalRatePct": round(rows[-1]["survive"] * 100 / cohort, 2) if rows else 0.0,
        "worstByDrop": worst_abs["id"],
        "worstByRate": worst_rate["id"],
        "errors": check_dag(steps),
    }


def render_svg(rep: dict, out_path: str) -> None:
    rows = rep["steps"]
    n = len(rows)
    W, H = 720, 40 + n * 46
    maxw = 560
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
    t = ET.SubElement(svg, "text", {"x": "120", "y": "20", "font-size": "13", "fill": "#111"})
    t.text = f"新手引导流失漏斗(cohort={rep['cohort']})"
    base = rows[0]["enter"] if rows else 1
    for i, r in enumerate(rows):
        y = 32 + i * 46
        w = int(maxw * r["survive"] / base) if base else 0
        ET.SubElement(svg, "rect", {"x": "120", "y": str(y), "width": str(max(w, 2)), "height": "28",
                                    "rx": "3", "fill": "#1f6feb", "opacity": "0.85"})
        lab = ET.SubElement(svg, "text", {"x": "114", "y": str(y + 18), "font-size": "11",
                                          "text-anchor": "end", "fill": "#333"})
        lab.text = f"{r['id']} {r['name']}"
        val = ET.SubElement(svg, "text", {"x": str(126 + max(w, 2)), "y": str(y + 18), "font-size": "11",
                                          "fill": "#111"})
        val.text = f"{r['survive']}  (-{r['drop']}, {r['dropPct']}%)"
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    ET.ElementTree(svg).write(out_path, encoding="utf-8", xml_declaration=True)


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, "ch06_onboarding")
    rep = simulate(cfg)
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch06_onboarding.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch06_onboarding.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "minuteMark", "passPct", "enter", "survive", "drop", "dropPct"])
        for r in rep["steps"]:
            w.writerow([r["id"], r["name"], r["minuteMark"], r["passPct"], r["enter"], r["survive"], r["drop"], r["dropPct"]])
    render_svg(rep, os.path.join(args.out, "ch06_onboarding.svg"))
    print(json.dumps({"finalSurvive": rep["finalSurvive"], "finalRatePct": rep["finalRatePct"],
                      "worstByDrop": rep["worstByDrop"], "worstByRate": rep["worstByRate"],
                      "errors": rep["errors"]}, ensure_ascii=False))
    return 0


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

在本机跑 python3 sim/ch06_onboarding.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):

# snippet: 实跑输出
{"finalSurvive": 3298, "finalRatePct": 32.98, "worstByDrop": "s09", "worstByRate": "s10", "errors": []}

CSV 明细把每一步摊开:

# snippet: out/ch06_onboarding.csv
id,name,minuteMark,passPct,enter,survive,drop,dropPct
s01,进入主城,0,100,10000,10000,0,0.0
s02,建造伐木场,1,97,10000,9700,300,3.0
s03,升级主城,2,94,9700,9118,582,6.0
s04,打开世界地图,3,92,9118,8388,730,8.01
s05,占领相邻地块,5,88,8388,7381,1007,12.01
s06,招募第一名英雄,4,90,9118,8206,912,10.0
s07,编队出征,6,84,7381,6200,1181,16.0
s08,训练部队,4,89,9118,8115,1003,11.0
s09,加入联盟,8,76,6200,4712,1488,24.0
s10,完成首日任务,10,70,4712,3298,1414,30.01

TypeScript 侧:同一台状态机

同一份配置也被原型层消费。proto/src/ch06_onboarding.ts 用同一套 AND 语义与整数地板除实现状态机,与 Python 侧逐位一致:

// file: proto/src/ch06_onboarding.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 6 章原型层:新手引导状态机仿真。与 sim/ch06_onboarding.py 读同一份 config,结果逐位一致。

import { loadConfig } from "./core.ts";

export interface OnboardStep { id: string; name: string; prereq: string[]; passPct: number; minuteMark: number }
export interface OnboardConfig { cohort: number; steps: OnboardStep[] }

export interface StepRow {
  id: string; name: string; minuteMark: number; prereq: string[]; passPct: number;
  enter: number; survive: number; drop: number; dropPct: number;
}

export function checkDag(steps: OnboardStep[]): string[] {
  const ids = new Set(steps.map((s) => s.id));
  const errs: string[] = [];
  for (const s of steps) for (const p of s.prereq) if (!ids.has(p)) errs.push(`${s.id} 的前置 ${p} 不存在`);
  const indeg = new Map<string, number>();
  const adj = new Map<string, string[]>();
  for (const s of steps) { indeg.set(s.id, 0); adj.set(s.id, []); }
  for (const s of steps) for (const p of s.prereq) if (ids.has(p)) { adj.get(p)!.push(s.id); indeg.set(s.id, indeg.get(s.id)! + 1); }
  const q = [...indeg.entries()].filter(([, d]) => d === 0).map(([i]) => i);
  let seen = 0;
  while (q.length) {
    const cur = q.pop()!;
    seen++;
    for (const nxt of adj.get(cur)!) { indeg.set(nxt, indeg.get(nxt)! - 1); if (indeg.get(nxt) === 0) q.push(nxt); }
  }
  if (seen !== steps.length) errs.push("前置链存在环,无法拓扑排序");
  return errs;
}

export function simulateOnboarding(cfg: OnboardConfig): {
  finalSurvive: number; finalRatePct: number; worstByDrop: string; worstByRate: string; errors: string[]; steps: StepRow[];
} {
  const cohort = cfg.cohort;
  const survive = new Map<string, number>();
  const rows: StepRow[] = [];
  for (const s of cfg.steps) {
    const enter = s.prereq.length === 0 ? cohort : Math.min(...s.prereq.map((p) => survive.get(p)!));
    const surv = Math.floor((enter * s.passPct) / 100);
    survive.set(s.id, surv);
    rows.push({
      id: s.id, name: s.name, minuteMark: s.minuteMark, prereq: s.prereq, passPct: s.passPct,
      enter, survive: surv, drop: enter - surv,
      dropPct: enter ? Math.round(((enter - surv) * 100 / enter) * 100) / 100 : 0,
    });
  }
  const worstAbs = rows.reduce((a, b) => (b.drop > a.drop ? b : a), rows[0]);
  const worstRate = rows.reduce((a, b) => (b.dropPct > a.dropPct ? b : a), rows[0]);
  return {
    finalSurvive: rows.length ? rows[rows.length - 1].survive : 0,
    finalRatePct: rows.length ? Math.round((rows[rows.length - 1].survive * 100 / cohort) * 100) / 100 : 0,
    worstByDrop: worstAbs.id,
    worstByRate: worstRate.id,
    errors: checkDag(cfg.steps),
    steps: rows,
  };
}

if (process.argv[1] && process.argv[1].endsWith("ch06_onboarding.ts")) {
  console.log(JSON.stringify(simulateOnboarding(loadConfig<OnboardConfig>("ch06_onboarding")), null, 2));
}

proto/test/ch06.test.ts 里写死了 Python 侧算出的期望值——每步存活人数 {s01: 10000, …, s10: 3298}、worstByDrop 是 s09、worstByRate 是 s10。两边只要有一个数字对不上,node --test 就红。测试里还有一个反例:把两个步骤的前置互相指向对方,checkDag 必须报出「成环」。

结果解读:流失最猛的地方在尾部

漏斗的结果很值得琢磨。最终转化 finalSurvive = 3298:10000 个进入引导的玩家,走完首日任务的只有 3298 人,finalRatePct = 32.98%。按绝对流失,worstByDrop = s09(加入联盟)从 6200 掉到 4712,一步走了 1488 人;按流失率,worstByRate = s10(完成首日任务)流失率 30.01%,passPct 只有 70,是全部步骤里最低的。两个「最差」指向同一个结论:引导尾部最危险。s09、s10 都在引导的后半段,此时玩家新鲜感已经耗尽,任何一步复杂一点,人就走了。对照 s07(编队出征,16%)和 s04(打开世界地图,8.01%),前半段的流失明显温和。

引导信息架构的四条原则

把上面的数字翻译成设计原则:

  1. 每步只做一件事。一步里塞多个操作,passPct 会乘起来下降。s07「编队出征」同时要求占地和招英雄,两个前置任一卡住都会拖累它。
  2. 前置要可见。玩家完成 s05 后若不知道「还差招募英雄」,就会在 s07 前卡住。把未满足的前置直接标在任务面板上,是低成本高收益的信息架构改进。
  3. 允许跳过。强制引导是流失放大器。把非关键步骤设为可跳过,让玩家自己决定节奏,比把他锁在引导里更安全。
  4. 用并行支线缩短关键路径。s05 与 s06 是两条可并行的支线,玩家可以任选先做。把长链拆成并行支线,能压缩引导的总时长——这和第 4 章的解锁链是同一套思路。

小结

新手引导的信息架构,本质是把一台状态机配置化,然后仿真它的流失漏斗。三个数字——最终转化率、绝对流失最大的步骤、流失率最高的步骤——分别回答「引导整体行不行」「哪里掉人最多」「哪里最难」。它们和第 6.2 的点击成本一样,都是把体感问题变成可回归的数字。

到这里,卷一第一部分「产品架构与系统设计」就完整了:从定位、玩家、循环、系统地图、功能架构,到交互信息架构,一款 SLG 的骨架已经搭好。下一章 7.1 版本列车与赛季结构 换一个维度——骨架有了,怎么持续往里交付内容。

阅读导航:上一节:操作路径与点击成本 · 下一节:版本列车与赛季结构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引