角色逻辑越改越乱怎么办?从状态、命令到对象池选择游戏设计模式
时间:2026/04/09
一个角色最初只有站立和移动,更新函数可能十分清楚:
1 | if (left_pressed) { |
加入跳跃、攻击、硬直、死亡、手柄映射、AI 控制和网络回放后,同一个函数开始同时判断输入、改变状态、播放动画、触发音效并创建特效。此时继续增加分支很痛苦,但一次性引入单例、观察者、命令、状态、ECS 和对象池,也可能把直接逻辑变成一张难以追踪的抽象网络。
游戏设计模式不是一套必须集齐的名词。每种模式都在交换复杂度:它解决一种已经出现的变化,同时引入新的间接层、生命周期或状态管理成本。本文以角色系统为主线,讨论怎样识别问题,再选择状态、命令、事件、组件和对象池,而不是为了“架构完整”套模式。
1. 为什么游戏逻辑特别容易失控?
游戏每帧都在处理几类方向不同的变化:
1 | 玩家 / AI / 网络输入 |
这些变化如果全部挤在一个对象中,就会产生几个典型信号:
- 新增状态时要修改许多互不相关的分支;
- 输入设备和角色行为直接绑定,无法重映射或回放;
- 角色受伤后,UI、音效和成就系统互相直接调用;
- 子弹和粒子频繁创建销毁,引发运行时抖动;
- 为复用能力不断增加继承层级。
不同信号对应不同工具。状态模式不能解决高频分配,对象池也不能改善输入耦合。第一步应是给问题分类,而不是挑一个熟悉的模式套上去。
2. 状态逻辑一定要改成多态类吗?
不一定。状态模式的核心是把“当前状态允许什么行为、怎样转移”集中管理,而不是必须为每个状态创建一个继承类。
状态很少、转移清楚时,枚举状态机通常最直接:
1 | Idle ──Move──> Running |
当每个状态拥有大量独立数据和更新逻辑、需要由不同模块扩展,或状态行为频繁新增时,再考虑多态状态对象。一个几十行的 switch 未必是坏设计;一个把控制流拆到十几个小类中的状态模式也未必更清楚。
3. 命令模式为什么适合输入、回放和网络同步?
如果按键处理直接调用角色方法:
1 | if (key == Key::Space) { |
那么“空格键”和“跳跃行为”耦合在一起。手柄重映射、AI 控制和回放系统都要复制这条调用路径。
命令模式把“发生了什么意图”表示成一个可传递的值或对象:
1 | struct Command { |
输入层把按键翻译成命令,角色状态机解释命令。命令可以记录、排队、发送到网络或重复执行。它不一定要使用带虚函数的 Command 基类;当命令集合封闭且数据简单时,一个普通结构体更容易序列化。
需要撤销时,命令还必须保存逆操作所需状态。仅仅把函数调用包进对象不会自动获得可靠的 undo。
4. 一个最小可运行的“命令 + 状态机”示例
下面的 C++17 示例使用值类型命令驱动玩家状态。相同命令序列既可以来自键盘,也可以来自录像文件或网络包;状态机完全不关心来源。
1 |
|
编译运行:
1 | clang++ -std=c++17 -O2 -Wall -Wextra -pedantic \ |
预期输出:
1 | frame 1: running |
这个例子把两种变化分开了:输入系统负责生成 Command,Player 负责根据当前状态解释 Action。第 3 帧的 move 在硬直状态下无效,第 7 帧的恢复也不能让死亡状态复活,非法转移被集中在状态机中处理。
示例还没有做到确定性网络同步所需的全部条件。真实回放还要记录完整输入参数、随机种子和时间步,并避免平台相关的非确定性计算。命令可记录只是基础,不代表系统自动可重放。
5. 什么时候把枚举状态升级为状态对象?
当 Player::apply 和每帧 update 逐渐变成巨型分支,而且每个状态有独立进入、更新和退出逻辑时,可以引入状态接口:
1 | struct State { |
状态对象让 StunnedState 等类型拥有自己的计时器和转移规则,但也引入对象所有权、切换期间的生命周期以及跨状态共享数据问题。状态切换时不要立即销毁当前正在执行成员函数的对象;常见做法是记录“下一状态”,在更新边界统一完成退出、替换和进入。
选择依据可以概括为:
| 状态特征 | 更合适的起点 |
|---|---|
| 状态少、转移简单、集合封闭 | 枚举 + switch |
| 每个状态逻辑较多且边界独立 | 状态对象 |
| 状态可以并行组合,如移动与武器状态 | 多个正交状态机或组件 |
| 设计师需要可视化编辑复杂行为 | 数据驱动状态图/行为树工具 |
状态模式不是消灭所有分支,而是让分支出现在最能解释规则的位置。
6. 观察者怎样避免系统互相直接调用?
玩家受伤可能需要更新血条、播放音效、记录统计和检查成就。如果 Player 直接依赖所有系统,每增加一个响应者都要修改玩家代码。
观察者或事件系统把依赖方向改成:
1 | Player 发布 HealthChanged |
它适合“一件事已经发生,零到多个系统对此作出反应”。如果发布者必须立即得到唯一结果,普通函数调用通常更清楚。
事件系统必须定义:
- 同步派发还是排队到之后处理;
- 订阅者调用顺序是否有保证;
- 订阅者在回调中取消订阅是否安全;
- 发布者或订阅者销毁时怎样解除关系;
- 事件中的引用在真正处理时是否仍然有效。
观察者降低编译依赖,却会让运行时控制流变隐蔽。事件名、日志和调试工具应让这条链重新可见,不能把所有模块通信都塞进一个全局事件总线。
7. 组件化和 ECS 解决的是哪一种爆炸?
如果使用继承组合能力,类型可能演变成:
1 | Monster |
“会飞”“有护甲”“是 Boss”是可以组合的能力,不一定适合固定继承树。组件化把数据拆成 Position、Velocity、Health、Render 等部分,实体通过拥有不同组件获得能力。
ECS(Entity Component System)通常进一步让系统批量处理拥有特定组件的数据。这有利于数据驱动和缓存友好的布局,但完整 ECS 还涉及实体 ID、组件存储、结构变更和查询调度。几十个对象的小项目使用普通组合往往足够,不需要提前自研框架。
8. 对象池什么时候真的能减少抖动?
子弹、粒子和临时特效数量多、生命周期相近,频繁通用堆分配可能进入性能热点。对象池提前保留对象或存储,运行时通过“取出—重置—归还”复用。
1 | struct Bullet { |
池化并非免费:对象归还时必须重置完整状态,外部保存的指针可能在槽位复用后误指向“另一个对象”,池满时还需要丢弃、扩容或替换策略。上面的线性查找也可能成为热点,生产实现通常维护空闲索引。
先用 profiler 证明分配或抖动确实是问题,再引入池。数量很少、生命周期不一致或构造便宜的对象,直接由标准容器管理往往更简单。
9. 单例为什么在游戏项目里特别诱人?
日志、配置、资源管理器似乎到处都要访问,把它们做成 instance() 很方便。C++11 起,函数局部静态对象的初始化具备线程安全保证:
1 | class GameConfig { |
安全初始化不等于安全设计。单例会隐藏依赖、共享可变状态,并让测试难以替换配置;跨全局对象的析构顺序也可能出问题。
对于需要明确生命周期或可替换实现的服务,优先通过构造函数传入引用。真正进程级、无状态或生命周期等同程序且替换价值很低的基础设施,才谨慎使用单例。不要用单例避免思考所有权。
10. 模板方法还值得使用吗?
模板方法让基类固定主流程,把部分步骤交给派生类:
1 | void Character::update() { |
当流程确实稳定且派生点很少时,它能维护调用顺序。但如果子类需要跳过步骤、访问大量基类内部状态或组合多种行为,继承层级会逐渐僵硬。此时把“思考策略”“移动能力”作为成员组合,往往比继续增加虚函数更灵活。
11. 工程中最容易踩哪些坑?
误区一:大 switch 一定比状态模式差
小而封闭的状态机用 switch 最容易一次看全。只有分支已经难以维护时,拆成状态对象才抵得上间接层成本。
误区二:命令对象天然支持撤销和网络同步
撤销需要保存旧状态,网络同步需要序列化、顺序、确认和确定性规则。命令只提供一个承载意图的边界。
误区三:事件越多,模块越解耦
过度事件化会隐藏调用链和因果顺序。明确的一对一请求优先普通接口,事件适合一对多的事实通知。
误区四:对象池一定提升性能
池可能增加扫描、重置和容量浪费。没有可重复基准和 profiler 证据时,不应仅凭对象“经常创建”就池化。
误区五:ECS 是所有游戏对象的最终架构
ECS 擅长批量处理大量同构组件,不代表 UI、关卡流程和所有业务对象都适合被拆成组件。可以在同一项目中让不同子系统使用不同模型。
12. 怎样根据问题选择模式?
| 已经出现的问题 | 可以评估的工具 | 首先确认的代价 |
|---|---|---|
| 状态分支和转移难维护 | 状态机/状态模式 | 状态对象生命周期与间接控制流 |
| 输入需要重映射、排队或回放 | 命令 | 参数、序列化与撤销数据 |
| 一个事实需要通知多个系统 | 观察者/事件 | 订阅生命周期与调试可见性 |
| 继承组合能力导致类型爆炸 | 组件/ECS | 存储、查询和系统调度复杂度 |
| 高频同类对象分配成为热点 | 对象池 | 重置、句柄失效和池满策略 |
| 少量真正进程级服务 | 单例或应用上下文 | 隐藏依赖和测试替换困难 |
如果问题尚未出现,普通函数、清晰数据结构和组合通常是更好的起点。模式应该支付已经存在的复杂度,而不是预付未来也许不会出现的扩展需求。
13. 总结
开头的角色更新函数之所以混乱,是因为输入翻译、状态转移、系统通知和对象创建几种变化被放在了同一位置。正确做法不是一次套入所有模式,而是逐项识别变化来源。
- 状态机集中合法状态与转移,小状态集合不必回避
switch; - 命令把输入来源与游戏意图分开,为排队和记录提供数据边界;
- 观察者适合一对多事实通知,但必须管理订阅生命周期和可见性;
- 组件解决能力组合,对象池解决经过测量的高频分配,它们不能互相替代;
- 单例和复杂模式都有隐藏依赖成本,应在问题出现后克制使用。
面对一段不断增长的游戏逻辑,先在每个分支旁标注它属于“输入、状态、通知、数据组合还是对象生命周期”。同类变化聚集到清晰边界后,真正需要的模式通常会自然显现。