ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SRPG项目实战:用全局事件总线解耦成就与支线系统

SRPG项目实战:用全局事件总线解耦成就与支线系统 最近在整理SRPG项目的功能收尾遇到一个在战棋类游戏里非常典型的问题战斗、队伍、关卡脚本这些核心模块已经稳定运行了几个月但一旦开始接入成就系统和支线系统就发现处处都要往原本干净的战斗流程里塞代码。击杀要回传数值层、对话选择要通知任务状态机、关卡结束要告诉成就有没有满足条件……耦合点越来越多每次调整一个系统都要连带检查另外两三个系统会不会被误伤。这种时候全局事件总线就成了最自然的解法。我在这个项目里用了一套轻量级的事件总线把成就系统、支线系统彻底从战斗主流程中拆了出去。这篇就按开发日志的形式记录我从设计思路到落地实现的完整过程包括几个SRPG特有的时序坑希望能给同样在做战棋类游戏、或者任何带有“大量离散决策”玩法的朋友一点参考。1. 先搞清楚SRPG为什么绕不开事件驱动很多人在刚开始写SRPG战斗时容易把游戏做成一个“整块”的流程战斗管理器打完一场就开始清点战利品然后检查经验值接着推进剧情再通知UI刷新。前期系统少的时候这样写没问题但成就、支线、任务目标、教学内容这些系统一多问题就全来了。SRPG和实时动作游戏有个本质差异它的几乎所有核心玩点都是以离散决策的方式发生的。玩家一次移动是一次决策一次攻击是一次决策一次技能释放是一次决策选择某个对话分支也是一次决策。这种“棋盘上的每一手”天然就是事件源。你在代码里划一条清晰的边界说“这里发生了一件事谁关心谁自己来听”而不是“这件事发生后要把数据同步给所有相关方”就是事件驱动设计的核心。我后来用了一个很通俗的类比给组里新人解释没有事件总线的系统像办公室里的电梯——A部门要通知B、C、D部门就必须挨个敲门有事件总线的系统像公司广播——谁关心哪条消息自己去听。在SRPG里战斗系统只需要把“单位阵亡”“回合结束”“关卡胜利”这些广播出去就够了至于成就要统计击杀、支线要判断NPC是否死亡、教学要弹提示都跟战斗系统没有关系。实际需求里我最先遇到的痛点来自成就系统。那头摆了一堆效果各异的成就某角色累计造成十万伤害、一次攻击暴击三连、某个回合内未损失任何单位且过关、使用特定角色击败特定Boss……这些判定条件散落在一场战斗的各个角落。如果不用事件总线成就系统就只能向战斗系统暴露大量查询接口或者反过来战斗系统在几百个关键节点写死“if成就条件then提交”。无论哪种都等于把成就的复杂度摊给别人。支线系统的情形也类似。SRPG支线任务往往不是“到达区域A→打怪→回城交付”这么简单而是“对话触发→加入一名临时角色→若干场战斗中保护他→特定关卡结束后离开”。这些状态机的推进点几乎全部埋在其他系统的执行过程中。所以结论很直接在SRPG这个类型里全局事件总线不是锦上添花是系统数量过线后的必需品。它不是框架要求你做的“最佳实践”而是你面对一堆跨系统同步需求时唯一能把复杂度控制住的办法。2. 事件总线的最小实现从委托集合到泛型消息这节直接给代码。我先说明我是怎么选型的避免有人上来就用很重的解决方案。目前C#项目里常见的事件总线写法五花八门有基于EventHandlerT的、有基于DictionaryType, Delegate的、有基于反射扫方法的。我的要求只有三个不引入额外依赖、支持泛型消息、性能不拖回合帧后腿。于是我最先落地的是一版朴素实现核心结构是一个静态静态容器保存“事件类型”到“订阅者集合”的映射。每个订阅者是一个包含具体处理方法的包装。代码大致长这样public interface IGameEventBus { void PublishT(T evt) where T : struct; IDisposable SubscribeT(ActionT handler) where T : struct; } public sealed class GameEventBus : IGameEventBus { private readonly DictionaryType, ListSubscription _subs new DictionaryType, ListSubscription(); private struct Subscription { public Actionobject Handler; public object Owner; } public IDisposable SubscribeT(ActionT handler) where T : struct { var type typeof(T); if (!_subs.TryGetValue(type, out var list)) { list new ListSubscription(); _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 PublishT(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内存。而且我后续为了更灵活把参数对象独立定义事件本身是纯数据结构这样调试时能序列化记录。为什么存Actionobject而不是直接存ActionT这是为了在这种非泛型容器里统一处理。实际只会损失一层类型转换的开销对单帧几十次的事件广播来说可以忽略但换来的是容器代码极其简单。为什么需要快照事件在广播过程中订阅者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; // trueplayer, 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 ListIDisposable(); // 监听本关需要的事件 _subDisposers.Add(_bus.SubscribeUnitKilledEvent(OnUnitKilled)); _subDisposers.Add(_bus.SubscribeTurnEndedEvent(OnTurnEnded)); _subDisposers.Add(_bus.SubscribeSkillUsedEvent(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() { ListenAreaReachedEvent(OnAreaReached); ListenUnitDiedEvent(OnUnitDied); ListenDialogueEndedEvent(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 Liststring Entries new Liststring(); public void RecordT(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这个品类的核心就是在一张棋盘上不断制造“发生了什么事”的时刻你只需要让这些时刻被真正关心它们的地方听到就够了。
返回列表