12.1 游戏状态机
前面 11 章我们把渲染、输入、音频、UI 一件件搭齐,mini2d 已经能画、能跑、能出声。但《方块冲刺》到现在仍然是「一个场景跑到黑」:从启动到游戏结束,所有逻辑挤在同一个更新循环里。一旦上线,玩家会经历加载、主菜单、游戏中、暂停、结算这几个互斥的阶段,每个阶段的输入响应、是否推进物理、界面可见性都不一样。用一堆布尔量去描述这些阶段,很快就会写出「暂停时还能跳跃」这类 bug。这一节把游戏阶段收进一个状态机,末节给出 Cocos Creator 3.8 的对照。
12.1.1 状态散落成布尔标志的代价
先看反面教材。很多小游戏的更新循环长这样:
// 反面教材:用布尔量描述游戏阶段
let isPaused = false;
let isGameOver = false;
let isInMenu = true;
function update(dt: number): void {
if (!isPaused && !isGameOver && !isInMenu) {
stepPhysics(dt);
}
}
三个标志就有 2^3 = 8 种组合,其中「暂停且结算中」「主菜单且结算中」是非法的,但类型系统拦不住。随着阶段变多,判断条件会指数膨胀,漏写一个 !isInMenu 就是一次线上事故。问题的根子是:这些标志本该是互斥的,而布尔量天生不互斥。状态机的第一件事就是把「当前处于哪个阶段」收敛成一个值。
12.1.2 状态接口:三个钩子
状态机的最小模型是「一组状态 + 一张转移表」。每个状态实现同样的接口,把「进入时要做什么」「退出时要清什么」封装在自己内部:
export type StateId = 'loading' | 'menu' | 'playing' | 'paused' | 'result';
export interface GameContext {
score: number;
level: number;
frame: number;
}
export interface GameState {
readonly id: StateId;
/** 进入本状态:准备界面、重置数据 */
onEnter?(ctx: GameContext, from: StateId | null): void;
/** 离开本状态:清理监听、隐藏界面 */
onExit?(ctx: GameContext, to: StateId): void;
/** 处于本状态时每帧调用 */
onUpdate?(ctx: GameContext, dt: number): void;
}
三个钩子的分工要清楚:onEnter / onExit 负责一次性的准备工作,onUpdate 负责每帧的逻辑。把「进入时播放菜单 BGM」写进 onUpdate 是常见错误——那样会每帧重启一次音乐。from / to 参数让状态知道自己从哪来、到哪去,比如暂停界面需要知道「恢复后回到游戏」而不是回主菜单。
12.1.3 状态机核心与非法转移防护
状态机本体只做三件事:注册状态、按转移表校验、执行带钩子的切换:
export class StateMachine {
private states = new Map<StateId, GameState>();
private stack: GameState[] = [];
private allowed: Record<StateId, StateId[]>;
constructor(allowed: Record<StateId, StateId[]>) {
this.allowed = allowed;
}
register(state: GameState): void {
this.states.set(state.id, state);
}
get current(): GameState | null {
return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
}
canTransition(to: StateId): boolean {
const from = this.current?.id;
if (!from) return to === 'loading';
return this.allowed[from]?.includes(to) ?? false;
}
transition(to: StateId, ctx: GameContext): boolean {
if (!this.canTransition(to)) {
console.warn(`[mini2d] 非法状态转移:${this.current?.id ?? '(空)'} -> ${to}`);
return false;
}
const next = this.states.get(to);
if (!next) {
console.warn(`[mini2d] 未注册的状态:${to}`);
return false;
}
const prev = this.current;
prev?.onExit?.(ctx, to);
this.stack.pop();
this.stack.push(next);
next.onEnter?.(ctx, prev?.id ?? null);
return true;
}
}
转移表本身是一份声明式数据,集中写在一处:
export const TRANSITIONS: Record<StateId, StateId[]> = {
loading: ['menu'],
menu: ['playing'],
playing: ['paused', 'result'],
paused: ['playing', 'menu'],
result: ['menu', 'playing'],
};
这张表是唯一的真相来源。想加一条「结算后直接重开」,只改这里一行,transition 自动放行;想禁掉「主菜单直接跳结算」,删掉对应项即可,canTransition 会打印告警并拒绝。相比散落的 if,非法转移从「悄悄发生」变成「一条日志 + 一次返回 false」,调试成本天差地别。
12.1.4 状态栈:暂停不丢上下文
注意上面的实现用了栈而不是单个 current。原因在暂停:暂停不该销毁游戏状态,恢复时要原样回来。如果 paused 只是 transition 过去,playing 的 onExit 会把关卡数据清空,恢复就得重新加载。正确做法是把暂停当成叠加在游戏之上的一层:
push(state: GameState, ctx: GameContext): void {
const prev = this.current;
this.stack.push(state);
state.onEnter?.(ctx, prev?.id ?? null);
}
pop(ctx: GameContext): void {
if (this.stack.length <= 1) return;
const top = this.stack.pop()!;
top.onExit?.(ctx, this.current?.id ?? 'loading');
}
push 暂停层时,底下的 playing 状态对象原封不动地留在栈里,它的 onUpdate 自然停止(因为栈顶不是它)。pop 之后 playing 又回到栈顶,帧计数、分数、关卡进度一个不丢。这里有个纪律:push 的状态 onUpdate 负责渲染遮罩和响应「继续」,不要在里面推进物理。栈的深度通常不超过 2,如果发现要 push 第三层,多半是状态划分出了问题。
12.1.5 在《方块冲刺》里串起来
把五个状态注册进状态机,GameContext 持有跨状态存活的数据:
const ctx: GameContext = { score: 0, level: 1, frame: 0 };
const sm = new StateMachine(TRANSITIONS);
sm.register({
id: 'loading',
onEnter: () => { /* 预加载图集与音频,完成后 sm.transition('menu', ctx) */ },
});
sm.register({
id: 'menu',
onEnter: () => { /* 显示主菜单、播放菜单 BGM */ },
onExit: () => { /* 停止菜单 BGM、隐藏菜单 */ },
});
sm.register({
id: 'playing',
onEnter: (c) => { c.score = 0; c.frame = 0; },
onUpdate: (c, dt) => { c.frame++; stepPhysics(dt); },
});
sm.register({
id: 'paused',
onEnter: () => { /* 显示暂停遮罩 */ },
onExit: () => { /* 隐藏遮罩 */ },
});
sm.register({
id: 'result',
onEnter: (c) => { /* 用 c.score 生成结算面板 */ },
});
主循环随之简化:状态机负责「什么时候调用谁」,update 只管转发:
export function update(dt: number): void {
const state = sm.current;
state?.onUpdate?.(ctx, dt);
}
五个状态的职责摊开来看,边界很清晰:
| 状态 | 进入时做什么 | 每帧做什么 | 退出时做什么 |
|---|---|---|---|
| loading | 预加载图集、音频 | 更新进度条 | 释放加载界面 |
| menu | 显示菜单、播 BGM | 响应按钮 | 停 BGM |
| playing | 重置分数、帧计数 | 物理、输入、渲染 | 保存本局成绩 |
| paused | 显示遮罩 | 响应「继续」 | 隐藏遮罩 |
| result | 用分数生成结算面板 | 响应重开 / 回菜单 | 关闭面板 |
输入处理也变干净:跳跃键在 playing 里才生效,暂停键在 playing 里 push 暂停层。判断依据是 sm.current?.id,而不是三个布尔量的组合。ctx.score 这类数据放在 GameContext 里而非某个状态对象上,因为结算界面要读它,而那时 playing 状态已经 onExit 了——状态对象是行为,GameContext 才是数据,这条边界要守住。
12.1.6 Cocos 对照:场景切换、预加载与前后台事件
在 Cocos Creator 3.8 里,「状态」通常直接映射成场景:loading / menu / game 各是一个 .scene。切换靠 director.loadScene,cc.d.ts 里的签名是 loadScene(sceneName: string, onLaunched?: Director.OnSceneLaunched, onUnloaded?: Director.OnUnload): boolean。为了不让加载态一闪而过,先用 director.preloadScene 把场景资源预载好:
import { director, game, Game, _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('Bootstrap')
export class Bootstrap extends Component {
start(): void {
// 先把 menu 场景资源预载,再切过去;loadScene 在预载未完成时也能调用
director.preloadScene('menu', (err) => {
if (err) return;
director.loadScene('menu', () => {
console.log('[方块冲刺] 已进入主菜单');
});
});
}
}
这与我们 loading.onEnter 里「预加载完成后 transition('menu')」是同一件事:先备好资源,再宣布状态切换,避免切过去之后界面空白。区别在于 Cocos 的 loadScene 会销毁旧场景,所以暂停这种「叠加」语义不能靠切场景实现,得用一个常驻的弹窗节点盖在上面(第 11 章的弹窗栈正是干这个的)。
暂停还有一种情况要处理:玩家切到后台。Cocos 用 game.on 监听 Game.EVENT_HIDE / Game.EVENT_SHOW(cc.d.ts 里 Game.EVENT_HIDE = "game_on_hide",EVENT_SHOW 同族)。这两个事件对应我们的「暂停/恢复」:
import { game, Game, _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('AppLifecycle')
export class AppLifecycle extends Component {
onEnable(): void {
game.on(Game.EVENT_HIDE, this.onHide, this);
game.on(Game.EVENT_SHOW, this.onShow, this);
}
onDisable(): void {
game.off(Game.EVENT_HIDE, this.onHide, this);
game.off(Game.EVENT_SHOW, this.onShow, this);
}
private onHide(): void {
// 进入后台:等价于 push 一个 paused 状态
}
private onShow(): void {
// 回到前台:恢复,或提示玩家是否继续
}
}
还有一类状态不切换场景也能做:加载进度。Cocos 提供了 director 之外的事件,也可以在 preloadScene 的进度回调里更新进度条。对照下来,mini2d 的状态机负责逻辑阶段,Cocos 的场景 + 常驻节点负责表现阶段,两者可以一一对应。下一节我们解决另一件上线必做但容易翻车的事——存档。
阅读导航:上一节:目录 · 下一节:12.2 序列化格式、版本与迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。