5.2 场景管理与状态机

一款小游戏不止一个「世界」:菜单、对局、结算、弹窗各自是一个场景。本节用场景栈 + 生命周期状态机管理它们——进入、暂停、恢复、退出四个动作,按动作而非目标态判定合法迁移,非法迁移一律拒绝且不改动任何状态,并配可跑的 TS 内核与断言测试。

上一节我们把「时间怎么走」定了下来:固定步长、按 tick 采样、逐位可复现。但一款游戏不止一个「世界」——主菜单、对局、结算面板、设置弹窗,它们各自是一套逻辑,各自有自己的更新。主循环负责「推进时间」,场景管理负责「推进谁的时间」:现在到底该更新哪一套逻辑?谁该跑、谁该停?这一节把这件事变成一个可断言的状态机。

先说清边界。本站引擎内核 那套专题里的场景/节点树是引擎级的通用设施(怎么挂载、怎么遍历、怎么渲染);本节要的是小游戏级的最小状态机:只要够管住「进入 / 暂停 / 恢复 / 退出」四个动作,够让暂停、切后台、弹窗这些生命周期事件有一个统一的落点就够了。不引入节点树、不引入依赖注入,只讲状态迁移本身。

为什么是「栈」而不是「当前场景」

最朴素的做法是维护一个 currentScene 变量,切场景就赋值。它在两处场景(菜单 → 对局)里能用,一旦出现「叠一层」的需求就崩:对局中弹出设置面板,关掉后要回到对局——如果 currentScene 被设成了「设置」,那「回到对局」这个信息就丢了。

正确的心智模型是栈:压入新场景时,旧的场景不销毁、只暂停,它还在栈里;弹出新场景时,它下面的场景恢复。设置面板压在对局之上,关掉它,对局从「暂停」回到「活动」——栈天然记住了「从哪来」。这也是移动端小游戏的真实形态:任何时刻,玩家看到的是栈顶场景,但它下面往往还压着一到两层。

于是有一条必须守住的不变式:栈非空时,只有栈顶是「活动」,其余全是「暂停」。这条不变式让「谁在更新」永远没有歧义——更新栈顶就够了。

四个状态,四个动作

场景的生命周期用四个状态描述:idle(还没进)、active(活动中)、paused(暂停)、exited(已退出)。动作有四个:enter(进入)、pause(暂停)、resume(恢复)、exit(退出)。把它们连起来就是状态机。

当前状态允许的动作迁到
idleenteractive
activepause / exitpaused / exited
pausedresume / exitactive / exited
exited(无)终态

这里藏着一个非常容易踩的坑:enter 和 resume 的目标态都是 active。如果你按「目标状态」来判断合法性(「要到 active 就允许」),那么从 paused 直接 enter 也会被放行——可它其实应该是 resume。所以合法迁移必须按动作判定,而不是按目标态判定。这是本节实现里最值得记住的一行。

idle:   enter → active
active: pause → paused,  exit → exited
paused: resume → active, exit → exited
exited: (终态,任何动作都拒绝)

P 层内核:场景栈 + 状态机

下面这段是 P 层内核 core/src/e05/scene.ts。它把上面的迁移表落成 LEGAL,transition 是一个纯函数——给它「当前状态 + 动作」,它告诉你「合法吗、迁到哪、不合法为什么」。push / pop 建在状态机之上:压栈时把原栈顶 pause、新场景 enter;弹栈时把栈顶 exit、下层 resume。所有非法调用(空栈弹栈、重复 id、连续两次暂停)都返回 ok: false 且不触碰任何状态,所以可以安全地重复调用。

// file: core/src/e05/scene.ts
// tier: P
// verify: node --test 'core/test/e05_*.test.ts'
//
// 场景栈 + 生命周期状态机:进入 / 暂停 / 恢复 / 退出四个动作。
// 非法迁移一律拒绝,且不改动任何状态(可安全重复调用)。
// 不变式:栈非空时,只有栈顶是 active,其余全是 paused。
// 只用可擦除语法(禁 enum / namespace)。

