小游戏状态机设计:从首页到战斗再到结算的完整流程
"棋牌游戏 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 变量。isPlaying、isPaused、isGameOver——这些变量散落各处,像一堆碎片拼图,你得自己拼出完整画面。
难改:新增一个”暂停”状态?得改十几个地方的 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 恢复。
叠加状态很重要。暂停、弹窗、确认框——这些都需要覆盖当前状态,但不销毁它。
实战踩坑与解决方案
理论说完了,聊聊实战踩坑。搜狐那篇文章总结了棋牌游戏的踩坑经验,我把几个经典问题展开讲讲。
状态漂移:服务端和客户端状态不一致
多人对战最容易踩这个坑。
场景是这样的:服务端切到结算状态了,但某个玩家的客户端还在战斗动画里——网络延迟,没收到状态切换的广播。界面显示”战斗中”,其实已经结束了。
原因:状态切换消息丢了,或者延迟太久。
解决:
-
状态同步机制:服务端切状态时,广播给所有客户端。客户端收到消息后立即同步。
-
心跳检测:客户端每隔几秒发心跳给服务端,带上当前状态。服务端发现状态不一致,强制推送同步消息。
// 客户端心跳
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 显示隐藏。状态机刚好对应这套架构。
BootLayer → HomeLayer → BattleLayer → SettlementLayer
每个 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: 定义游戏流程层状态
列出游戏的主流程状态:BootState、HomeState、BattleState、SettlementState。每个状态对应一个 Layer 或场景,确定状态之间的切换规则。 - 2
步骤2: 拆分战斗流程层子状态
在 BattleState 内部建立子状态机:BeginBattleState(初始化加锁)、PlayerTurnState(玩家回合)、EnemyTurnState(可选 AI 回合)、EndBattleState(结算判定)。 - 3
步骤3: 实现状态接口和管理器
创建 IGameState 接口(name、enter、exit、update、handleEvent),实现 GameStateManager 管理状态切换(changeState)和栈操作(pushState、popState)。 - 4
步骤4: 添加踩坑保护机制
在关键状态(发牌、结算)加锁防止并发操作冲突,设置超时器避免卡死,实现心跳检测解决状态漂移,服务端独立计算防止结算作弊。 - 5
步骤5: 对接 Cocos Creator Layer
在 enter 中显示对应 Layer 并初始化数据,在 exit 中隐藏 Layer 并清理资源。状态机驱动 UI 切换,代码逻辑与视觉表现解耦。
常见问题
小游戏一定要用状态机吗?
三层状态机是不是过度设计?
状态机和场景切换有什么区别?
多人对战时状态漂移怎么解决?
结算数据怎么防止作弊?
10 分钟阅读 · 发布于: 2026年5月19日 · 修改于: 2026年5月19日
相关文章
Cocos Creator 小游戏项目结构:Boot、场景、结算页这样拆
Cocos Creator 小游戏项目结构:Boot、场景、结算页这样拆
Vitest 组件测试实战:Browser Mode 与 Playwright 集成
Vitest 组件测试实战:Browser Mode 与 Playwright 集成
GitHub Actions 安全实践:从 tj-actions 事件学到的 3 个关键防护
评论
使用 GitHub 账号登录后即可评论