06/2029 浏览开发心得
从“上帝类”到“事件驱动”:Lua回合制卡牌对战游戏架构重构实战
在卡牌游戏开发中,随着卡牌数量和机制的增长,核心逻辑文件往往会迅速膨胀,变成难以维护的“上帝类”。本文将分享我们如何通过事件驱动架构(Event-Driven Architecture)对一款Lua卡牌游戏的核心模块进行重构,实现高度解耦、易于扩展的代码结构。
一、痛点:当“主流程”变成“万能处理器”
我们最初的 GameLogic.lua 负责一切:回合管理、出牌、攻击、光环、战吼、亡语、专属技……所有卡牌效果都通过 if card.template.abilityFn == "xxx" then ... end 硬编码在主流程中。这导致:
- 代码量爆炸:单文件超过 2000 行,每次新增卡牌都要修改核心文件。
- 变更风险高:修改一个技能可能意外影响其他逻辑。
- 测试困难:技能与流程深度耦合,无法单独验证。
- 协作冲突:多人开发时频繁合并冲突。
以下是一个典型片段(来自 GameLogic.StartTurn):
lua-- 处理士气为0 GameLogic.ProcessMoraleZero(game, playerIdx) -- 张良光环 for _, card in ipairs(player.field) do if card.template.abilityFn == "zhangliang_aura" then player.provisions = player.provisions + 1 break end end -- 鲁肃光环 for _, card in ipairs(player.field) do if card.template.abilityFn == "lusu_aura" then GameLogic.DrawCard(game, playerIdx) break end end -- 墓碑递减... -- 摸牌...
每增加一个光环,就要在 StartTurn 中增加一段 for 循环,完全违背了开闭原则。
二、药方:观察者模式 + 事件系统
我们引入事件驱动架构,核心思想是:主流程只负责在特定节点“发布”事件,所有具体效果通过“订阅”事件来响应。这样主流程只关心“何时做”,而不关心“做什么”。
1. 事件系统的实现(Lua)
我们设计了一个轻量级事件分发器,支持优先级排序和错误隔离:
lua-- EventSystem.lua local EventSystem = {} EventSystem.__index = EventSystem function EventSystem:new() local instance = setmetatable({}, EventSystem) instance._listeners = {} -- event -> {func, priority, target, id} instance._id_counter = 0 return instance end function EventSystem:on(event, func, params) params = params or {} local priority = params.priority or 0 local target = params.target self._id_counter = self._id_counter + 1 local listener = { func = func, target = target, priority = priority, id = self._id_counter } self._listeners[event] = self._listeners[event] or {} table.insert(self._listeners[event], listener) table.sort(self._listeners[event], function(a, b) if a.priority == b.priority then return a.id < b.id end return a.priority > b.priority end) end function EventSystem:emit(event, data) local listeners = self._listeners[event] if not listeners then return end -- 快照防止回调中修改列表 for _, cb in ipairs(listeners) do local ok, err = pcall(cb.func, cb.target, data) if not ok then print(string.format("[Event Error] %s: %s", event, err)) if data and data.game and data.game.AddLog then data.game:AddLog("⚠️ 技能执行异常: " .. tostring(err)) end end end end return EventSystem
2. 定义事件节点
我们将游戏流程拆解为多个细粒度事件,方便技能精确挂钩:
| 事件名称 | 触发时机 | 传递数据 | |----------|----------|----------| | ROUND_START | 回合开始时 | {game, playerIdx, turnNumber} | | ROUND_END | 回合结束时 | {game, playerIdx} | | BEFORE_ATTACK | 攻击结算前(可修改/阻止) | {game, attacker, target, damage} | | AFTER_ATTACK | 攻击结算后 | {game, attacker, target, damage} | | CARD_PLAYED | 卡牌被打出时 | {game, playerIdx, card, handIndex} | | CARD_DEATH | 卡牌死亡时 | {game, playerIdx, card, fieldIndex} |
三、重构实战:从硬编码到模块化
1. 主流程精简
GameLogic.StartTurn 变成纯粹的流程控制:
luafunction GameLogic.StartTurn(game, playerIdx) -- 基础状态重置 game.currentPlayer = playerIdx game.turnNumber = game.turnNumber + 1 -- 重置攻击状态、粮草等... -- 派发回合开始事件(所有光环、效果在此处理) local eventData = { game = game, playerIdx = playerIdx, turnNumber = game.turnNumber } globalEventSystem:emit("ROUND_START", eventData) -- 统一摸牌(也可以作为事件的一部分) GameLogic.DrawCard(game, playerIdx) -- 生成专属技(可事件化) GameLogic.GenerateExclusiveSkills(game, playerIdx) -- 胜负判定... if GameLogic.CheckDefeat(game, playerIdx) then return end if game.onStateChange then game.onStateChange() end end
2. 卡牌效果独立为监听器
每个光环/技能放在单独文件中,例如张良光环:
lua-- abilities/auras/zhangliang.lua local GameLogic = require("GameLogic") local M = {} function M.onRoundStart(data) local game = data.game local player = game.players[data.playerIdx] for _, card in ipairs(player.field) do if card.template.abilityFn == "zhangliang_aura" then player.provisions = math.min(player.provisions + 1, GameLogic.MAX_PROVISIONS) GameLogic.AddLog(game, "🧠 张良光环:粮草+1") break end end end -- 注册监听,优先级80(高于默认) globalEventSystem:on("ROUND_START", M.onRoundStart, { priority = 80 }) return M
鲁肃光环、士气归零处理、墓碑递减都可以采用相同模式,各自独立,互不干扰。
3. 攻击流程的细化控制
攻击流程最容易出现顺序依赖,我们将其拆分为多个阶段:
luafunction GameLogic.Attack(game, playerIdx, attIdx, tgtIdx) local attacker = player.field[attIdx] local target = getTarget(...) local data = { game = game, attacker = attacker, target = target, damage = 0, cancelled = false } -- 1. 攻击前(可取消) globalEventSystem:emit("BEFORE_ATTACK", data) if data.cancelled then return false, "攻击被取消" end -- 2. 计算基础伤害 data.damage = GameLogic.CalcBaseDamage(attacker, target) -- 3. 伤害修正(增伤/减伤/免疫) globalEventSystem:emit("CALCULATE_DAMAGE", data) -- 4. 执行伤害 applyDamage(target, data.damage) -- 5. 攻击后(触发各种被动) globalEventSystem:emit("AFTER_ATTACK", data) -- 6. 死亡检查及亡语 if target.troops <= 0 then globalEventSystem:emit("BEFORE_DEATH", { game = game, card = target }) removeFromField(target) globalEventSystem:emit("DEATHRATTLE", { game = game, card = target }) end end
这样:
- 彭越(免反击) 监听 CALCULATE_DAMAGE,将反击伤害设为0。
- 陈汤(虽远必诛) 监听 AFTER_ATTACK,在攻击后额外反击。
- 霍去病(封狼居胥) 监听 BEFORE_DEATH,生成墓碑。
- 樊哙(排闼直入) 监听 AFTER_ATTACK,为我方其他卡牌加士气。
所有技能各司其职,主流程完全不知道它们的存在。
4. 执行顺序如何保证?
- 优先级数字:定义全局常量,如 PRIORITY_AURA = 100、PRIORITY_BUFF = 80、PRIORITY_DEBUFF = 60。
- 相同优先级:按注册顺序执行(通过 id 排序)。
- 阶段拆分:将强依赖的逻辑放入不同事件(如 BEFORE_ATTACK 先于 AFTER_ATTACK),从机制上保证顺序。
5. 日志的可视化
为了让主流程能完整展示执行过程,每个技能回调都通过 GameLogic.AddLog(game, msg) 写入同一个日志队列。主流程只需在适当位置打印分割线:
luaGameLogic.AddLog(game, "=== 开始回合结算 ===") globalEventSystem:emit("ROUND_START", data) GameLogic.AddLog(game, "=== 回合结算完毕 ===")
最终日志按时间顺序呈现,开发者可以清晰看到每个步骤。
四、收益与注意事项
收获
- 主流程代码量减少 60%,只保留规则骨架。
- 新增卡牌零侵入:只需在 abilities/ 目录下新增文件并注册监听,无需修改核心文件。
- 测试友好:可单独模拟事件,独立测试技能逻辑。
- 协作便利:不同技能由不同开发者维护,冲突大大减少。
注意事项
- 避免循环事件:回调中不要再 emit 同一事件,可用计数器防护。
- 数据传递一致:所有事件数据必须包含 game 引用,以便操作共享状态。
- 错误隔离:用 pcall 包裹回调,防止单个技能崩溃阻塞全部。
- 不要过度拆分:事件粒度以“流程阶段”为准,不要为每个细微动作都定义事件。
五、总结
事件驱动不是银弹,但在卡牌游戏这种“大量独立效果与少数流程节点交互”的场景下,它提供了优雅的解耦方案。通过将“何时做”与“做什么”分离,我们让主流程回归本质,让卡牌技能变得可插拔、可测试、可维护。
如果你的游戏也正陷入“God Class”泥潭,不妨试试这套事件驱动重构思路。它不仅能拯救你的代码,更能让你在后续开发中感受到“新增卡牌如搭积木”的畅快体验。


