角色逻辑越改越乱怎么办?从状态、命令到对象池选择游戏设计模式

时间:2026/04/09

一个角色最初只有站立和移动,更新函数可能十分清楚:

1
2
3
if (left_pressed) {
x -= speed * dt;
}

加入跳跃、攻击、硬直、死亡、手柄映射、AI 控制和网络回放后,同一个函数开始同时判断输入、改变状态、播放动画、触发音效并创建特效。此时继续增加分支很痛苦,但一次性引入单例、观察者、命令、状态、ECS 和对象池,也可能把直接逻辑变成一张难以追踪的抽象网络。

游戏设计模式不是一套必须集齐的名词。每种模式都在交换复杂度:它解决一种已经出现的变化,同时引入新的间接层、生命周期或状态管理成本。本文以角色系统为主线,讨论怎样识别问题,再选择状态、命令、事件、组件和对象池,而不是为了“架构完整”套模式。


1. 为什么游戏逻辑特别容易失控?

游戏每帧都在处理几类方向不同的变化:

1
2
3
4
5
6
7
玩家 / AI / 网络输入

游戏状态

物理、动画、音效、UI、成就

大量短生命周期对象

这些变化如果全部挤在一个对象中,就会产生几个典型信号:

  • 新增状态时要修改许多互不相关的分支;
  • 输入设备和角色行为直接绑定,无法重映射或回放;
  • 角色受伤后,UI、音效和成就系统互相直接调用;
  • 子弹和粒子频繁创建销毁,引发运行时抖动;
  • 为复用能力不断增加继承层级。

不同信号对应不同工具。状态模式不能解决高频分配,对象池也不能改善输入耦合。第一步应是给问题分类,而不是挑一个熟悉的模式套上去。

2. 状态逻辑一定要改成多态类吗?

不一定。状态模式的核心是把“当前状态允许什么行为、怎样转移”集中管理,而不是必须为每个状态创建一个继承类。

状态很少、转移清楚时,枚举状态机通常最直接:

1
2
3
4
5
Idle ──Move──> Running
Running ──Stop──> Idle
Idle/Running ──Hit──> Stunned
Stunned ──Recover──> Idle
任意非 Dead 状态 ──Die──> Dead

当每个状态拥有大量独立数据和更新逻辑、需要由不同模块扩展,或状态行为频繁新增时,再考虑多态状态对象。一个几十行的 switch 未必是坏设计;一个把控制流拆到十几个小类中的状态模式也未必更清楚。

3. 命令模式为什么适合输入、回放和网络同步?

如果按键处理直接调用角色方法:

1
2
3
if (key == Key::Space) {
player.jump();
}

那么“空格键”和“跳跃行为”耦合在一起。手柄重映射、AI 控制和回放系统都要复制这条调用路径。

命令模式把“发生了什么意图”表示成一个可传递的值或对象:

1
2
3
4
struct Command {
Action action;
std::uint32_t frame;
};

输入层把按键翻译成命令,角色状态机解释命令。命令可以记录、排队、发送到网络或重复执行。它不一定要使用带虚函数的 Command 基类;当命令集合封闭且数据简单时,一个普通结构体更容易序列化。

需要撤销时,命令还必须保存逆操作所需状态。仅仅把函数调用包进对象不会自动获得可靠的 undo。

4. 一个最小可运行的“命令 + 状态机”示例

下面的 C++17 示例使用值类型命令驱动玩家状态。相同命令序列既可以来自键盘,也可以来自录像文件或网络包;状态机完全不关心来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <cstdint>
#include <iostream>
#include <string_view>
#include <vector>

enum class Action {
move,
stop,
hit,
recover,
die,
};

enum class PlayerState {
idle,
running,
stunned,
dead,
};

struct Command {
std::uint32_t frame;
Action action;
};

std::string_view name(PlayerState state) {
switch (state) {
case PlayerState::idle: return "idle";
case PlayerState::running: return "running";
case PlayerState::stunned: return "stunned";
case PlayerState::dead: return "dead";
}
return "unknown";
}

class Player {
public:
void apply(Action action) {
if (state_ == PlayerState::dead) {
return;
}

switch (action) {
case Action::move:
if (state_ == PlayerState::idle) {
state_ = PlayerState::running;
}
break;
case Action::stop:
if (state_ == PlayerState::running) {
state_ = PlayerState::idle;
}
break;
case Action::hit:
state_ = PlayerState::stunned;
break;
case Action::recover:
if (state_ == PlayerState::stunned) {
state_ = PlayerState::idle;
}
break;
case Action::die:
state_ = PlayerState::dead;
break;
}
}

PlayerState state() const noexcept {
return state_;
}

private:
PlayerState state_ = PlayerState::idle;
};