export type SceneState = "idle" | "active" | "paused" | "exited";
export type SceneAction = "enter" | "pause" | "resume" | "exit";

export interface TransitionResult {
  readonly ok: boolean;
  readonly from: SceneState;
  readonly to: SceneState;
  readonly reason: string;
}

const TARGET: Record<SceneAction, SceneState> = {
  enter: "active",
  pause: "paused",
  resume: "active",
  exit: "exited",
};

// 合法迁移表:状态 -> 允许的动作。enter 与 resume 都到 active,故必须按动作而非目标态判定。
const LEGAL: Record<SceneState, readonly SceneAction[]> = {
  idle: ["enter"],
  active: ["pause", "exit"],
  paused: ["resume", "exit"],
  exited: [],
};

export function transition(from: SceneState, action: SceneAction): TransitionResult {
  const ok = LEGAL[from].indexOf(action) >= 0;
  return { ok, from, to: ok ? TARGET[action] : from, reason: ok ? action : "illegal:" + from + "->" + action };
}

export interface SceneStack {
  readonly ids: string[];
  readonly states: SceneState[];
  readonly log: string[];
}

export function createStack(): SceneStack {
  return { ids: [], states: [], log: [] };
}

export function topIndex(s: SceneStack): number {
  return s.ids.length - 1;
}

export function stateOf(s: SceneStack, id: string): SceneState | null {
  const i = s.ids.indexOf(id);
  return i < 0 ? null : s.states[i];
}

function apply(s: SceneStack, i: number, action: SceneAction): TransitionResult {
  const r = transition(s.states[i], action);
  if (r.ok) {
    s.states[i] = r.to;
    s.log.push(s.ids[i] + ":" + action);
  }
  return r;
}

/** 压入新场景:原栈顶暂停,新场景进入。重复 id 拒绝。 */
export function push(s: SceneStack, id: string): TransitionResult {
  if (s.ids.indexOf(id) >= 0) return { ok: false, from: "idle", to: "idle", reason: "duplicate:" + id };
  const i = topIndex(s);
  if (i >= 0) {
    const p = apply(s, i, "pause");
    if (!p.ok) return p;
  }
  s.ids.push(id);
  s.states.push("idle");
  return apply(s, s.ids.length - 1, "enter");
}

/** 弹出栈顶:栈顶退出,其下场景恢复。空栈拒绝。 */
export function pop(s: SceneStack): TransitionResult {
  const i = topIndex(s);
  if (i < 0) return { ok: false, from: "idle", to: "idle", reason: "empty" };
  const x = apply(s, i, "exit");
  if (!x.ok) return x;
  s.ids.pop();
  s.states.pop();
  const j = topIndex(s);
  if (j >= 0) {
    const r = apply(s, j, "resume");
    if (!r.ok) return r;
  }
  return x;
}

/** 暂停栈顶(平台切后台)。 */
export function pauseTop(s: SceneStack): TransitionResult {
  const i = topIndex(s);
  if (i < 0) return { ok: false, from: "idle", to: "idle", reason: "empty" };
  return apply(s, i, "pause");
}

/** 恢复栈顶(平台回前台)。 */
export function resumeTop(s: SceneStack): TransitionResult {
  const i = topIndex(s);
  if (i < 0) return { ok: false, from: "idle", to: "idle", reason: "empty" };
  return apply(s, i, "resume");
}

/** 栈摘要:id=state 序列,稳定可比。 */
export function dump(s: SceneStack): string {
  return s.ids.map((id, k) => id + "=" + s.states[k]).join(",");
}

注意 apply 的实现细节:只有迁移合法时才写状态和日志。不合法时,transition 返回的 to 就是 from,函数直接原样返回,一个字节都不改。这个「要么成功、要么什么都不做」的性质,让上层可以放心地在事件回调里调用它——哪怕同一个事件被重复触发(切后台事件在某些平台确实会连发两次),第二次也只会被拒绝,不会把状态搅乱。

