切换语言
切换主题

小游戏状态机设计:从首页到战斗再到结算的完整流程

"棋牌游戏 80% 的 bug 来自状态设计不当,状态机能有效解决状态判断散落各处的问题。"

- 搜狐棋牌游戏架构文章

"嵌套状态机已成为复杂游戏的主流设计模式,Battle State 内部可包含 BeginBattle、HeroTurn、EnemyTurn、EndBattle 四个子状态。"

你做的小游戏刚上线,玩家反馈结算界面经常卡住——明明游戏结束了,界面却还显示战斗状态。翻开代码一看,满屏的 if (isPlaying && !isPaused && !isGameOver && hasPlayerLeft)。那一刻我只想说:这代码谁写的?哦,是我自己。

说实话,我也踩过这个坑。以前做一个棋牌小游戏,状态判断到处都是 boolean 变量,改一个功能要翻十几个文件。最崩溃的是多人对战时,服务端切到结算状态,客户端还在播放战斗动画——两边状态漂移了。后来重构用了状态机,代码清爽了不少。

这篇文章聊聊小游戏状态机怎么设计。从首页到战斗再到结算,我会拆成三层架构讲清楚:游戏流程层、战斗流程层、战斗细节层。还有踩坑经验:状态锁、服务端校验、超时机制——这些才是实战里真正有用的东西。

为什么小游戏需要状态机?

说白了,游戏就是一堆状态在来回切换。

首页、战斗、结算、暂停、观战——每个画面背后都是一个状态。玩家点”开始游戏”,首页消失,战斗界面出现。战斗结束,结算弹出来。听起来简单,但代码写起来就乱了。

if-else 瀑布流的三宗罪

先看段代码,你应该见过类似的:

// 状态判断散落各处
if (isPlaying && !isPaused && !isGameOver) {
  // 玩家可以操作
}

if (isGameOver && !hasShownResult) {
  // 显示结算界面
}

if (isPlaying && currentPlayer === 'player1') {
  // 玩家1回合
}

这代码有三宗罪:

难读:要判断当前状态,你得翻遍整个文件找 boolean 变量。isPlayingisPausedisGameOver——这些变量散落各处,像一堆碎片拼图,你得自己拼出完整画面。

难改:新增一个”暂停”状态?得改十几个地方的 if 判断。漏掉一处,bug 就来了。有一次我加了个”观战”状态,改了两天代码,上线还是出问题了——某个动画没暂停。

难调试:状态切换顺序乱了,你根本不知道是哪里出问题。isGameOver 变成 true 了,但 hasShownResult 还是 false——到底是哪个判断漏写了?

搜狐那篇文章提到:棋牌游戏 80% 的 bug 来自状态设计不当。这数字看着吓人,但经历过的人都知道,是真的。

状态机怎么解决这些问题?

状态机把这些散落的判断收拢到一个地方。

每个状态有自己的行为:进入时做什么、运行时做什么、退出时做什么。状态之间的切换规则写清楚:从首页只能跳到战斗,不能直接跳到结算。

看对比:

// 状态机方式
class HomeState {
  enter() { showHomeUI(); }
  exit() { hideHomeUI(); }
  handleEvent(event) {
    if (event === 'START_BATTLE') {
      manager.changeState(new BattleState());
    }
  }
}

class BattleState {
  enter() { showBattleUI(); startBattle(); }
  exit() { hideBattleUI(); cleanupBattle(); }
}

状态封装好了。改 HomeState 的逻辑,不会影响 BattleState。新增状态,只需要写一个新的 State 类,再加一条切换规则。

Better Programming 那篇文章说:嵌套状态机已经成为复杂游戏的主流设计模式。小游戏也不例外——状态清晰,代码才能清晰。

三层状态机架构详解

小游戏的状态机要分层设计。不分层,战斗逻辑塞进游戏流程层,代码会越来越臃肿。

我把状态机拆成三层:游戏流程层、战斗流程层、战斗细节层。

第一层:游戏流程层

这是游戏的主骨架,从启动到结算的大流程:

BootState:游戏启动时,加载资源、初始化引擎。Cocos Creator 的 Boot 场景就是干这个事的——先跑 Boot,再切到 Home。

HomeState:首页菜单。玩家选关卡、选角色、看排行榜。这里的逻辑相对简单:显示 UI、处理按钮点击。