int main() {
const std::vector<Command> replay{
{1, Action::move},
{2, Action::hit},
{3, Action::move}, // 硬直时忽略移动
{4, Action::recover},
{5, Action::move},
{6, Action::die},
{7, Action::recover}, // 死亡后忽略其他命令
};

Player player;
for (const Command command : replay) {
player.apply(command.action);
std::cout << "frame " << command.frame
<< ": " << name(player.state()) << '\n';
}
}

编译运行:

1
2
3
clang++ -std=c++17 -O2 -Wall -Wextra -pedantic \
player_state.cpp -o player_state
./player_state

预期输出:

1
2
3
4
5
6
7
frame 1: running
frame 2: stunned
frame 3: stunned
frame 4: idle
frame 5: running
frame 6: dead
frame 7: dead

这个例子把两种变化分开了:输入系统负责生成 CommandPlayer 负责根据当前状态解释 Action。第 3 帧的 move 在硬直状态下无效,第 7 帧的恢复也不能让死亡状态复活,非法转移被集中在状态机中处理。

示例还没有做到确定性网络同步所需的全部条件。真实回放还要记录完整输入参数、随机种子和时间步,并避免平台相关的非确定性计算。命令可记录只是基础,不代表系统自动可重放。

5. 什么时候把枚举状态升级为状态对象?

Player::apply 和每帧 update 逐渐变成巨型分支,而且每个状态有独立进入、更新和退出逻辑时,可以引入状态接口:

1
2
3
4
5
6
struct State {
virtual ~State() = default;
virtual void enter(Player&) {}
virtual void update(Player&, float dt) = 0;
virtual void exit(Player&) {}
};

状态对象让 StunnedState 等类型拥有自己的计时器和转移规则,但也引入对象所有权、切换期间的生命周期以及跨状态共享数据问题。状态切换时不要立即销毁当前正在执行成员函数的对象;常见做法是记录“下一状态”,在更新边界统一完成退出、替换和进入。

选择依据可以概括为:

状态特征 更合适的起点
状态少、转移简单、集合封闭 枚举 + switch
每个状态逻辑较多且边界独立 状态对象
状态可以并行组合,如移动与武器状态 多个正交状态机或组件
设计师需要可视化编辑复杂行为 数据驱动状态图/行为树工具

状态模式不是消灭所有分支,而是让分支出现在最能解释规则的位置。

6. 观察者怎样避免系统互相直接调用?

玩家受伤可能需要更新血条、播放音效、记录统计和检查成就。如果 Player 直接依赖所有系统,每增加一个响应者都要修改玩家代码。

观察者或事件系统把依赖方向改成:

1
2
3
4
Player 发布 HealthChanged
├──> UI 更新血条
├──> Audio 播放受伤音效
└──> Achievement 更新统计

它适合“一件事已经发生,零到多个系统对此作出反应”。如果发布者必须立即得到唯一结果,普通函数调用通常更清楚。

事件系统必须定义:

  • 同步派发还是排队到之后处理;
  • 订阅者调用顺序是否有保证;
  • 订阅者在回调中取消订阅是否安全;
  • 发布者或订阅者销毁时怎样解除关系;
  • 事件中的引用在真正处理时是否仍然有效。

观察者降低编译依赖,却会让运行时控制流变隐蔽。事件名、日志和调试工具应让这条链重新可见,不能把所有模块通信都塞进一个全局事件总线。

7. 组件化和 ECS 解决的是哪一种爆炸?

如果使用继承组合能力,类型可能演变成:

1
2
3
4
Monster
└── FlyingMonster
└── ArmoredFlyingMonster
└── BossArmoredFlyingMonster

“会飞”“有护甲”“是 Boss”是可以组合的能力,不一定适合固定继承树。组件化把数据拆成 PositionVelocityHealthRender 等部分,实体通过拥有不同组件获得能力。

ECS(Entity Component System)通常进一步让系统批量处理拥有特定组件的数据。这有利于数据驱动和缓存友好的布局,但完整 ECS 还涉及实体 ID、组件存储、结构变更和查询调度。几十个对象的小项目使用普通组合往往足够,不需要提前自研框架。

8. 对象池什么时候真的能减少抖动?

子弹、粒子和临时特效数量多、生命周期相近,频繁通用堆分配可能进入性能热点。对象池提前保留对象或存储,运行时通过“取出—重置—归还”复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Bullet {
bool active = false;
float x = 0.0F;
float y = 0.0F;
float life = 0.0F;
};

