下载 App
06/2033 浏览开发交流

从“上帝类”到“事件驱动”: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 变成纯粹的流程控制:
lua
function 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. 攻击流程的细化控制

攻击流程最容易出现顺序依赖,我们将其拆分为多个阶段:
lua
function 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 = 100PRIORITY_BUFF = 80PRIORITY_DEBUFF = 60
  • 相同优先级:按注册顺序执行(通过 id 排序)。
  • 阶段拆分:将强依赖的逻辑放入不同事件(如 BEFORE_ATTACK 先于 AFTER_ATTACK),从机制上保证顺序。

5. 日志的可视化

为了让主流程能完整展示执行过程,每个技能回调都通过 GameLogic.AddLog(game, msg) 写入同一个日志队列。主流程只需在适当位置打印分割线:
lua
GameLogic.AddLog(game, "=== 开始回合结算 ===") globalEventSystem:emit("ROUND_START", data) GameLogic.AddLog(game, "=== 回合结算完毕 ===")
最终日志按时间顺序呈现,开发者可以清晰看到每个步骤。

四、收益与注意事项

收获

  • 主流程代码量减少 60%,只保留规则骨架。
  • 新增卡牌零侵入:只需在 abilities/ 目录下新增文件并注册监听,无需修改核心文件。
  • 测试友好:可单独模拟事件,独立测试技能逻辑。
  • 协作便利:不同技能由不同开发者维护,冲突大大减少。

注意事项

  • 避免循环事件:回调中不要再 emit 同一事件,可用计数器防护。
  • 数据传递一致:所有事件数据必须包含 game 引用,以便操作共享状态。
  • 错误隔离:用 pcall 包裹回调,防止单个技能崩溃阻塞全部。
  • 不要过度拆分:事件粒度以“流程阶段”为准,不要为每个细微动作都定义事件。

五、总结

事件驱动不是银弹,但在卡牌游戏这种“大量独立效果与少数流程节点交互”的场景下,它提供了优雅的解耦方案。通过将“何时做”与“做什么”分离,我们让主流程回归本质,让卡牌技能变得可插拔、可测试、可维护。
如果你的游戏也正陷入“God Class”泥潭,不妨试试这套事件驱动重构思路。它不仅能拯救你的代码,更能让你在后续开发中感受到“新增卡牌如搭积木”的畅快体验。
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
66 赞
20 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
122 赞
44 回复
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
2 回复
感谢官方大大真的让我通过了截图
感谢官方大大真的让我通过了
我抱着试一试的心态,用了这个聚光灯活动,活动免费白嫖的10万积分,做我不擅长的叙事游戏,我跟官方审核说清楚原因,升级开发者计划二用来做新游戏,没想到真的通过,牛牛牛( ఠൠఠ )ノ点赞点赞👍,效率真高,不过我记得开发者计划二以前不是有每日积分的吗,怎么只剩下周期积分了[表情_疑惑][表情_疑惑]
15 赞
16 回复
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
27 赞
6 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
与塔啦啦携手:没有编辑器的关卡编辑截图
与塔啦啦携手:没有编辑器的关卡编辑
做《觅光》的时候我遇到了一个很现实的问题:游戏需要多个关卡,但taptap制造只有对话框,没有没有TiledMap 之类的手动关卡编辑器,怎么办呢?后来我摸索出了一套"文档驱动"的关卡制作流程——我在 Markdown 文件里画 ASCII 网格当地图,写个表格摆放火把、敌人、钥匙,然后让 AI 把这些文档转成游戏能跑的代码。改关卡的时候也不用碰代码,直接说"把第三关的torch_01 移到 (2
精华
8 赞
3 回复
一个类LOL带有合成树的装备商店系统文档截图
一个类LOL带有合成树的装备商店系统文档
可复用于其他需要类似功能的项目,减少沟通的积分消耗,分享给大家: 1. 整体架构equip_defs.lua 数据层 — 装备定义、合成配方、价格、属性、工具函数 equip_shop.lua UI 层 — NanoVG 渲染、交互逻辑、合成树可视化 2.2 装备分类类型值名称说明1武器—2衣甲—3头盔—4腰带—5靴子—6项链—7戒指—8饰品—2.3 阶级体系阶级颜色说明T1灰白 (180,180
精华
6 赞
2 回复
01:27
《大二暑假,我用AI做了一款属于自己的游戏》截图
《大二暑假,我用AI做了一款属于自己的游戏》
本人自制游戏现已加入taptap封闭测试(仅适配安卓机型)欢迎大家前来尝试(八月十号可以正式开始游玩) 如果有建议或者bug反馈可以加入qq群928501443 #发现好游戏 #游戏日常 #浅评一下 #今天游戏圈发生了啥
3 赞
【UI教程】如何将已有的效果图应用为实际的UI界面截图
【UI教程】如何将已有的效果图应用为实际的UI界面
在经过了一些折腾和比较折磨的尝试后,我总算是摸清楚了嗒啦啦怎么把UI效果图转换为实际游戏中的UI组件,今天就给大家带来这部分的经验分享。 tips:1.本篇教程需要各位掌握最基本的ps使用,具体的操作部分我会大致讲解,但仍需各位自行学习ps的基本操作,部分可以由ai代劳的环节我会注明。 2.本篇教程需要在以我前两篇教程作为前置环节,请在阅读这两篇前置教程后再进行本篇教程内容的学习 在按照前
10 赞
4 回复