13.1 引导系统与状态机

引导状态机把「教玩家认识玩法」变成一串可断言的步骤:每步只认自己的完成事件,未开始、已结束、事件不匹配、不可跳过的步被跳过、第一步没有可回退的步这五种非法迁移一律拒绝且不改状态;再配跳过、回退与幂等重入,用 P 层内核与单测把整条新手路径钉死。

站内那篇 小游戏开发综述 是平台能力与留存综述(引擎选型、性能、变现,96 行单篇),本章给的是可跑、可断言的引导状态机与漏斗模型:那篇告诉你「要重视新手引导」,本章把引导本身写成能单测、能仿真、能在浏览器里点开看的东西。本节先解决最底层的一层——把引导写成一台状态机。这里不重复产品层面的新手体验设计原则(那是一卷《市场品类与产品设计》的题目),只讲工程实现。

新手引导在代码里最容易被写成一堆 if:到某一步弹个箭头、玩家点了就 next()、点了「跳过」就整段关掉。这种写法的问题是没人能回答「这一步现在到底合不合法」——玩家在网络卡顿下重复点两次、脚本玩家乱序触发事件、老玩家带着旧存档回来,if 堆就会把游标推到一个说不清的位置。把它写成状态机,这些情况就都变成可断言的拒绝:拒绝本身就是一种正确行为,而不是「没崩就算过」。

把引导写成状态机

一台引导状态机只有三样东西:步骤序列(有序的 steps)、当前游标(cursor 指向正在进行的步骤)、每步状态(pending / active / done / skipped)。每一步声明一个完成事件——只有这个事件能把它推过去;事件不匹配就拒绝。这样「玩家触发了某个事件」和「这一步该不该结束」就解耦了:事件由游戏逻辑抛出,状态机只负责判定。

四条工程纪律贯穿全节:幂等重入(同一个事件重复来、start 重复调,都不改状态)、前置校验(没开始就不能触发、走完了就不能再触发)、可跳过但受限(只有 skippable 的步骤才允许跳)、可回退(误点回退一格,其后步骤重置为 pending)。全部用整数,随机数为零——状态机是纯函数,喂同一串请求必得同一个状态,这也是它能被单测钉死的前提。

D 层:引导步骤表

下面这段是 D 层(静态层)配置 config/e13_tutorial.json:五步引导各自声明完成事件、是否可跳过、停留多久给提示;funnel 段是给 13.2 漏斗仿真用的参数(千分比 / 整数),本节的内核只读 steps。它是纯数据——策划改步骤顺序、把某步改成可跳过,都不必动内核。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "新手引导步骤表:每步声明完成事件、是否可跳过、停留多久给提示;funnel 段是引导漏斗仿真参数(千分比 / 整数),供 sim/e13_funnel_sim.py 消费。全整数、无浮点。",
  "steps": [
    { "id": "tap_board", "trigger": "first_tap", "skippable": false, "hintMs": 4000 },
    { "id": "merge_two", "trigger": "merge_done", "skippable": true, "hintMs": 6000 },
    { "id": "open_shop", "trigger": "shop_open", "skippable": true, "hintMs": 5000 },
    { "id": "buy_item", "trigger": "purchase_done", "skippable": true, "hintMs": 6000 },
    { "id": "place_item", "trigger": "place_done", "skippable": false, "hintMs": 5000 }
  ],
  "funnel": {
    "players": 20000,
    "retryMax": 3,
    "retrySuccessPermille": 550,
    "steps": {
      "tap_board": { "skipPermille": 0, "failPermille": 70, "abandonPermille": 30 },
      "merge_two": { "skipPermille": 180, "failPermille": 130, "abandonPermille": 45 },
      "open_shop": { "skipPermille": 150, "failPermille": 95, "abandonPermille": 40 },
      "buy_item": { "skipPermille": 220, "failPermille": 150, "abandonPermille": 55 },
      "place_item": { "skipPermille": 0, "failPermille": 80, "abandonPermille": 35 }
    }
  }
}

注意 tap_board 与 place_item 的 skippable 是 false:第一步教「点一下棋盘」和最后一步「把物品放下」是玩法地基,跳过它们玩家会不知道棋盘能点、物品要放哪;中间三步是可选的装饰性操作,允许跳过。

P 层:引导状态机内核

下面这段是 P 层(内核层)产物 core/src/e13/tutorial.ts。它的核心是一个 trigger(state, cfg, event):先取当前步骤,若事件不等于当前步骤的 trigger 就返回 wrong-event 且原样返回旧状态(不改游标、不改状态数组),命中才把当前步置 done 并把游标前移一格、激活下一步。skip 与 back 同理,各带自己的前置校验。所有拒绝路径都返回 { ok: false, reason },调用方据此决定要不要给玩家反馈。全整数、无 Math.random()。

