资讯动态

Unity3D AVG卡牌游戏开发实战:数据驱动对话与回合制战斗

发布时间:2026/10/2 4:33:26 来源:尧图企业网站定制
去年年初我用Unity3D做了一款名为《火炬低语》的AVG卡牌游戏Demo。玩法是文字冒险加爬塔卡牌战斗玩家在暗黑塔楼里一层一层探索碰到事件触发对话靠出牌解决战斗卡牌构筑随冒险推进不断变强。整个过程从原型到可玩版本大概花了四周中间踩了不少坑今天把这套设计的思考和实现细节整理出来希望能给想用Unity3D做AVG卡牌的同学省点弯路。无论你是刚入门Unity3D的学生还是想尝试独立开发的小团队这篇内容讲的都是思路和代码级别的方法可以直接拿走用。1. 立项与整体设计AVG和卡牌怎么才能揉在一起1.1 为什么选择Unity3D而不是其他方案做AVG卡牌游戏很多人第一反应是问直接拿RenPy或者Web引擎做文字冒险不就行了吗纯AVG确实可以用RenPy快速搞定但一旦涉及卡牌战斗、数值成长、回合制交互这些工具就不够用了。卡牌游戏需要的是一套完整的游戏框架手牌状态管理、目标选择、效果结算、动画回调、UI层级切换这些在Unity3D里都有成熟的组件和生态支撑C#的开发效率也高第三方插件随手就能装。Unity3D还有一个很实际的优势编辑器扩展。卡牌配置、对话预览、关卡流程都可以做成编辑器工具策划和美术能够直接参与内容制作程序不需要反复改代码来调数值。我做《火炬低语》时把卡牌数据做成ScriptableObject策划在Inspector里直接拖资源配效果反馈非常及时。这一点在独立开发里尤其重要程序、策划、美术经常是同一个人能减少上下文切换就是实打实的效率。1.2 核心玩法循环与模块划分《火炬低语》的整体循环是进入房间、探索并触发剧情、对话选择、进入战斗、获得卡牌或遗物、再选下一个房间。这个结构是典型的“叙事驱动战斗战斗驱动成长”的三明治式设计。对话不只是交代背景它还会影响战斗比如选择某个选项后获得一张特殊卡牌或者敌人属性发生变化战斗的结果也会解锁后续对话分支两个系统必须互相借力而不是各玩各的。为了实现这种耦合我把工程按六个模块划分Core负责事件总线和全局管理器Data负责ScriptableObject和Json的加载Dialog负责对话播放器Battle负责回合状态机、手牌、敌人AIUI负责HUD与面板管理SceneFlow负责场景切换和过渡动画。模块之间不直接引用全部通过事件总线通信。比如对话系统播完某段剧情后抛出一个GameEvent战斗系统监听这个事件就知道该进场了。这个解耦设计在后期帮了大忙加新功能几乎不动旧代码。1.3 技术选型UGUI、URP和AddressablesUI框架我选了UGUI而不是UI Toolkit。原因是UGUI对实时交互的支持非常成熟手牌拖拽、卡牌缩放、层级排序、Button与EventSystem的配合都很稳定UI Toolkit更适合做编辑器窗口拿来做运行时UI要处理的东西反而更多。渲染管线用了URP主要是为了2D光源和后处理AVG游戏需要大量暗色场景有光晕和色调控制之后氛围感完全不一样。动画插件用DOTween文本用TextMeshPro资源管理用Addressables。DOTween用来做卡牌移动、立绘呼吸、镜头轻微拉近这些表现层动作写起来简单效果好。TextMeshPro是Unity原生支持的老牌方案文字清晰度和排版控制比旧版Text强很多。Addressables不只是为了减小首包更重要的是支持按房间、按章节分包加载后续接热更也顺理成章。这些选型不是花架子都是实测落地后留下的方案。2. 数据驱动把剧情和卡牌从代码里拆出来2.1 卡牌为什么用ScriptableObject配置直接定义一个Card类然后填字段不行吗可以但当卡牌数量超过三四十张后每改一个数值都要改代码重编译策划和美术根本没法插手。ScriptableObject本质上就是一个躺在Assets目录里的数据资产可以在Inspector里直接配置运行时按引用加载不需要解析过程也不用担心格式错误。我的做法是把卡牌拆成两层CardData负责静态配置CardInstance负责一张卡在战斗中的临时状态。这个概念是新手最容易忽略的地方。如果只有一份CardData战斗中给某张卡临时加了攻击力打完这场战斗这个改动会永久保存下次再抽到这张卡数值就不对了。必须让每张手牌都持有自己独立的CardInstance里面记录当前费用、当前攻击力、Buff标记等临时数据CardData只是它的初始值来源。做完这个区分后面做遗物、强化、复制卡牌都会轻松很多。下面是CardData的基础结构字段基本覆盖了一张卡需要的所有静态信息public class CardData : ScriptableObject { public string cardId; public string cardName; [TextArea] public string description; public int baseCost; public CardType cardType; // 攻击 / 技能 / 能力 public TargetType targetType; // 无目标 / 敌方单体 / 所有敌人 public Sprite portrait; public AudioClip playSound; public ListCardEffectBase effects; }这里CardEffectBase是所有卡牌效果的基类AttackEffect、DrawEffect、ApplyBuffEffect都继承它。运行时遍历effects列表逐个调用Apply方法即可。这个列表在Inspector里可以直接拖进去配置很直观。不过要注意一点ScriptableObject里装复杂类型比如神类列表时老版本Unity的Inspector刷新可能异常建议升级到较新的U20版本或者给卡牌编辑器写一个自定义绘制不然多人协作时会出现“明明改了配置重新打开却还是旧数据”的诡异问题。每张卡配好后还有一个校验工具。我写了一个菜单脚本扫描整个卡牌目录检查cardId是否重复、effects是否为空、description是否为空。这个步骤很值得做项目配置多了之后靠人工肉眼查根本查不完一个空的effect列表在战斗里直接导致卡牌没有任何效果玩家完全摸不着头脑。2.2 剧情对话的Json结构设计剧情数据我用的是Json文件而不是写死在代码里。AVG对话天然是树状结构单线推进加少量分支直接用嵌套的Json表达非常顺手。我为《火炬低语》设计了一个很简单的对话节点结构{ id: room_01_intro, speaker: 守卫者, portrait: portraits_guardian, text: 火炬即将熄灭但你来了。, choices: [ { text: 接过火把, next: room_01_accept, condition: has_torch, effects: [add_torch] }, { text: 沉默不语, next: room_01_silent } ] }每个节点就是一个对话片段choices是分支出口condition表示进入分支需要满足的条件effects表示节点触发后要执行的游戏事件。解析时用一个Dictionary把所有节点缓存起来通过当前节点id拿到下一个id跳转非常快。用Json还有一个好处随时可以用任意文本编辑器修改Git能够逐字对比变更记录。缺点是不如Excel直观做数值整合时稍微费劲但对文案来说足够友好。如果你更习惯Excel也可以做成Excel表再导出Json只不过多一层工具链对单人开发来说有点重。2.3 加载方式与热更预留开发初期我用Resources.Load加载所有Json和卡牌数据简单直接。但项目后期主力切换到Addressables原因是美术资源实在太多了角色立绘、场景背景、卡图、音效加起来轻松超过100MB。Resources目录不仅拖慢首包加载还会长时间占用内存。Addressables可以按房间按章节分包加载玩家进入某一层时才下载对应资源体验会好很多。为了避免频繁打AB包耽误编辑器调试我加了一段条件编译开发期走AssetDatabase直接加载真机上走Addressables路径public static T LoadConfigT(string key) where T : Object { #if UNITY_EDITOR string[] guids UnityEditor.AssetDatabase.FindAssets(key); if (guids.Length 0) return UnityEditor.AssetDatabase.LoadAssetAtPathT( UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0])); #endif return Addressables.LoadAssetAsyncT(key).WaitForCompletion(); }所有资源的key统一成“资源ID”卡牌数据里的portrait字段和Json里的portrait字段都指向这个key资源层做统一的LoadSprite、LoadAudio封装后续所有系统都不关心资源到底是本地还是远程。这个抽象写一次就能长久受益后面接多语言、接活动包都只需要加一个解析层。3. 核心实现对话播放器、回合制战斗与手牌交互怎么落地3.1 AVG对话播放器的实现细节对话播放器看起来简单实际上要处理的状态特别多打字机显示中、等待玩家点击、显示选项分支、触发剧情事件、角色立绘切换。我写了一个DialogPlayer组件内部维护一个状态机核心的打字机效果用协程实现IEnumerator TypeText(string fullText) { textMeshPro.text ; for (int i 0; i fullText.Length; i) { textMeshPro.text fullText[i]; yield return new WaitForSecondsRealtime(typeSpeed); } state DialogState.WaitForClick; }这里有两个细节容易翻车。第一不要用Time.deltaTime或者依赖Time.timeScale玩家暂停游戏或者切后台再回来时timeScale可能被改变打字机会凭空卡住用WaitForSecondsRealtime更稳。第二textMeshPro.text 这种写法在长文本里频繁分配内存会生成大量GC如果你对话文本经常接近两千字建议改成StringBuilder拼接或者用TextMeshPro的maxVisibleCharacters逐字增减后者性能最好。点击检测也不能直接监听全局鼠标事件。我一开始用Input.GetMouseButtonDown监听结果发现对话中的按钮点击也被当成“下一句”玩家选分支时经常误跳。解决办法是所有可点击元素都挂Button或者实现IPointerClickHandler对话输入层只处理UI外的事件并且在等待状态时检查EventSystem.current.currentSelectedGameObject确认没有按下任何按钮才推进到下一句。3.2 回合制战斗的状态机设计卡牌战斗的核心是回合循环玩家回合开始、抽牌、出牌、结算、敌人回合、敌人行动、回到玩家回合。我用了协程状态机而不是Update轮询原因是回合流程天然是线性的用Update加标志位很容易把逻辑搅成一锅粥协程可以按顺序直接写出来IEnumerator BattleLoop() { while (!battleOver) { yield return StartCoroutine(PlayerTurn()); yield return StartCoroutine(EnemyTurn()); } }PlayerTurn里做这几件事重置玩家能量、清除回合结束类状态、从抽牌堆抽固定张数、把手牌摆开、等待玩家完成出牌或主动结束回合。玩家出牌时我先遍历CardInstance里的效果列表逐个执行效果比如造成伤害、抽牌、附加Buff等所有效果动画伤害数字跳动、卡牌飞出等播放完成才把手牌移入弃牌堆。这里有个关键点效果结算必须等动画播完再收牌不等的话玩家会看到“卡已经消失了伤害数字才跳出来”体验非常割裂。敌人AI用的是最简单的权重随机每个敌人持有一个意向行为列表每回合根据权重选一个执行。AVG卡牌里敌人数量不多不需要复杂的行为树权重表调起来最顺手。敌人行动动画播放完成后检查死亡状态再切回玩家回合闭环完成。3.3 手牌的拖拽、点击与目标选择手牌交互是卡牌游戏体验的灵魂。每张CardView握有CardData和CardInstance两个引用展示数据一律从CardInstance读取这样临时Buff和费用修改才能及时反映到界面上。手牌初始排列采用扇形布局我写了一个手牌布局类用DOTween做DOMove和DORotate平滑移动抽牌、出牌、调整顺序都有插值动画。点击出牌和拖拽出牌我同时实现了。点击出牌的逻辑是鼠标进入卡牌区域时上移并放大离开时恢复点击卡牌后根据TargetType决定是否显示敌人目标指示器玩家点击目标后执行效果。拖拽出牌的方案是记录PointerDown时的坐标移动距离超过阈值后判定为拖拽卡牌跟随鼠标移动放到目标上松开后结算。最麻烦的是UGUI的Drag和Click互相冲突我通过在PointerUp时判断位移是否超过20像素来区分点击和拖拽实测稳定。目标识别的方案有两个一种是给敌人挂Collider2D用Camera.ScreenPointToRay做物理射线检测另一种是UI法给敌人头顶挂目标Slot用EventSystem射线检测。我用了后者因为敌人头顶的状态图标和血条本来就是UI统一走UI检测逻辑更少。需要注意拖拽中的卡牌会挡住敌人目标拖拽时要设置canvasGroup.blocksRaycasts为false让射线能穿透卡牌点到底下的目标。3.4 画面表现与场景切换AVG卡牌非常吃氛围感。我用URP管线加2D光照场景里放了一个点光源和几个暗部色块营造塔楼里火把照亮的氛围人物立绘加了一个简单的呼吸动画用DOTween控制材质的透明度变化对话时镜头轻微拉近增强对话临场感。战斗背景是一张静态场景贴图加两层视差移动的装饰物血量变化时屏幕边缘闪红光提醒玩家。场景流转我做了统一的全屏渐变过渡从房间探索进战斗、从战斗回探索都走一个FadeCanvas控制黑场淡入淡出。事件驱动部分用事件总线比如对话系统抛出EnterBattle事件SceneFlow接收后播放过渡动画再加载战斗场景。这套方案让切换流程永远是“事件发布、过渡播放、场景加载、初始化数据”不会出现“界面刚跳出来数据还没准备好”的竞态问题。4. 实操踩坑实录对话乱码、点击穿透和状态残留怎么排查4.1 Json解析的BOM、编码和中文转义问题开发头两天就撞见第一个坑。用VS新建的Json文件带了BOM头在真机上解析总是报错编辑器里却正常。后来定位到是BOM的问题因为部分平台的TextAsset会保留BOM字节JsonUtility解析时直接炸掉。解决办法是批量清理BOM或者统一走导出工具生成无BOM的UTF-8文件不要手动在编辑器里新建Json。JsonUtility还带一个坑不支持Dictionary嵌套你想把剧情节点缓存成字典的话直接用JsonUtility解析会得到空集合。我直接换了Newtonsoft.Json第三方库在移动端打包稳定解析灵活没有理由自己写解析器。Json里的中文默认不转义也能正常显示但你如果用了在线Json编辑器它会主动把中文转成\uXXXX这也没问题解析端支持就是肉眼排查文本时不太友好。4.2 手牌点击穿透和事件系统冲突《火炬低语》同时有对话界面和战斗界面两个系统都要监听鼠标点击冲突就来了。某天测试发现对话里点了角色立绘下面的按钮却被触发了。原因是我在全局输入控制器里监听Input.GetMouseButtonDown这个监听完全不理会UGUI层把事件拦截了没有。统一方案是所有可点击元素都挂Button组件或者实现IPointerClickHandler接口由EventSystem统一派发。全局输入层只处理UI外的事件比如场景中点击地面移动。类似问题在卡牌拖拽时也出现过卡牌拖到目标区域射线检测先命中了卡牌自身底下的敌人反而点不到。我在目标区域设了一个专门的射线层卡牌拖拽时把自身CanvasGroup的blocksRaycasts设成false射线就能穿过卡牌直达目标了。4.3 状态效果的过期与死亡清理Buff和Debuff是卡牌游戏的标配状态系统也是最容易出Bug的地方。我最初的版本用一个字典存储层数每回合结束时遍历所有敌人每个状态减层数。结果发现敌人死了之后下一回合遍历删字典元素直接抛异常而且被冻结的敌人行动代码还会继续跑。后来我把状态对象提成StatusInstance类记录剩余回合数、来源卡牌、每回合触发回调销毁时统一调用OnExpire。遍历时先收集过期状态再统一移除绝不在遍历过程中直接删除集合元素。这个习惯不只在状态系统里管用任何集合遍历场景都通用写代码时心里默念一遍能省不少调试时间。4.4 对象池复用引发的显示残留手牌、伤害数字、粒子特效都做了对象池对象池最大的坑是复用对象时没把上一个状态清干净。伤害数字回池后再激活显示数字还是上一次的卡牌出牌后回池再抽出来时还带着旧的Buff图标。这些bug排查起来很烦因为不是必现只在池子翻转过才有问题。最终方案是定一条规范所有进池对象在Deactivate时重置所有显示状态在Activate时从数据源重新拉取字段不要只隐藏就不管了。这个原则写进代码规范之后这类问题几乎绝迹。我把这四类典型问题整理成速查表遇到类似症状第一眼就能定位症状可能原因处理办法Json解析报错BOM头或编码问题统一无BOM的UTF-8文件点按钮触发对话下一句全局鼠标监听与UGUI冲突改用EventSystem的IPointerClickHandler拖拽卡牌点不到敌人卡牌自身挡住射线拖拽时设blocksRaycasts为false敌人死亡后报错遍历中删集合元素先收集再统一移除伤害数字残留旧数值对象池复用未重置Deactivate时重置所有显示状态最后说一点个人体会。做完这个项目我感觉AVG卡牌和纯卡牌、纯AVG最大的差别在于它需要两个系统互相借力。对话系统不只是播文本要能触发战斗、影响卡牌战斗系统也不只是数值碰撞要能承载叙事节奏。所以项目真正的核心从来不是某个单点技术而是数据结构和系统间的接口设计。我把剧情节点做成事件触发器的格式让对话系统和战斗系统通过事件总线解耦这个决定带来的收益比任何优化都大。如果以后要扩展可以往Roguelike路线走加随机事件池、商店节点、大地图选关也可以做多语言把Json文本拆到语言目录用同一套节点id加载任意语言文案。到那时候你会跟我一样发现当初把数据拆干净的每一分钟都没有白费。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