前面两节优化的都是老玩家的体验:找得到功能(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%),前半段的流失明显温和。
引导信息架构的四条原则
把上面的数字翻译成设计原则:
- 每步只做一件事。一步里塞多个操作,
passPct会乘起来下降。s07「编队出征」同时要求占地和招英雄,两个前置任一卡住都会拖累它。 - 前置要可见。玩家完成 s05 后若不知道「还差招募英雄」,就会在 s07 前卡住。把未满足的前置直接标在任务面板上,是低成本高收益的信息架构改进。
- 允许跳过。强制引导是流失放大器。把非关键步骤设为可跳过,让玩家自己决定节奏,比把他锁在引导里更安全。
- 用并行支线缩短关键路径。s05 与 s06 是两条可并行的支线,玩家可以任选先做。把长链拆成并行支线,能压缩引导的总时长——这和第 4 章的解锁链是同一套思路。
小结
新手引导的信息架构,本质是把一台状态机配置化,然后仿真它的流失漏斗。三个数字——最终转化率、绝对流失最大的步骤、流失率最高的步骤——分别回答「引导整体行不行」「哪里掉人最多」「哪里最难」。它们和第 6.2 的点击成本一样,都是把体感问题变成可回归的数字。
到这里,卷一第一部分「产品架构与系统设计」就完整了:从定位、玩家、循环、系统地图、功能架构,到交互信息架构,一款 SLG 的骨架已经搭好。下一章 7.1 版本列车与赛季结构 换一个维度——骨架有了,怎么持续往里交付内容。
阅读导航:上一节:操作路径与点击成本 · 下一节:版本列车与赛季结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。