P 层测试:把合法矩阵与栈行为钉死

状态机最怕的是「漏了某个非法迁移」。所以测试的第一件事不是跑流程,而是把 4×4 的迁移矩阵全部穷举一遍,逐格比对预期。第二件事才是栈的 push/pop 流程与日志。第三件事是非法调用的拒绝。

// file: core/test/e05_scene.test.ts
// tier: P
// verify: node --test 'core/test/e05_*.test.ts'
//
// 断言:四个状态 × 四个动作的合法矩阵;场景栈 push/pop 的迁移与日志;非法迁移拒绝且不改状态。

import { test } from "node:test";
import assert from "node:assert/strict";

import {
  transition, createStack, push, pop, pauseTop, resumeTop, stateOf, dump,
} from "../src/e05/scene.ts";

const STATES = ["idle", "active", "paused", "exited"] as const;
const ACTIONS = ["enter", "pause", "resume", "exit"] as const;
// 合法矩阵:行=状态,列=动作
const EXPECT = {
  idle: { enter: true, pause: false, resume: false, exit: false },
  active: { enter: false, pause: true, resume: false, exit: true },
  paused: { enter: false, pause: false, resume: true, exit: true },
  exited: { enter: false, pause: false, resume: false, exit: false },
};

test("迁移矩阵:enter 与 resume 都到 active,必须按动作判定", () => {
  for (const from of STATES) {
    for (const action of ACTIONS) {
      const r = transition(from, action);
      assert.equal(r.ok, EXPECT[from][action], `${from} --${action}--> 期望 ${EXPECT[from][action]}`);
      if (r.ok) {
        assert.notEqual(r.to, from);
      } else {
        assert.equal(r.to, from, "非法迁移不得改变状态");
        assert.ok(r.reason.startsWith("illegal:"), "非法迁移应给出 illegal 原因");
      }
    }
  }
});

test("栈:push 暂停下层、pop 恢复下层", () => {
  const s = createStack();
  assert.ok(push(s, "menu").ok);
  assert.equal(dump(s), "menu=active");
  assert.ok(push(s, "game").ok);
  assert.equal(dump(s), "menu=paused,game=active");
  assert.ok(push(s, "popup").ok);
  assert.equal(dump(s), "menu=paused,game=paused,popup=active");

  assert.ok(pop(s).ok);
  assert.equal(dump(s), "menu=paused,game=active");
  assert.ok(pop(s).ok);
  assert.equal(dump(s), "menu=active");
  assert.ok(pop(s).ok);
  assert.equal(dump(s), "");

  assert.deepEqual(s.log, [
    "menu:enter", "menu:pause", "game:enter",
    "game:pause", "popup:enter", "popup:exit", "game:resume",
    "game:exit", "menu:resume", "menu:exit",
  ]);
});

test("非法:空栈 pop / 重复 id push / 连续两次暂停栈顶", () => {
  const s = createStack();
  assert.equal(pop(s).ok, false);
  assert.equal(pop(s).reason, "empty");

  push(s, "game");
  assert.equal(push(s, "game").ok, false);
  assert.equal(push(s, "game").reason, "duplicate:game");

  assert.ok(pauseTop(s).ok);
  assert.equal(stateOf(s, "game"), "paused");
  assert.equal(pauseTop(s).ok, false, "已暂停不能再暂停");
  assert.equal(stateOf(s, "game"), "paused", "非法调用不得改变状态");
});

test("非法:active 状态不能 resume(resume 只对 paused 合法)", () => {
  const s = createStack();
  push(s, "game");
  assert.equal(stateOf(s, "game"), "active");
  const r = resumeTop(s);
  assert.equal(r.ok, false);
  assert.equal(r.reason, "illegal:active->resume");
  assert.equal(stateOf(s, "game"), "active");
});

