游戏开发中,经常会有一段逻辑同时涉及多个系统。如果这些操作散落在角色、UI、任务和战斗系统中,后续很难确认一次业务操作到底会产生哪些影响。MyFramework 使用 Command 思想,把一段具有明确含义的业务逻辑集中封装到一个对象中。
项目地址:
一、Command 解决什么问题
以怪物血量变化为例,一次血量修改可能同时包含:
bash
修改怪物血量数据
刷新怪物头顶血条
播放受击表现
判断怪物是否死亡
更新任务或战斗统计
如果调用方分别操作这些系统:
bash
monster.setHealth(newHealth);
monster.getHealthBar()?.setHealth(newHealth);
mBattleStatistic.addDamage(damage);
if (newHealth <= 0)
{
monster.dead();
}
这些代码很容易出现在网络消息、技能、Buff、调试工具等多个地方。
时间久了以后,不同入口执行的逻辑可能不一致:
bash
技能伤害会刷新血条
Buff 伤害忘记刷新血条
GM 命令修改血量没有触发死亡
网络同步没有更新战斗统计
更合适的做法,是把"怪物血量变化"封装成一个命令。
二、创建一个静态命令
如果逻辑不需要延迟执行,也不需要保存执行状态,命令可以只是一个带静态 execute() 的类:
bash
public class CmdMonsterHealthChanged
{
public static void execute(Monster monster, int newHealth, int damage)
{
if (monster == null || monster.isDead())
{
return;
}
int health = MathUtility.clamp(newHealth, 0, monster.getMaxHealth());
monster.setHealth(health);
monster.getHealthBar()?.setHealth(
health,
monster.getMaxHealth());
mBattleStatistic.addDamage(damage);
if (health == 0)
{
CmdMonsterDead.execute(monster);
}
}
}
调用方只需要:
bash
CmdMonsterHealthChanged.execute(
monster,
monster.getHealth() - damage,
damage);
看到这句代码时,可以明确知道这里执行的是一次完整的"怪物血量变化",而不是单纯修改一个字段。
三、为什么静态类仍然属于 Command
Command 的核心不是必须:
bash
继承 Command
创建命令实例
设置命令接收者
压入命令队列
支持延迟和中断
它的核心是:
逻辑就是对象。
命令类本身代表一段业务逻辑:
bash
类名 表示这段逻辑的业务含义
execute 表示统一执行入口
参数 表示执行逻辑需要的数据
类内部 保存这次操作的完整流程
因此,即使没有创建实例,CmdMonsterHealthChanged 在设计上仍然是一个明确的逻辑对象。
只有在确实需要延迟执行、保存状态或中断时,才有必要继承框架中的 Command 并创建实例。
不要为了使用 Command 而强行增加对象和调度流程。
四、命令可以继续拆分
一个命令不应该无限扩大。
怪物死亡本身也是一段完整业务,因此可以继续封装:
bash
public class CmdMonsterDead
{
public static void execute(Monster monster)
{
if (monster == null || monster.isDead())
{
return;
}
monster.setDead(true);
monster.stopMove();
monster.playDeadAnimation();
mMonsterManager.removeAliveMonster(monster);
mBattleStatistic.addKillCount();
LT.HIDE(monster.getHealthBar());
}
}
血量变化命令只需要决定:
bash
if (health == 0)
{
CmdMonsterDead.execute(monster);
}
这样每个命令都有明确含义,也能被其他入口复用。
五、Command 和 EventSystem 的区别
Command 和 EventSystem 都可能涉及多个系统,但两者用途不同。
Command:明确知道要执行什么
bash
CmdMonsterHealthChanged.execute(monster, health, damage);
调用关系是确定的:
bash
调用方
↓
血量变化命令
├── 修改怪物数据
├── 更新血条
├── 更新统计
└── 判断死亡
它不需要订阅,也不需要分发。
需要执行的逻辑直接写在命令中,阅读代码时可以顺着调用关系找到全部行为。
EventSystem:不知道谁会处理
bash
mEventSystem.pushEvent<EventMonsterHealthChanged>();
事件发送方只负责通知:
bash
怪物血量已经发生变化
至于哪些系统监听、监听者什么时候注册、会有多少监听者,发送方并不知道。
EventSystem 更适合:
bash
监听者数量不确定
监听关系会动态变化
发送方不应该依赖监听方
某些响应属于可选行为
如果业务发生时已经明确知道必须修改哪些系统,就不需要为了"解耦"而全部改成事件。
过多事件会让执行链路变得隐蔽,阅读发送代码时无法直接知道最后会触发哪些逻辑。
六、Command 中也可以发送事件
Command 和 EventSystem 并不冲突。
命令负责必须执行的主逻辑,完成以后仍然可以发送事件,通知那些不属于核心流程的监听者:
bash
public class CmdMonsterHealthChanged
{
public static void execute(Monster monster, int newHealth)
{
monster.setHealth(newHealth);
monster.getHealthBar()?.setHealth(newHealth);
if (newHealth <= 0)
{
CmdMonsterDead.execute(monster);
}
mEventSystem.pushEvent<EventMonsterHealthChanged>();
}
}
这里可以区分为:
bash
修改数据、刷新血条、判断死亡
属于这次操作必须执行的逻辑,写在 Command 中
成就、埋点或临时活动监听
不属于命令必须知道的逻辑,可以通过 EventSystem 响应
七、命令应该放什么逻辑
适合封装成 Command 的通常是具有明确业务含义的操作:
bash
角色受到伤害
怪物血量变化
角色死亡
拾取物品
穿戴装备
领取任务奖励
读取邮件
购买商品
切换地图
不建议把简单的字段访问也全部写成命令:
bash
monster.getHealth();
monster.getPosition();
item.getCount();
Command 应该代表一段完整逻辑,而不是代替所有普通函数。
八、命名和调用规则
命令类建议使用统一前缀:
bash
CmdMonsterHealthChanged
CmdMonsterDead
CmdCharacterSelect
CmdGlobalPickItem
CmdMailRead
统一入口使用:
bash
public static void execute(...)
调用时就能形成清晰的业务代码:
bash
CmdCharacterTakeDamage.execute(character, attacker, damage);
CmdMonsterHealthChanged.execute(monster, health, damage);
CmdMonsterDead.execute(monster);
命令名称应描述业务行为,而不是描述内部实现。
例如:
bash
CmdMonsterHealthChanged 清楚
CmdSetValue 含义过于模糊
CmdUpdate 无法判断更新什么
九、总结
MyFramework 中 Command 的核心不是命令队列,也不是延迟、中断或接收者,而是一种逻辑封装思想:
逻辑就是对象。
当一个业务操作会明确影响多个系统时,把完整逻辑集中到一个命令类中:
bash
调用入口统一
执行内容明确
跨系统逻辑集中
不同调用方可以复用
代码调用关系容易追踪
不需要延迟时,直接使用静态 execute() 即可。
Command 负责明确、必须执行的业务逻辑;EventSystem 负责订阅、广播和不确定的监听关系。两者用途不同,不应该相互替代。