《Cocos 游戏开发入门》12.1 游戏状态机

《方块冲刺》跑起来了,但加载、主菜单、游戏、暂停、结算全靠布尔标志拼凑。本节为 mini2d 写一个状态机:统一的进入/退出钩子、状态栈做暂停恢复、用转移表挡住非法跳转;末节对照 Cocos Creator 3.8 的 director 场景切换与 game 前后台事件。

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 序列化格式、版本与迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画