BattleState:战斗场景。这层不是终点,而是容器——战斗内部还有子状态机。

SettlementState:结算界面。显示胜负、统计数据、清理战斗资源。

流程是这样的:Boot → Home → Battle → Settlement → 回到 Home(或退出)。

用代码画个草图:

游戏启动 → BootState
          ↓ (加载完成)
         HomeState
          ↓ (点击开始)
         BattleState ← 战斗子状态机在这里
          ↓ (战斗结束)
      SettlementState
          ↓ (点击返回)
         HomeState

第二层:战斗流程层

BattleState 内部还有一套子状态机。Better Programming 那篇文章提到:Battle State 可以包含 BeginBattle、HeroTurn、EnemyTurn、EndBattle 四个子状态。

BeginBattleState:初始化战斗。设置关卡参数、加载角色数据、同步所有玩家状态。多人对战时,这个阶段要加锁——禁止玩家中途加入或退出。

PlayerTurnState:玩家回合。出牌、攻击、防御——这些操作都在这个状态里处理。回合结束,切换到下一个回合或结算。

EnemyTurnState(可选):AI 行动。单机小游戏可以跳过,多人对战就是对手回合。

EndBattleState:结算逻辑。判定胜负、计算奖励、触发结算动画。

流程:

进入 BattleState → BeginBattleState (加锁、同步)
                  ↓ (准备完成)
              PlayerTurnState
                  ↓ (回合结束)
              EnemyTurnState (可选)
                  ↓ (战斗结束)
              EndBattleState
                  ↓ (结算完成)
         退出 BattleState → SettlementState

第三层:战斗细节层

有些状态还要再细分。比如 PlayerTurnState,内部可以有:

动画状态机:角色攻击时播放攻击动画,受伤时播放受伤动画。Unity 和 Cocos Creator 都有内置的动画状态机,这里就不展开了。

回合状态机:出牌 → 攻击 → 防御 → 结束回合。每个动作是一个子状态。

Beast Card Clash 那个游戏把 setup、scoring、results screen 封装为独立状态类。这种设计的好处是:改一个阶段的逻辑,不影响其他阶段。

三层架构的好处是职责分明。改首页逻辑,不会破坏战斗逻辑。改回合细节,不会影响结算流程。团队协作也更容易——一个人负责战斗流程层,另一个人负责细节层。

状态机核心接口设计

三层架构说完了,来看代码怎么写。

知乎那篇 Unity 文章提到:状态机节点的统一接口是 OnEnter、OnUpdate、OnExit、OnHandleEvent。我参考这个设计,用 TypeScript 写了一套。

IGameState 接口

每个状态都要实现这个接口:

interface IGameState {
  name: string;                    // 状态名称,调试时方便识别
  
  enter(params?: any): void;       // 进入状态时初始化
  update(dt: number): void;        // 每帧更新(可选)
  exit(): void;                    // 退出状态时清理
  handleEvent(event: GameEvent): void;  // 处理事件,触发状态切换
}

五个方法的作用:

name:调试用。输出日志时能看到当前状态是 “HomeState” 还是 “BattleState”,比看 boolean 变量直观。

enter:进入状态时调用。HomeState 的 enter 显示首页 UI、加载玩家数据。BattleState 的 enter 初始化战斗参数、进入战斗子状态机。

update:每帧更新。战斗场景用得多——刷新倒计时、检测玩家操作。首页基本不用。

exit:退出状态时清理。释放资源、隐藏 UI、取消监听。

handleEvent:事件驱动切换。点击”开始游戏”触发 START_BATTLE 事件,HomeState 处理这个事件,切换到 BattleState。

GameStateManager 状态管理器

状态机需要一个管理器来切换状态。Stack Exchange 那篇文章提到 CGameEngine 类的设计:Init、Cleanup、ChangeState、PushState、PopState。

我简化了一下:

class GameStateManager {
  private currentState: IGameState | null = null;
  private stateStack: IGameState[] = [];  // 支持叠加状态(暂停覆盖战斗)
  
  // 初始化
  init(firstState: IGameState) {
    this.currentState = firstState;
    this.currentState.enter();
  }
  
  // 切换状态
  changeState(newState: IGameState, params?: any) {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = newState;
    this.currentState.enter(params);
  }
  
