上一节我们把「时间怎么走」定了下来:固定步长、按 tick 采样、逐位可复现。但一款游戏不止一个「世界」——主菜单、对局、结算面板、设置弹窗,它们各自是一套逻辑,各自有自己的更新。主循环负责「推进时间」,场景管理负责「推进谁的时间」:现在到底该更新哪一套逻辑?谁该跑、谁该停?这一节把这件事变成一个可断言的状态机。
先说清边界。本站引擎内核 那套专题里的场景/节点树是引擎级的通用设施(怎么挂载、怎么遍历、怎么渲染);本节要的是小游戏级的最小状态机:只要够管住「进入 / 暂停 / 恢复 / 退出」四个动作,够让暂停、切后台、弹窗这些生命周期事件有一个统一的落点就够了。不引入节点树、不引入依赖注入,只讲状态迁移本身。
为什么是「栈」而不是「当前场景」
最朴素的做法是维护一个 currentScene 变量,切场景就赋值。它在两处场景(菜单 → 对局)里能用,一旦出现「叠一层」的需求就崩:对局中弹出设置面板,关掉后要回到对局——如果 currentScene 被设成了「设置」,那「回到对局」这个信息就丢了。
正确的心智模型是栈:压入新场景时,旧的场景不销毁、只暂停,它还在栈里;弹出新场景时,它下面的场景恢复。设置面板压在对局之上,关掉它,对局从「暂停」回到「活动」——栈天然记住了「从哪来」。这也是移动端小游戏的真实形态:任何时刻,玩家看到的是栈顶场景,但它下面往往还压着一到两层。
于是有一条必须守住的不变式:栈非空时,只有栈顶是「活动」,其余全是「暂停」。这条不变式让「谁在更新」永远没有歧义——更新栈顶就够了。
四个状态,四个动作
场景的生命周期用四个状态描述:idle(还没进)、active(活动中)、paused(暂停)、exited(已退出)。动作有四个:enter(进入)、pause(暂停)、resume(恢复)、exit(退出)。把它们连起来就是状态机。
| 当前状态 | 允许的动作 | 迁到 |
|---|---|---|
| idle | enter | active |
| active | pause / exit | paused / exited |
| paused | resume / exit | active / 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 成稳定字符串 |
| 弹窗/引导 | 走压栈,而不是替换当前场景 |
下一节处理这套状态机最麻烦的一条分支:暂停与恢复。暂停容易,难的是「切后台十分钟再回来」——如果老老实实把十分钟按固定步长补回来,一帧就要跑三万七千步,设备直接死。下一节给出封顶补偿的做法,并把整条「暂停 → 切后台 → 恢复」路径放进浏览器里跑给你看。
阅读导航:上一节:固定步长主循环与确定性 · 下一节:暂停、切后台与恢复 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。