// file: core/src/e13/tutorial.ts
// tier: P
// verify: node --test 'core/test/e13_*.test.ts'
//
// 《合成小岛》新手引导状态机:步骤序列 + 触发条件 + 前置校验 + 跳过 + 回退 + 幂等重入。
// 一条引导 = 一串有序步骤;每步只认自己声明的「完成事件」。状态机只做一件事:
// 在正确的前置下把游标往前推一格,其余一切非法请求(未开始、已结束、事件不匹配、
// 不可跳过的步被跳过、第一步没有可回退的步)一律拒绝且不改动任何状态。
// 全整数、无 Math.random()。

export interface TutorialStepDef {
  id: string;
  trigger: string;      // 完成本步的事件名
  skippable: boolean;   // 是否允许跳过
  hintMs: number;       // 停留多久后给提示(整数毫秒,供 13.3 消费)
}
export interface TutorialConfig {
  version: number;
  seed: number;
  steps: TutorialStepDef[];
}
export type StepStatus = "pending" | "active" | "done" | "skipped";
export interface TutorialState {
  started: boolean;
  cursor: number;            // 当前步骤下标;== steps.length 表示走完
  statuses: StepStatus[];
  log: string[];             // 事件流,稳定可比
}
export interface StepResult {
  ok: boolean;
  step: string;              // 相关步骤 id(无则空串)
  status: StepStatus;        // 相关步骤操作后的状态
  cursor: number;
  reason: string;
}

export function newTutorial(cfg: TutorialConfig): TutorialState {
  return { started: false, cursor: 0, statuses: cfg.steps.map(() => "pending"), log: [] };
}

/** 当前活动步骤(未开始或已走完返回 null)。 */
export function currentStep(state: TutorialState, cfg: TutorialConfig): TutorialStepDef | null {
  if (!state.started || state.cursor >= cfg.steps.length) return null;
  return cfg.steps[state.cursor];
}

export function isFinished(state: TutorialState, cfg: TutorialConfig): boolean {
  return state.cursor >= cfg.steps.length;
}

/** 已完成(done 或 skipped)的步骤数,用于进度条。 */
export function progress(state: TutorialState): number {
  return state.statuses.filter((s) => s === "done" || s === "skipped").length;
}

function clone(state: TutorialState): TutorialState {
  return { started: state.started, cursor: state.cursor, statuses: state.statuses.slice(), log: state.log.slice() };
}

function reject(state: TutorialState, cfg: TutorialConfig, reason: string): StepResult {
  const cur = currentStep(state, cfg);
  return { ok: false, step: cur ? cur.id : "", status: cur ? state.statuses[state.cursor] : "pending", cursor: state.cursor, reason };
}

/** 开始引导:把第一步置为 active。重复调用被拒绝且不改状态(幂等重入)。 */
export function start(state: TutorialState, cfg: TutorialConfig): { state: TutorialState; result: StepResult } {
  if (state.started) {
    return { state, result: reject(state, cfg, "already-started") };
  }
  if (cfg.steps.length === 0) {
    return { state, result: { ok: false, step: "", status: "pending", cursor: 0, reason: "empty-tutorial" } };
  }
  const next = clone(state);
  next.started = true;
  next.cursor = 0;
  next.statuses[0] = "active";
  next.log.push("start:" + cfg.steps[0].id);
  return { state: next, result: { ok: true, step: cfg.steps[0].id, status: "active", cursor: 0, reason: "start" } };
}

function advanceFrom(next: TutorialState, cfg: TutorialConfig, completedAs: StepStatus): void {
  const i = next.cursor;
  next.statuses[i] = completedAs;
  next.cursor = i + 1;
  if (next.cursor < cfg.steps.length) next.statuses[next.cursor] = "active";
}

/** 触发一个事件:仅当事件等于当前步骤的 trigger 时才推进(幂等重入:旧事件不重复推进)。 */
export function trigger(state: TutorialState, cfg: TutorialConfig, event: string): { state: TutorialState; result: StepResult } {
  const cur = currentStep(state, cfg);
  if (!cur) return { state, result: reject(state, cfg, isFinished(state, cfg) ? "finished" : "not-started") };
  if (event !== cur.trigger) return { state, result: reject(state, cfg, "wrong-event:" + event) };
  const next = clone(state);
  advanceFrom(next, cfg, "done");
  next.log.push("done:" + cur.id);
  return { state: next, result: { ok: true, step: cur.id, status: "done", cursor: next.cursor, reason: "done" } };
}