  // 推入叠加状态(用于暂停、弹窗)
  pushState(state: IGameState) {
    if (this.currentState) {
      // 当前状态不退出,只是暂停
      this.stateStack.push(this.currentState);
    }
    this.currentState = state;
    state.enter();
  }
  
  // 弹出叠加状态
  popState() {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = this.stateStack.pop();
    // 不需要重新 enter,因为之前只是暂停
  }
  
  // 每帧更新
  update(dt: number) {
    if (this.currentState) {
      this.currentState.update(dt);
    }
  }
  
  // 处理事件
  handleEvent(event: GameEvent) {
    if (this.currentState) {
      this.currentState.handleEvent(event);
    }
  }
}

changeState:切换状态。旧状态退出,新状态进入。首页切换到战斗用这个。

pushState / popState:叠加状态。战斗中按暂停键,PauseState 推入栈顶覆盖 BattleState。取消暂停,PauseState 弹出,BattleState 恢复。

叠加状态很重要。暂停、弹窗、确认框——这些都需要覆盖当前状态,但不销毁它。

实战踩坑与解决方案

理论说完了,聊聊实战踩坑。搜狐那篇文章总结了棋牌游戏的踩坑经验,我把几个经典问题展开讲讲。

状态漂移:服务端和客户端状态不一致

多人对战最容易踩这个坑。

场景是这样的:服务端切到结算状态了,但某个玩家的客户端还在战斗动画里——网络延迟,没收到状态切换的广播。界面显示”战斗中”,其实已经结束了。

原因:状态切换消息丢了,或者延迟太久。

解决

  1. 状态同步机制:服务端切状态时,广播给所有客户端。客户端收到消息后立即同步。

  2. 心跳检测:客户端每隔几秒发心跳给服务端,带上当前状态。服务端发现状态不一致,强制推送同步消息。

// 客户端心跳
class BattleState {
  enter() {
    this.startHeartbeat();
  }
  
  startHeartbeat() {
    setInterval(() => {
      socket.send({
        type: 'HEARTBEAT',
        state: this.manager.currentState.name,
        roomId: this.roomId
      });
    }, 3000);  // 每3秒发一次
  }
}

并发冲突:多人同时操作

牌桌上,两个玩家同时出牌,状态切换顺序乱了。

原因:没有状态锁,并发操作没有排队。

解决:状态锁 + “当前操作者ID”机制。

class DealingState implements IGameState {
  private lock: boolean = true;
  private currentOperator: string = '';
  
  enter() {
    this.lock = true;  // 发牌阶段加锁
    // 禁止玩家加入退出
    // 同步所有人初始数据
    
    setTimeout(() => this.unlock(), 3000);  // 3秒后解锁
  }
  
  unlock() {
    this.lock = false;
    this.currentOperator = this.getFirstPlayerId();
    this.manager.changeState(new PlayerTurnState());
  }
  
  handleEvent(event: GameEvent) {
    if (this.lock) {
      // 加锁状态,拒绝操作
      return;
    }
    
    // 只有当前操作者才能触发事件
    if (event.playerId === this.currentOperator) {
      // 处理操作
    }
  }
}

加锁的好处是:关键时刻(发牌、结算)只有一个流程在跑,不会被玩家操作打断。

结算安全:破解包上传虚假分数

客户端上传结算数据:分数、胜负。破解包把分数改了,服务端怎么知道真假?

原因:服务端信任客户端数据。

解决:服务端独立计算,不信任客户端结果。

// 服务端结算逻辑
class EndBattleState {
  enter() {
    // 不接受客户端上传的分数
    // 服务端根据战斗记录独立计算
    const result = this.calculateResultFromLog(battleLog);
    
    // 发送结果给所有客户端
    this.broadcastResult(result);
  }
  
  calculateResultFromLog(log: BattleLog) {
    // 根据战斗记录(出牌顺序、攻击数据)独立计算
    // 客户端的日志可以造假,关键数据要服务端校验
  }
}

核心原则:服务端不信任客户端。客户端只展示,服务端才做判定。

超时卡死:状态没有退出条件

某个状态卡住了,永远不切换到下一个状态。

原因:状态没有超时机制,等待的事件没触发。

解决:每个状态设置超时器。

class PlayerTurnState implements IGameState {
  private timeoutTimer: number;
  