class BulletPool {
public:
explicit BulletPool(std::size_t capacity) : bullets_(capacity) {}

Bullet* spawn(float x, float y) {
for (Bullet& bullet : bullets_) {
if (!bullet.active) {
bullet = Bullet{true, x, y, 2.0F};
return &bullet;
}
}
return nullptr;
}

private:
std::vector<Bullet> bullets_;
};

池化并非免费:对象归还时必须重置完整状态,外部保存的指针可能在槽位复用后误指向“另一个对象”,池满时还需要丢弃、扩容或替换策略。上面的线性查找也可能成为热点,生产实现通常维护空闲索引。

先用 profiler 证明分配或抖动确实是问题,再引入池。数量很少、生命周期不一致或构造便宜的对象,直接由标准容器管理往往更简单。

9. 单例为什么在游戏项目里特别诱人?

日志、配置、资源管理器似乎到处都要访问,把它们做成 instance() 很方便。C++11 起,函数局部静态对象的初始化具备线程安全保证:

1
2
3
4
5
6
7
8
9
10
class GameConfig {
public:
static GameConfig& instance() {
static GameConfig config;
return config;
}

private:
GameConfig() = default;
};

安全初始化不等于安全设计。单例会隐藏依赖、共享可变状态,并让测试难以替换配置;跨全局对象的析构顺序也可能出问题。

对于需要明确生命周期或可替换实现的服务,优先通过构造函数传入引用。真正进程级、无状态或生命周期等同程序且替换价值很低的基础设施,才谨慎使用单例。不要用单例避免思考所有权。

10. 模板方法还值得使用吗?

模板方法让基类固定主流程,把部分步骤交给派生类:

1
2
3
4
5
void Character::update() {
think();
move();
draw();
}

当流程确实稳定且派生点很少时,它能维护调用顺序。但如果子类需要跳过步骤、访问大量基类内部状态或组合多种行为,继承层级会逐渐僵硬。此时把“思考策略”“移动能力”作为成员组合,往往比继续增加虚函数更灵活。

11. 工程中最容易踩哪些坑?

误区一:大 switch 一定比状态模式差

小而封闭的状态机用 switch 最容易一次看全。只有分支已经难以维护时,拆成状态对象才抵得上间接层成本。

误区二:命令对象天然支持撤销和网络同步

撤销需要保存旧状态,网络同步需要序列化、顺序、确认和确定性规则。命令只提供一个承载意图的边界。

误区三:事件越多,模块越解耦

过度事件化会隐藏调用链和因果顺序。明确的一对一请求优先普通接口,事件适合一对多的事实通知。

误区四:对象池一定提升性能

池可能增加扫描、重置和容量浪费。没有可重复基准和 profiler 证据时,不应仅凭对象“经常创建”就池化。

误区五:ECS 是所有游戏对象的最终架构

ECS 擅长批量处理大量同构组件,不代表 UI、关卡流程和所有业务对象都适合被拆成组件。可以在同一项目中让不同子系统使用不同模型。

12. 怎样根据问题选择模式?

已经出现的问题 可以评估的工具 首先确认的代价
状态分支和转移难维护 状态机/状态模式 状态对象生命周期与间接控制流
输入需要重映射、排队或回放 命令 参数、序列化与撤销数据
一个事实需要通知多个系统 观察者/事件 订阅生命周期与调试可见性
继承组合能力导致类型爆炸 组件/ECS 存储、查询和系统调度复杂度
高频同类对象分配成为热点 对象池 重置、句柄失效和池满策略
少量真正进程级服务 单例或应用上下文 隐藏依赖和测试替换困难

如果问题尚未出现,普通函数、清晰数据结构和组合通常是更好的起点。模式应该支付已经存在的复杂度,而不是预付未来也许不会出现的扩展需求。

13. 总结

开头的角色更新函数之所以混乱,是因为输入翻译、状态转移、系统通知和对象创建几种变化被放在了同一位置。正确做法不是一次套入所有模式,而是逐项识别变化来源。

  1. 状态机集中合法状态与转移,小状态集合不必回避 switch
  2. 命令把输入来源与游戏意图分开,为排队和记录提供数据边界;
  3. 观察者适合一对多事实通知,但必须管理订阅生命周期和可见性;
  4. 组件解决能力组合,对象池解决经过测量的高频分配,它们不能互相替代;
  5. 单例和复杂模式都有隐藏依赖成本,应在问题出现后克制使用。

面对一段不断增长的游戏逻辑,先在每个分支旁标注它属于“输入、状态、通知、数据组合还是对象生命周期”。同类变化聚集到清晰边界后,真正需要的模式通常会自然显现。