最近在整理SRPG项目的功能收尾,遇到一个在战棋类游戏里非常典型的问题:战斗、队伍、关卡脚本这些核心模块已经稳定运行了几个月,但一旦开始接入成就系统和支线系统,就发现处处都要往原本干净的战斗流程里塞代码。击杀要回传数值层、对话选择要通知任务状态机、关卡结束要告诉成就有没有满足条件……耦合点越来越多,每次调整一个系统,都要连带检查另外两三个系统会不会被误伤。
这种时候,全局事件总线就成了最自然的解法。我在这个项目里用了一套轻量级的事件总线,把成就系统、支线系统彻底从战斗主流程中拆了出去。这篇就按开发日志的形式,记录我从设计思路到落地实现的完整过程,包括几个SRPG特有的时序坑,希望能给同样在做战棋类游戏、或者任何带有“大量离散决策”玩法的朋友一点参考。
1. 先搞清楚SRPG为什么绕不开事件驱动
很多人在刚开始写SRPG战斗时,容易把游戏做成一个“整块”的流程:战斗管理器打完一场,就开始清点战利品,然后检查经验值,接着推进剧情,再通知UI刷新。前期系统少的时候这样写没问题,但成就、支线、任务目标、教学内容这些系统一多,问题就全来了。
SRPG和实时动作游戏有个本质差异:它的几乎所有核心玩点,都是以离散决策的方式发生的。玩家一次移动是一次决策,一次攻击是一次决策,一次技能释放是一次决策,选择某个对话分支也是一次决策。这种“棋盘上的每一手”天然就是事件源。你在代码里划一条清晰的边界,说“这里发生了一件事,谁关心谁自己来听”,而不是“这件事发生后,要把数据同步给所有相关方”,就是事件驱动设计的核心。
我后来用了一个很通俗的类比给组里新人解释:没有事件总线的系统,像办公室里的电梯——A部门要通知B、C、D部门,就必须挨个敲门;有事件总线的系统,像公司广播——谁关心哪条消息,自己去听。在SRPG里,战斗系统只需要把“单位阵亡”“回合结束”“关卡胜利”这些广播出去就够了,至于成就要统计击杀、支线要判断NPC是否死亡、教学要弹提示,都跟战斗系统没有关系。
实际需求里,我最先遇到的痛点来自成就系统。那头摆了一堆效果各异的成就:某角色累计造成十万伤害、一次攻击暴击三连、某个回合内未损失任何单位且过关、使用特定角色击败特定Boss……这些判定条件散落在一场战斗的各个角落。如果不用事件总线,成就系统就只能向战斗系统暴露大量查询接口,或者反过来战斗系统在几百个关键节点写死“if成就条件then提交”。无论哪种,都等于把成就的复杂度摊给别人。
支线系统的情形也类似。SRPG支线任务往往不是“到达区域A→打怪→回城交付”这么简单,而是“对话触发→加入一名临时角色→若干场战斗中保护他→特定关卡结束后离开”。这些状态机的推进点,几乎全部埋在其他系统的执行过程中。
所以结论很直接:在SRPG这个类型里,全局事件总线不是锦上添花,是系统数量过线后的必需品。它不是框架要求你做的“最佳实践”,而是你面对一堆跨系统同步需求时,唯一能把复杂度控制住的办法。
2. 事件总线的最小实现:从委托集合到泛型消息
这节直接给代码。我先说明我是怎么选型的,避免有人上来就用很重的解决方案。目前C#项目里常见的事件总线写法五花八门,有基于EventHandler<T>的、有基于Dictionary<Type, Delegate>的、有基于反射扫方法的。我的要求只有三个:不引入额外依赖、支持泛型消息、性能不拖回合帧后腿。
于是我最先落地的是一版朴素实现,核心结构是一个静态静态容器,保存“事件类型”到“订阅者集合”的映射。每个订阅者是一个包含具体处理方法的包装。代码大致长这样:
public interface IGameEventBus { void Publish<T>(T evt) where T : struct; IDisposable Subscribe<T>(Action<T> handler) where T : struct; } public sealed class GameEventBus : IGameEventBus { private readonly Dictionary<Type, List<Subscription>> _subs = new Dictionary<Type, List<Subscription>>(); private struct Subscription { public Action<object> Handler; public object Owner; } public IDisposable Subscribe<T>(Action<T> handler) where T : struct { var type = typeof(T); if (!_subs.TryGetValue(type, out var list)) { list = new List<Subscription>(); _subs[type] = list; } var sub = new Subscription { Handler = (obj) => handler((T)obj), Owner = handler.Target }; list.Add(sub); return new Unsubscriber(this, type, sub); } public void Publish<T>(T evt) where T : struct { if (!_subs.TryGetValue(typeof(T), out var list)) return; // 快照很重要,防止在某处取消订阅时影响本次广播循环 var snapshot = list.ToArray(); foreach (var sub in snapshot) { sub.Handler(evt); } } private void RemoveSubscription(Type type, Subscription sub) { /* 从列表移除 */ } }实现只有几十行,但有几个细节我想特别说一下,都是真做项目后才会注意到的:
为什么消息用struct约束?因为SRPG里的事件消息大多是零负载或者只有几个int参数,比如“第某回合结束”“某个单位阵亡”,用struct可以避免在战斗高频率广播时频繁分配GC内存。而且我后续为了更灵活,把参数对象独立定义,事件本身是纯数据结构,这样调试时能序列化记录。
为什么存Action<object>而不是直接存Action<T>?这是为了在这种非泛型容器里统一处理。实际只会损失一层类型转换的开销,对单帧几十次的事件广播来说可以忽略,但换来的是容器代码极其简单。
为什么需要快照?事件在广播过程中,订阅者A的处理逻辑可能取消了订阅者B的订阅——比如支线完成后,它把自己对某个事件的监听取消了,而该事件此刻正在分发途中。如果不做快照,这次广播可能报集合修改异常的错。做开发日志的都知道,这类bug极难复现,因为跟执行顺序强相关,所以我在最开始就快照,宁可多一层数组分配。
后面我为了调试方便,还加了一个按事件类型统计次数的计数器,以及可选的事件日志开关。这东西再往后发展出了一点很实用的功能,我放到第6节单独说。
3. 成就系统接入:把判定从业务流程里拆掉
事件总线本身是个空壳,真正有价值的是业务怎么用它。成就系统的核心理念可以概括成一句话:每个成就声明自己“关心哪些事件”和“满足什么条件”,剩下的交给事件总线动态喂它。战斗代码不需要知道成就的存在,成就系统也不需要了解战斗内部的实现。
3.1 事件、阶段积累与判定解耦
我先在代码里定了一套事件结构,围绕SRPG的常见判定点:
public readonly struct UnitKilledEvent { public readonly int KillerActorId; public readonly int VictimActorId; public readonly bool IsPlayerTeam; public readonly bool IsCounterAttack; } public readonly struct TurnEndedEvent { public readonly int TurnNumber; public readonly bool WhoseTurn; // true=player, false=敌人 } public readonly struct StageCompletedEvent { public readonly string StageId; public readonly int TurnsUsed; public readonly int LostUnitCount; }然后成就定义做成了配置和逻辑分离的结构。举三个典型例子,说明三种不同的处理模式:
“某回合内无损失过关”:注意这是计数值,需要事件带参。击杀事件里
IsPlayerTeam && IsCounterAttack就分别代表对方行动回合的反击击杀,这类细节必须在事件结构里带够,否则成就数据就得去查战斗记录,又成了耦合。“全程不使用治疗魔法通关”:这种没法在单一事件里判断,必须监听使用技能事件,统计一个本地状态,在关卡完成事件里做最终判定。
“累积造成十万伤害”:监听伤害结算事件,累加数值。这里值得注意,伤害结算事件应该发生在伤害真正生效后、动画演出前,否则玩家跳过动画时可能出现数据未更新的情况。
3.2 订阅/退订的生命周期管理
成就系统在自己的初始化阶段集中订阅事件。比如在关卡开始时:
public void OnStageStart(string stageId) { _stageKills = 0; _subDisposers = new List<IDisposable>(); // 监听本关需要的事件 _subDisposers.Add(_bus.Subscribe<UnitKilledEvent>(OnUnitKilled)); _subDisposers.Add(_bus.Subscribe<TurnEndedEvent>(OnTurnEnded)); _subDisposers.Add(_bus.Subscribe<SkillUsedEvent>(OnSkillUsed)); }这个设计有个很容易犯的错:忘记退订。虽然我的事件总线是全局静态的,但如果每次关卡都往里加订阅而不退订,到第20关时一个事件会同时触发几十个失效的监听,轻则内存泄漏,重则成绩误判。我的做法是给Subscribe返回的IDisposable在关卡结束统一释放,保证每个订阅的生命周期有明确的边界。
3.3 成就进度和UI展示的数据来源
UI需要实时显示成就进度,比如“还差3000点伤害”。我一开始直接让成就UI监听事件总线里所有与进度相关的事件,事件一发生就更新文本。实测下来发现问题:高频事件会导致UI每帧刷多次,尤其在多单位连锁反应时,闪得人眼晕。
后来我把进步到一个**“进度缓冲区”**方案:事件总线里发生的事件只让成就系统的内部状态更新,UI通过每帧轮询一个轻量级的脏标记来刷新。成就系统在内部状态变化时置一个Dirty标志,UI每帧只检查这个标志。这个方案的好处是,一次连锁反应触发的十几次进度更新,一帧内只刷新一次UI。
4. 支线系统接入:状态推进靠事件表驱动
支线系统的难点和成就有重合,但多了“流程时序”问题。成就只是一堆逻辑判定,支线则是一个会推进状态的机器。我这里用的模型是**“事件驱动的任务状态机”**。
4.1 典型的SRPG支线任务骨架
以我项目里一个实际支线举例:任务是护送一位低战力NPC从村庄撤退到桥头。整个任务的推进点有以下几类:
- 接取阶段:玩家在过场对话中触发接取,任务状态从
NotStarted变为Active。 - 执行阶段:护送目标到达某个坐标格、途中不能阵亡、进入某个地点的区域才会触发新对话。
- 完成判定:所有目标都满足后自动回关,或者需要找NPC交付。
如果用传统做法,这些推进逻辑会被塞进移动管理器、对话脚本、战败判定三个地方。事实上一开始我也踩了这个坑,后来改成这样:
public readonly struct AreaReachedEvent { public readonly string AreaId; public readonly string UnitId; } public readonly struct UnitDiedEvent { public readonly string UnitId; public readonly bool CountsAsFailure; } public readonly struct DialogueEndedEvent { public readonly string DialogueId; public readonly int SelectedOption; }然后在任务状态机里声明“这个任务关心哪些事件”:
public class QuestGuardMarsh : QuestBase { protected override void OnActivate() { Listen<AreaReachedEvent>(OnAreaReached); Listen<UnitDiedEvent>(OnUnitDied); Listen<DialogueEndedEvent>(OnDialogueEnded); } private void OnAreaReached(AreaReachedEvent evt) { if (evt.AreaId == "BridgeExit" && evt.UnitId == "Marsh") { SetObjective(Succeed); } else if (evt.AreaId == "AmbushZone") { SpawnReinforcement(); SetObjective(DefeatAmbush); } } }这样看起来清爽得多,但有个工程细节必须处理好:事件的发布位置要足够“早”且有保障。比如AreaReachedEvent必须在单位的坐标确实进入某个格子的那帧发布,不能等到UI演出播完之后才发,否则任务推进逻辑和动画表现会产生一帧的错位。为了让这个稳定,我把这类事件统一在战斗管理器的底部发布,也就是逻辑结算后立即发,外部系统可以在事件里拿到当时的快照,UI层如果要播对应演出,在监听器里再按自己的节奏接管。
4.2 支线任务本身也是事件的发布者
这里有个容易被忽略的思维反转:支线系统不只是事件总线上的订阅者,它自己还要发布事件。任务接取、任务完成、目标变更,都应该广播出去,否则NPC头顶的感叹号、小地图追踪、成就系统的“完成十个支线”成就,又得反向依赖支线系统的具体接口了。
比如任务完成事件:
public readonly struct QuestCompletedEvent { public readonly string QuestId; public readonly bool FromDialogue; public readonly int RewardsGiven; }成就系统听这个事件去统计“完成十次支线”,UI听懂这个事件去刷新任务列表和引导图标。这样支线系统对外只暴露事件,别的系统就不用知道它的内部状态了。
4.3 多任务并行推进时的防涮机制
SRPG支线还有一个麻烦:玩家通常会同时挂着好几个任务。多个任务监听同一个事件时,它们的推进逻辑都在同一个事件回调里执行。比如玩家一口气在战场上消灭了两个任务目标,两个任务同时完成——这时如果同时广播了两个UnitKilledEvent,第一个任务在完成时弹了UI,第二个任务紧接着又弹UI,两个UI互相盖住,体验很糟。
我的处理办法是在任务系统的总线监听器里加一个“本事件仅允许一个任务推进成功”的标记位:对每次UnitKilledEvent,建立taskTargetsWhoCare集合,同一事件只让第一个匹配的任务推进。这个逻辑虽然不复杂,但在没做事件总线前,它藏在任务管理器里就是一团浆糊;有了事件总线后,它变成一个很小的筛选函数,特别清晰。
5. SRPG特有的事件时序陷阱:回合帧、表现层和存档顺序
写到这里,如果你觉得事件总线就是“发个事件,大家听”,那恐怕要吃亏了。实做下来,事件驱动最痛的坑全在时序上。这一节我按踩坑顺序写,每一个都是真实发生过的bug根源。
5.1 事件应该发生在“逻辑结算后”还是“演出播完后”
我这个项目里,一场战斗的基本循环是:玩家选择行动→AI入场→战斗结算(伤害计算、经验分配、死亡判定)→演出播放→界面刷新。在这个流程里,事件的发布时机必须固定在一个点,我在代码注释里明确写了:事件发布一律放在逻辑结算之后、演出之前。
我写UnitKilledEvent的原始意图是谁死了,谁杀的,哪个回合。但如果把事件发布放在演出播完之后,那战斗管理器就必须多保存一份“异步事件队列”,否则击杀事件会漏——因为一场连锁战斗可能有十多段演出依次排队。所以我的策略是:
- 逻辑层事件(如
UnitKilledEvent)在结算结束立刻发布。 - 表现层逻辑(如“播放某角色阵亡演出”)不靠监听事件直接驱动,而是由战斗管理器的事件上游先垫一个很小的演出队列。
为什么不是所有人都这样做?因为图省事的人经常直接把演出播放放在事件回调里,结果就是:多个监听器执行顺序不可控,一个监听器播一阵演出,另一个监听器又播一阵,效果是一团乱麻。事件总线只适合同步逻辑数据,不适合直接跑重要表现。
5.2 UI刷新一定推后到帧末
这一点和第3节里成就进度刷新遇到的问题同源。SRPG一回合里可能有:六次连击伤害事件、一次治疗事件、一次死亡事件。如果每个事件都让HP血条刷新,血条会闪烁到玩家看不清数值。事件总线的订阅者应该写数据状态而没有UI刷新义务。UI刷新统一放在帧末LateUpdate阶段。
我用一个很简单的门卫保证这件事:
private bool _isFlushing; public void StartTurnFlush() { _isFlushing = true; } public void EndTurnFlush() { _isFlushing = false; ForceUIRefresh(); }在执行复杂的战斗结算前,把UI刷新开关设成false;等结算收尾,把所有状态变化的脏标记统一刷新一遍。这样帧内怎么闹,玩家看到的永远是一个干净的最终态。
5.3 存档边界与事件重放的顺序
事件总线方案最容易被忽略的一个地方是存档。假设玩家在回合结束时存档,存档内容包含“某个支线任务已完成”。但我存档的时机如果在事件的发布过程中,而读取存档的时间点在另一个帧,就可能出现“存档里任务状态已经更新,但成就的统计还没被该事件触发”的错位。
我的解决方案非常暴力但有效:每个需要写入存档的系统,只依赖事件总线事件的结果,而存档只允许在帧末统一写一次。具体来说,回合结束的时候:
- 发布
TurnEndedEvent,所有关心此事件的系统先更新内存状态; - 等待
EndTurnFlush信号; - 只有全部信号都结束后,才开始序列化存档。
这样就杜绝了“同一个数据在两个系统里读到的版本不一致”的问题。真做SRPG存档的都知道,这个不一致是存档损坏的根源。
5.4 事件广播中的循环触发
这是所有事件总线都容易踩的顶级坑:A事件触发了B系统,B系统又发布C事件,C事件又触发A系统……我项目里一次最简陋的滑稽事故是:任务系统发布QuestCompletedEvent,成就系统收到后把“累计通关支线次数”更新,然后成就又发布一个AchievementUnlockedEvent,结果任务系统的监听器以为这个成就是支线触发条件,又推进了一个任务。最后整个循环跑了十几层才被栈溢出拦住。
后续我在总线里加了一个“当前广播深度”的调试字段,超过一定层数直接断点。更重要的是,我在事件设计上做了一条团队纪律:一个系统发布的事件,不允许再依赖同一系统在同一次广播内发布的其它事件。换句话说,事件触发链应该是单向推进的,不是无向图。
6. 调试快照、命名规范与事件总线的边界意识
做法走到这一步,事件总线已经不只是“工具”,它逐渐变成我项目里的信息中枢。这也带来一个新需求:事件多了之后,你怎么知道一个事件在某个回合到底有没有发布、订阅者有没有响应?
6.1 事件日志与快照转储
我给自己加了一个调试后门:事件总线可以开启录制模式,把所有事件按时间戳记录成列表,回合结束后可以导出JSON。这在复现“为什么某关的支线没触发”“为什么成就少算了一次击杀”时是神器。
public sealed class EventBusDebugDump { public List<string> Entries = new List<string>(); public void Record<T>(T evt) where T : struct { Entries.Add($"{Time.frameCount}: {typeof(T).Name} {JsonUtility.ToJson(evt)}"); } }实际排查问题时的路径通常是这样的:让测试把出问题的存档和事件日志一起交回来,然后我在日志里看到某一个关键事件根本就没发布,或者发布的时机不对。如果没这个日志,只能盲猜,那在SRPG这种大状态机游戏里等于大海捞针。
6.2 事件命名与消息体设计规范
我踩了几个误区后定的规则:事件名一律采用“发生的过去时态”,动词粗粒度,不加系统后缀。比如UnitKilledEvent,不要叫UnitKilledForAchievementEvent,因为事件本身是客观事实,与谁订阅无关。另外事件体要尽量携带“当时能拿到的全部上下文”,比如击杀事件里的受害方和击杀方ID、是否反击、是否是玩家回合。宁可消息体大一点,也别让订阅者反过来去查战斗记录。
初期我的事件字段起得极其发散,服务端同学接手时长叹……后来我们统一成前面代码里的写法,加注释标明字段含义,维护性大幅提升。
6.3 当事件总线开始“变大”时的清醒控制
最后一点,也是我作为博主特别想提醒的:事件总线很好用,但在SRPG这种项目里,它需要伴随清晰的边界意识。如果什么玩意儿都往事件总线上塞——连摄像机移动也发事件、连面板打开也发事件——那你等于把事件总线变成了一个全局变量垃圾场。出现问题时,调试难度反而比原来更高。
我的经验是,事件总线只承载跨系统、有明确业务含义的状态通报。系统内部的高频局部交互,保留它们自己的引用关系即可。举例来说:战斗管理器内部的伤害计算顺序,不应该通过事件总线来编排;但“这回合玩家是否使用过治疗道具”这种会跨到成就、支线、教学系统的信息,才值得广播。
通常我最怕的是,有人为了“解耦”把任务状态机和战斗结算也拆成事件监听,两个系统会互相等待对方的后续事件——这叫事件饿环。如果你发现系统里出现了“我先发布等待你处理,你又等我处理”的状态,那就说明事件总线用得太猛、太脏了,正确的做法永远是更直接地调用,而不是再造一个间接层。
这个项目做完一轮之后,我对全局事件总线的整体感受是:它适合用来扩大“系统协作的带宽”,但它不会自动帮你把逻辑变少。真正让代码清爽的,仍然是事件设计得有语义、生命周期管得干净、时序边界合理。这套思路不仅在成就和支线上有用,我后来的教学引导、图鉴收集、每日任务全都挂在同一条总线上——因为SRPG这个品类的核心,就是在一张棋盘上不断制造“发生了什么事”的时刻,你只需要让这些时刻被真正关心它们的地方听到,就够了。