  enter() {
    this.timeoutTimer = setTimeout(() => {
      // 超时自动跳过
      this.manager.handleEvent({
        type: 'TIMEOUT_SKIP',
        playerId: this.currentPlayer
      });
    }, 30000);  // 30秒超时
  }
  
  exit() {
    clearTimeout(this.timeoutTimer);  // 退出时清理超时器
  }
}

超时机制很重要。玩家掉线、网络卡住——状态不能一直等着,要有兜底方案。

四个踩坑点讲完了。这些经验比理论重要——我踩过状态漂移的坑,排查了两天才找到原因。后来加了心跳检测,问题就没了。

Cocos Creator 实战案例

理论有了,接口有了,踩坑经验也有了。来看 Cocos Creator 怎么落地。

上一篇《Cocos Creator 小游戏项目结构》聊了 Boot、场景、结算页怎么拆。这篇延续那个思路:状态机怎么和 Layer 结合。

单场景架构下的状态机

Cocos Creator 小游戏推荐单场景 + 多 Layer。一个场景,多个 Layer 显示隐藏。状态机刚好对应这套架构。

BootLayerHomeLayerBattleLayerSettlementLayer

每个 Layer 对应一个状态:

import { director, Node } from 'cc';

// HomeState
class HomeState implements IGameState {
  name = 'Home';
  private homeLayer: Node | null = null;
  
  enter() {
    // 显示 HomeLayer
    const scene = director.getScene();
    this.homeLayer = scene?.getChildByName('HomeLayer') ?? null;
    if (this.homeLayer) {
      this.homeLayer.active = true;
      this.initHomeUI();
    }
  }
  
  exit() {
    // 隐藏 HomeLayer
    if (this.homeLayer) {
      this.homeLayer.active = false;
      this.cleanupHome();
    }
  }
  
  handleEvent(event: GameEvent) {
    if (event.type === 'START_BATTLE') {
      // 切换到 BattleState
      this.manager.changeState(new BattleState(), event.params);
    }
  }
  
  initHomeUI() {
    // 加载玩家数据、设置按钮点击事件
  }
  
  cleanupHome() {
    // 释放首页资源
  }
}

// BattleState
class BattleState implements IGameState {
  name = 'Battle';
  private battleLayer: Node | null = null;
  
  enter(params?: any) {
    const scene = director.getScene();
    this.battleLayer = scene?.getChildByName('BattleLayer') ?? null;
    if (this.battleLayer) {
      this.battleLayer.active = true;
      this.startBattle(params);
    }
  }
  
  exit() {
    if (this.battleLayer) {
      this.battleLayer.active = false;
      this.cleanupBattle();
    }
  }
  
  startBattle(params: any) {
    // 进入战斗子状态机
    this.battleStateManager.init(new BeginBattleState(params));
  }
  
  cleanupBattle() {
    // 清理战斗资源
  }
}

Layer 的显示隐藏,就是状态切换的视觉表现。enter 显示,exit 隐藏。

状态机与数据传递

切换状态时传参数。比如首页选了关卡,BattleState 要知道是哪个关卡:

// HomeState 发起切换
handleEvent(event: GameEvent) {
  if (event.type === 'START_BATTLE') {
    // 传递关卡参数
    this.manager.changeState(new BattleState(), {
      levelId: event.levelId,
      difficulty: event.difficulty
    });
  }
}

// BattleState 接收参数
enter(params?: any) {
  const levelId = params?.levelId ?? 'default';
  const difficulty = params?.difficulty ?? 'normal';
  this.loadLevel(levelId, difficulty);
}

状态持久化用 localStorage。战斗中途退出,下次进来恢复进度:

// 保存进度
exit() {
  localStorage.setItem('battle_progress', JSON.stringify({
    levelId: this.levelId,
    round: this.currentRound,
    score: this.score
  }));
}

// 恢复进度
enter() {
  const saved = localStorage.getItem('battle_progress');
  if (saved) {
    const progress = JSON.parse(saved);
    this.resumeFromProgress(progress);
  }
}

微信小游戏注意事项

CSDN 那篇文章提到:微信小游戏状态机放在全局文件便于修改。

包体限制:微信小游戏主包 4MB。状态机代码要精简,不能写太多类。

分包加载:核心状态机放主包(Boot、Home、Battle),扩展状态放分包(特殊关卡、活动界面)。

全局状态文件:微信小游戏入口文件 game.js 里初始化状态机,其他模块通过全局变量访问。