test("切后台再回前台:pauseTop → resumeTop 回到 active", () => {
  const s = createStack();
  push(s, "menu");
  push(s, "game");
  assert.ok(pauseTop(s).ok);
  assert.equal(dump(s), "menu=paused,game=paused");
  assert.ok(resumeTop(s).ok);
  assert.equal(dump(s), "menu=paused,game=active");
});

test("exited 是终态:退出后任何动作都被拒", () => {
  for (const action of ACTIONS) {
    const r = transition("exited", action);
    assert.equal(r.ok, false, `exited --${action}--> 应被拒`);
  }
});

在本机跑 node --test 'core/test/e05_*.test.ts',这一组 6 个用例全部通过。其中「栈:push 暂停下层、pop 恢复下层」实测的迁移日志(s.log)是:

menu:enter, menu:pause, game:enter, game:pause, popup:enter,
popup:exit, game:resume, game:exit, menu:resume, menu:exit

读这段日志,正好能看见「压入时下层暂停、弹出时下层恢复」的节奏:menu:pause 紧跟着 game:enter,popup:exit 紧跟着 game:resume。栈顶永远是最后 enter/resume 的那个——这就是不变式在日志里的样子。

为什么用迁移表而不是一堆 if

一个真实的争议是:状态机到底要不要写成显式的迁移表?很多小游戏把它写成 if (state === "active" && action === "pause") { ... } 的一串判断,看起来更直接。问题在于,一旦状态和动作各增加到五六个,if 链的合法迁移就散落在代码各处,没人能一眼看出「从 X 到 Y 到底允不允许」,漏一条非法分支就是线上 bug。

迁移表把「合法性」和「副作用」拆开:LEGAL 只回答「允不允许」,apply 只负责「允许了才改」。好处有三个。可穷举:4×4 的矩阵能全部写成测试,一格不落。可读:新增一个状态,只改表,不改逻辑。可防御:非法迁移天然被拒,不用在每处调用点重复校验。代价是多了几行表结构——对一个会长到十几处迁移的场景系统来说,这笔账很划算。

还有一个反模式要避开:用布尔值代替状态。比如 isPaused、isActive、isExited 三个布尔量,它们能表达 8 种组合,但只有 4 种合法——剩下的 4 种非法组合(比如 isPaused && isExited)随时可能被误置。用单个枚举状态(这里是字符串联合类型)则从根上排除了非法组合:任一时刻,状态只能是四者之一。

与后面几章的接口

场景栈不是孤立的一节,它是好几章的公共地基。第 13 章的新手引导,本质就是「在某个场景之上压一层引导场景,引导完成后弹出恢复」——直接复用这里的 push/pop。第 11 章的弹窗系统,是「压入一个轻量场景,暂停下层输入」。而第 8 章的存档,在切场景时(比如从对局退出到菜单)触发保存。

所以本节刻意把接口做小:只有 transition / push / pop / pauseTop / resumeTop / dump 六个导出,每个都返回一个带 ok 的结果。接口越小,越不容易在后续章节里被塞进不该有的东西;而 dump 返回的稳定字符串,让「场景现在是什么样」可以直接进日志、进断言、进回放比对。

自检表

场景系统上线前,逐条对照:

检查项合格标准
迁移判定按动作判定,不按目标态(enter 与 resume 都到 active)
非法迁移返回 ok: false 且不改状态,可重复调用
不变式栈非空时只有栈顶 active
状态表达单个枚举,不用多个布尔量拼组合
日志每次合法迁移留痕,可 dump 成稳定字符串
弹窗/引导走压栈,而不是替换当前场景

下一节处理这套状态机最麻烦的一条分支:暂停与恢复。暂停容易,难的是「切后台十分钟再回来」——如果老老实实把十分钟按固定步长补回来,一帧就要跑三万七千步,设备直接死。下一节给出封顶补偿的做法,并把整条「暂停 → 切后台 → 恢复」路径放进浏览器里跑给你看。

阅读导航:上一节:固定步长主循环与确定性 · 下一节:暂停、切后台与恢复 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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