/** 跳过当前步骤:不可跳过的步骤被拒绝。 */
export function skip(state: TutorialState, cfg: TutorialConfig): { state: TutorialState; result: StepResult } {
  const cur = currentStep(state, cfg);
  if (!cur) return { state, result: reject(state, cfg, isFinished(state, cfg) ? "finished" : "not-started") };
  if (!cur.skippable) return { state, result: reject(state, cfg, "not-skippable:" + cur.id) };
  const next = clone(state);
  advanceFrom(next, cfg, "skipped");
  next.log.push("skip:" + cur.id);
  return { state: next, result: { ok: true, step: cur.id, status: "skipped", cursor: next.cursor, reason: "skip" } };
}

/** 回退一步:游标退一格并把该步重置为 active,其后各步重置为 pending。第一步无回退。 */
export function back(state: TutorialState, cfg: TutorialConfig): { state: TutorialState; result: StepResult } {
  if (!state.started) return { state, result: reject(state, cfg, "not-started") };
  if (state.cursor === 0) return { state, result: reject(state, cfg, "no-previous") };
  const next = clone(state);
  next.cursor = state.cursor - 1;
  for (let i = next.cursor; i < cfg.steps.length; i += 1) next.statuses[i] = "pending";
  next.statuses[next.cursor] = "active";
  next.log.push("back:" + cfg.steps[next.cursor].id);
  return { state: next, result: { ok: true, step: cfg.steps[next.cursor].id, status: "active", cursor: next.cursor, reason: "back" } };
}

/** 重置引导(重开会话 / 换账号)。 */
export function reset(cfg: TutorialConfig): TutorialState {
  return newTutorial(cfg);
}

/** 稳定快照:cursor=3 | tap_board=done,merge_two=done,open_shop=active,... */
export function dump(state: TutorialState, cfg: TutorialConfig): string {
  const parts = cfg.steps.map((s, i) => s.id + "=" + state.statuses[i]);
  return "started=" + state.started + " cursor=" + state.cursor + " | " + parts.join(",");
}

下面是 P 层测试 core/test/e13_tutorial.test.ts 的实跑输出(同机相对量级,耗时不可移植):

✔ 未开始时不能触发:任何事件都被拒绝且不改状态
✔ 开始引导:第一步置为 active,重复开始被拒绝(幂等重入)
✔ 事件不匹配:触发非当前步的事件被拒绝且不改状态
✔ 正常推进:按 trigger 逐步完成,游标依次前移
✔ 幂等重入:旧事件在后续步骤不重复推进
✔ 走完后拒绝一切触发
✔ 跳过:仅可跳过的步骤放行,不可跳过的被拒绝
✔ 回退:游标退一格,其后步骤重置为 pending
✔ 回退边界:第一步无回退,未开始无回退
✔ 重置:回到全新状态
✔ 同一操作序列两遍结果一致(无隐藏随机)
ℹ tests 11
ℹ pass 11
ℹ fail 0

五种非法迁移

把上面这台状态机的拒绝路径摊开,正好是五种最容易在真实项目里出 bug 的场景:

请求前置条件拒绝原因
触发事件未 startnot-started
触发事件事件 ≠ 当前步 triggerwrong-event:<事件>
触发事件已走完全部步骤finished
跳过当前步 skippable=falsenot-skippable:<步>
回退已在第一步 / 未开始no-previous / not-started

not-skippable 与 wrong-event 这两条最值得说:前者保证「地基步骤」不被玩家一键跳过,后者保证乱序事件不会偷偷推进游标——脚本玩家先 merge_done 再 first_tap 时,第一次触发就被 wrong-event 拦下,不会把 tap_board 记成完成。这就是「幂等重入」的落点:旧事件在后续步骤不重复推进,重复 start 也不改状态,因此状态机对重复输入天然免疫。

跳过与回退:两个容易写反的语义

「跳过」和「回退」看起来对称,语义却完全不同。跳过是向前的一步:把当前步标记为 skipped 然后游标前移,它承认「这一步玩家不打算做」,但整条引导照常往下走。回退是向后的一步:游标退一格,并且把从该格起的所有步骤重置为 pending——因为一旦回到第 2 步,第 3、4 步之前记的完成就都不算数了,否则玩家会看到「游标在第 2 步、第 3 步却已经是 done」的自相矛盾状态。测试里专门钉了这条:回退后 statuses[2] 与 statuses[3] 必须都回到 pending,而更早的 statuses[0] 保持 done 不受影响。

小结与下一步

这一节把引导的合法性钉死了:任何一步的迁移都只有一条合法路径,其余一律拒绝,而且拒绝不改状态、可安全重入。但状态机只回答「这一步合不合法」,回答不了另一个更要命的问题——一万个玩家走到第几步会掉、在哪里点跳过、有多少人卡住后重试成功。那是下一节的引导漏斗仿真要算的账;本节的状态机正好是它的「规则真源」,两者读同一份 config/e13_tutorial.json。

阅读导航:上一节:音效与震动反馈 · 下一节:引导漏斗埋点与仿真 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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