// game.js
import { GameStateManager } from './states/GameStateManager';
import { BootState } from './states/BootState';

// 全局状态管理器
window.gameStateManager = new GameStateManager();
window.gameStateManager.init(new BootState());

微信 API 调用时,通过全局变量获取状态:

// 微信登录回调
wx.login({
  success: (res) => {
    const state = window.gameStateManager.currentState;
    if (state.name === 'HomeState') {
      state.handleEvent({ type: 'LOGIN_SUCCESS', code: res.code });
    }
  }
});

这套架构的好处是:微信 API 不用 import 状态机,通过全局变量访问就行。


这篇文章和上一篇《Cocos Creator 小游戏项目结构》一起看,理解更完整。项目结构是状态机的容器,状态机是项目结构的行为逻辑。

总结

说了这么多,状态机其实就是把游戏流程拆成清晰的节点。

三层架构——游戏流程层、战斗流程层、战斗细节层——从小游戏到大项目都能用。三层的好处是改一层不影响其他层,团队协作也方便。

踩坑经验比理论重要。状态漂移、并发冲突、结算安全、超时卡死——这四个问题我踩过,排查起来很痛苦。后来加了状态锁、心跳检测、服务端校验、超时机制,问题就没了。代码写对了,bug 会少很多。

如果你想动手试试,可以下载完整代码示例(GitHub 链接)。先看上一篇《Cocos Creator 小游戏项目结构》,理解 Layer 怎么拆,再看这篇理解状态机怎么跑。两篇一起看,架构就清晰了。

下一步我会聊聊 AI 辅助状态机测试——让 AI 帮你生成测试用例,覆盖各种状态切换路径。测试自动化了,踩坑概率会更低。

设计小游戏三层状态机架构

从首页到战斗再到结算的完整状态管理实现流程

⏱️ 预计耗时: 60 分钟

  1. 1

    步骤1: 定义游戏流程层状态

    列出游戏的主流程状态:BootState、HomeState、BattleState、SettlementState。每个状态对应一个 Layer 或场景,确定状态之间的切换规则。
  2. 2

    步骤2: 拆分战斗流程层子状态

    在 BattleState 内部建立子状态机:BeginBattleState(初始化加锁)、PlayerTurnState(玩家回合)、EnemyTurnState(可选 AI 回合)、EndBattleState(结算判定)。
  3. 3

    步骤3: 实现状态接口和管理器

    创建 IGameState 接口(name、enter、exit、update、handleEvent),实现 GameStateManager 管理状态切换(changeState)和栈操作(pushState、popState)。
  4. 4

    步骤4: 添加踩坑保护机制

    在关键状态(发牌、结算)加锁防止并发操作冲突,设置超时器避免卡死,实现心跳检测解决状态漂移,服务端独立计算防止结算作弊。
  5. 5

    步骤5: 对接 Cocos Creator Layer

    在 enter 中显示对应 Layer 并初始化数据,在 exit 中隐藏 Layer 并清理资源。状态机驱动 UI 切换,代码逻辑与视觉表现解耦。

常见问题

小游戏一定要用状态机吗?
不一定,但状态多了(首页、战斗、暂停、结算等)if-else 就会乱。状态机能帮你把逻辑收拢,改起来更安心。
三层状态机是不是过度设计?
小游戏可能不需要三层,但理解这个架构能帮你应对复杂场景。简单游戏用两层,复杂游戏扩展到三层。
状态机和场景切换有什么区别?
场景切换是 Cocos 的资源管理概念,状态机是逻辑概念。一个场景可以对应一个状态,一个状态也可以只管理 UI 显示隐藏。
多人对战时状态漂移怎么解决?
服务端广播状态切换 + 客户端心跳检测。每 3 秒发一次心跳,服务端发现不一致就强制同步。
结算数据怎么防止作弊?
服务端独立计算,不信任客户端上传的结果。客户端只展示,判定逻辑全在服务端。

10 分钟阅读 · 发布于: 2026年5月19日 · 修改于: 2026年5月19日

相关文章

BetterLink

想持续收到这个主题的更新?

你可以直接关注作者更新、订阅 RSS,或者继续沿着系列入口往下读,避免下次又回到搜索结果重新找。

关注公众号

评论

使用 GitHub 账号登录后即可评论