用 Rust 和 Bevy 从零开发修仙卡牌游戏,完整开源

用 Rust 和 Bevy 从零开发修仙卡牌游戏,完整开源 我花了大半个季度用 Rust 和 Bevy 引擎从零做了一款修仙题材的卡牌游戏最近把整理好的代码完整开源了。项目名叫“青冥问道”仓库里包含一套完整的回合制卡牌框架、境界突破数值、几十张卡牌配置以及可直接运行的 Demo。做这个项目最初的动机很单纯想验证 Rust 到底能不能做出一款有完整玩法的游戏。结果一路踩下来发现Bevy 的 ECS 架构和回合制卡牌这种强状态机玩法意外地契合而 Rust 的所有权限制也没有想象中那么碍事反而帮我避开了很多隐性的逻辑 bug。对于正在学 Rust、想入门 Bevy或者打算做卡牌游戏但没头绪的朋友这份代码应该能提供一份不错的参考。围绕这个项目我会把技术选型、架构设计、核心实现和一些踩坑心得都拆开聊。不吹不黑只说实操。1. 为什么用 Rust 和 Bevy 做修仙卡牌1.1 技术选型Bevy 引擎的取舍Bevy 是一个纯 Rust 编写的现代游戏引擎最大的特点是完全基于 ECS 架构并且整个引擎本身也是开源的。它没有像 Unity 或 Godot 那样的可视化编辑器所有东西都用代码拼场景、UI、动画、输入反馈全靠 Rust 代码描述。这一点对初学者来说是劝退项但对想做卡牌游戏的开发者来说反而成了优势。卡牌游戏的本质是数据驱动。一张卡牌从 JSON 配置加载到实体生成再到打出时机、效果结算整个过程非常贴近“数据 规则 状态”的模型。在 Unity 里你往往要照顾 MonoBehaviour 的生命周期要用 ScriptableObject 做卡牌资产还要处理编辑器序列化的问题而在 Bevy 里卡牌就是实体属性就是组件行为就是系统直接按 ECS 的套路写就行几乎没有“引擎框架”带来的额外心智负担。当然代价也明显。Bevy 的版本迭代非常快0.11 到 0.12 到 0.13、0.14每次升级 API 都会动一批。我在开发过程中就体验过从Commands到EntityWorldMut的迁移模块拆分和插件配置方式也变过几轮。所以如果你打算参考这个项目第一件事就是确认你用的 Bevy 版本和代码里锁定的版本一致否则会收获一箩筐类型报错。再说 Rust 本身。选 Rust 做游戏核心理由是内存安全和性能可控。回合制卡牌虽然帧率压力不大但战斗逻辑里大量实体并发遍历借用检查器能强制你把“谁拥有数据”“谁可以修改数据”的边界想清楚。这种约束在多人协作时尤其有价值我一个人开发时也明显感觉“编译过就基本能跑”的比例很高。1.2 修仙卡牌的核心循环设计修仙题材和卡牌玩法的结合点其实很自然。修仙故事里常见的境界、灵力、修为、法宝、丹药都能直接映射成卡牌游戏里的资源与成长系统。我的核心循环是角色选择事件节点战斗、奇遇、修炼→ 通过战斗或事件获取修为与灵石 → 消耗修为突破境界 → 境界提升解锁更强卡牌与属性上限 → 再去挑战更高难度的节点。这个循环里“境界突破”不是单纯的角色等级提升而是会直接影响卡组构筑。比如炼气期只能编入基础功法牌与低阶丹药牌筑基期解锁第一张法宝牌金丹期开始出现神通牌。于是每次突破境界玩家必须回到“卡组编辑”界面重新调整牌组这就把 RPG 成长和卡牌构筑绑在了一起而不是两套体系各玩各的。为了让成长反馈足够明显我设计了境界属性表不同境界对生命上限、灵力上限、每回合灵力回复、修为消耗都有不同值。具体数值表我放在 3.3 节这里先提一句数值设计不是拍脑袋而是先用 Excel 模拟一局从炼气到元婴的完整成长曲线再回来调的系数。卡牌游戏最怕数值崩坏前期差点让一张低费功法牌变成持续回血永动机最后是加了一个“同名牌每场战斗最多使用一次”的规则才压住。1.3 开源目标和适合读者这个项目开源主要目的不是炫耀代码量而是想给三类人提供参考第一类是在学 Rust 但觉得语言特性太抽象、想找一个真实项目练手的。仓库里能看到的enum、trait、泛型、所有权转移全部出现在有意义的游戏场景里而不是练习题。第二类是 Bevy 入门者尤其是想知道 ECS 到底怎么设计的。卡牌游戏的状态跳转和事件流非常适合观察 ECS 的反应式编程模型。第三类是卡牌游戏开发者想看看修仙题材如何跟回合制战斗融合。项目里的卡牌数据模型、效果解析器、境界数值表都可以直接改改拿去用。如果你只是想把代码 clone 下来跑一遍那也简单装好 Rust 工具链在项目根目录执行cargo run等编译完成后就能看到窗口和控制台日志。第一次编译可能慢一点Bevy 的依赖树比较重请给自己泡杯茶。2. 用 ECS 架构拆解游戏系统2.1 实体、组件与系统在卡牌里的落地Bevy 的 ECS 三件套落到卡牌游戏里对应关系非常直观实体就是“存在的东西”。玩家、敌人、手牌里的卡牌、场上的 buff、甚至一次出牌产生的“剑气”动画只要有独立生命周期就可以是一个实体。组件就是“描述实体的数据”。比如一张卡牌实体上挂着Card组件卡牌 ID、名称、费用、CardType组件功法/法宝/丹药/神通、CardState组件在手牌/在牌库/已消耗。角色实体上挂着Cultivator组件当前境界、最大生命、当前生命、最大灵力、当前灵力、Status组件中毒、护盾、攻击加成等。组件本身只是数据不包含行为这是 ECS 的核心约定。系统就是“处理一类逻辑的函数”。抽牌系统扫描牌库实体并移动到手牌容器灵力结算系统在回合开始时重置灵力值出牌系统监听鼠标事件再寻找目标实体效果结算系统读取卡牌组件上的效果描述并作用于目标实体。每个系统只干一件事然后通过调度器组合起来。实际编码里我经常用到Querymut Cultivator, WithoutDead这种写法用来过滤出“还活着且需要更新属性”的角色。在传统的面向对象写法里你可能要在循环里写一堆if判断而 ECS 的世界里查询条件就是组件组合逻辑被拆得很干净。这对卡牌游戏这种“卡牌种类多、效果千奇百怪”的场景尤其友好。2.2 卡牌数据驱动JSON 转实体卡牌游戏最难维护的就是庞大且不断膨胀的卡牌数据。如果把每张卡都写死在 Rust 结构里后续扩展会非常痛苦。所以我在项目里用了数据驱动方案卡牌定义统一放在assets/cards/*.json文件里游戏启动时批量加载解析成卡牌数据资源再在需要时生成对应的卡牌实体。一张卡牌的 JSON 配置大概是这样的{ id: qinglian_jianjue, name: 青莲剑诀, description: 造成 12 点伤害抽 1 张牌。, card_type: Skill, cost: 2, effects: [ { type: Damage, value: 12 }, { type: DrawCard, value: 1 } ], flavor_text: 一剑出鞘青莲绽放。 }在 Rust 里用serde定义对应的结构体并把效果列表定义成一个枚举这样后续扩展新效果类型时只需要在枚举里增加一个变体然后在“效果执行系统”里写对应的分支即可。#[derive(Deserialize, Debug, Clone)] pub struct CardConfig { pub id: String, pub name: String, pub description: String, #[serde(rename card_type)] pub card_type: CardTypeDef, pub cost: i32, pub effects: VecEffectSpec, } #[derive(Deserialize, Debug, Clone)] #[serde(tag type, rename_all PascalCase)] pub enum EffectSpec { Damage { value: i32 }, Heal { value: i32 }, DrawCard { value: i32 }, GainQi { value: i32 }, // ... 后续扩展 }这套方案有几个好处第一数值策划可以直接改 JSON不需要动代码第二跨语言、跨工具协作更方便比如用 Python 脚本生成数值表再转成 JSON第三开源之后别人想往你项目里加卡牌不需要理解 Rust 的类型系统照着已有的 JSON 复制一份改改就行。当然也有坑。Bevy 的资源加载默认支持ron格式但用serde_json读取 JSON 时要注意文件路径和 asset 插件配置。我一开始直接把 JSON 放在assets/cards下面结果AssetServer不认.json后来改成JsonAssetLoader扩展才搞定。代码里这种“非 Bevy 原生资产”的加载方式建议单独封装成一个插件模块别散落在各个 system 里。2.3 回合状态机与事件驱动的战斗流程回合制卡牌的核心是“当前轮到谁行动”围绕这个需要一套明确的状态机。Bevy 内置的States插件正好能干这事。我将战斗流程拆成四个状态#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)] pub enum TurnState { #[default] PlayerTurn, EnemyTurn, Victory, Defeat, }玩家回合开始时调用enter回调重置灵力、抽牌、刷新“结束回合”按钮玩家出牌时通过事件系统发送CardPlayed事件出牌完成后检查敌方生命如果是 0 就切换到Victory否则保持PlayerTurn并继续等待输入。玩家点击结束回合状态切换到EnemyTurn此时敌人 AI 根据策略从手牌中选择卡牌并执行效果结束后再切回PlayerTurn。这里我比较推荐用 Bevy 的EventWriter/EventReader来处理卡牌效果而不是直接调用函数。比如CardPlayed事件被结算系统监听后结算系统再发一个DamageApplied事件给伤害飘字系统UI 系统订阅DamageApplied之后做动画。事件解耦可以避免系统之间产生强依赖也让日志更好打——每个事件都带实体 ID 和数值回看日志基本能还原整场战斗。状态切换最需要注意的是防止连续 switch 导致“一帧之内跑了多个回合”。我在切到EnemyTurn时会让敌人 AI 系统只监听OnEnter(EnemyTurn)的元事件并且 AI 系统内部负责把状态切回PlayerTurn。这样即使逻辑再复杂单帧内状态转换也被限制成有限步骤不会出现死循环。3. 从零搭项目关键实现细节3.1 依赖配置与项目骨架创建新项目只需要标准cargo new但 Cargo.toml 需要添上关键依赖。我使用 Bevy 0.14 版本开发时还打开了动态加载特性等前端表现稳定后再切成静态链接。核心依赖如下[package] name qingming_wendao version 0.1.0 edition 2021 [dependencies] bevy 0.14 serde { version 1, features [derive] } serde_json 1 rand 0.8 bevy_mod_picking 0.20需要注意Bevy 0.14 的默认 feature 体积很大如果只做 PC 端可以关掉一部分用不到的功能比如bevy_gltf、bevy_pbr。卡牌游戏场景基本是 2D UI不需要完整 3D 渲染。我实测只保留bevy_ui、bevy_text、bevy_sprite、default_font这几个 feature编译时间能缩短大概三分之一运行体积也小一圈。项目骨架按插件模块拆分每个功能域一个 pluginuse bevy::prelude::*; mod battle; mod cards; mod cultivation; mod ui; pub struct GamePlugin; impl Plugin for GamePlugin { fn build(self, app: mut App) { app.add_plugins(( cards::CardPlugin, battle::BattlePlugin, cultivation::CultivationPlugin, ui::UiPlugin, )); } } fn main() { App::new() .add_plugins(DefaultPlugins) .add_plugins(GamePlugin) .add_systems(Startup, setup_game) .run(); }这种模块化插件的好处非常直接每个插件负责自己的资源、系统、事件模块之间通过 Bevy 内置的事件和资源通信不会出现一个巨型 System 修改全局状态的情况。3.2 抽牌、出牌与效果结算的代码骨架抽牌逻辑说起来很直白每次从牌库顶端弹出一张实体加入手牌列表然后发送一个 UI 刷新事件。但这里实现时有一个细节手牌本质上是“实体列表”而牌库是“资源”所以我的Deck资源长这样#[derive(Resource, Default)] pub struct Deck { pub draw_pile: VecEntity, pub hand: VecEntity, pub discard_pile: VecEntity, }系统从draw_pile弹出实体push 进hand。所有逻辑都操作实体 ID具体的卡牌数据通过QueryCard从 ECS 里查询。这比把整个卡牌结构塞进资源里更适合 Bevy因为实体数据由 ECS 统一管理生命期和组件都受保护。出牌系统的骨架如下pub fn play_card( mut commands: Commands, mut deck: ResMutDeck, mut card_played_events: EventWriterCardPlayed, mut query_card: Querymut Card, mut query_state: Querymut Cultivator, WithPlayer, selected_card: ResSelectedCard, ) { let Some(entity) selected_card.0 else { return; }; let Ok(card) query_card.get_mut(entity) else { return; }; let Ok(mut player) query_state.single_mut() else { return; }; if player.qi card.cost { info!(灵力不足无法打出 {}, card.name); return; } player.qi - card.cost; deck.hand.retain(|e| *e ! entity); deck.discard_pile.push(entity); commands.entity(entity).insert(UsedThisTurn {}); card_played_events.send(CardPlayed { card_entity: entity, caster: player_entity, }); }注意上面这段是示意把玩家 click 到选中状态的SelectedCard资源当成输入源了。真实项目里我用的是鼠标点击 UI 按钮触发CardSelected事件然后由系统更新SelectedCard资源。出牌只是把卡牌从手牌移动到弃牌堆同时扣掉灵力真正卡牌效果全在CardPlayed事件的监听系统里异步执行。效果结算系统负责读取卡牌配置中的effects数组然后逐个执行。比如Damage效果对目标生命做减法DrawCard效果调用抽牌队列接口GainQi效果在当前灵力上加值。为了让效果可叠加效果系统不直接操作Cultivator组件上的数值而是先生成一条“属性变化缓冲区”等所有效果结算完后统一写入。这样避免多张卡牌同时结算时出现“先扣血再加血”的时序混乱。3.3 境界突破的数值表设计境界系统是修仙卡牌的核心成长线。为了保证游戏体验不极端我整理了一张数值表游戏运行时读取这张表做属性计算境界生命上限灵力上限每回合灵力回复突破所需修为解锁卡牌数量炼气60330基础功法 8 张筑基10054200功法 12 张、法宝 2 张金丹15075500功法 16 张、法宝 4 张、神通 1 张元婴220961000功法 20 张、法宝 6 张、神通 3 张表格本身不值得细看关键是数值推导逻辑。我先设定了“玩家在炼气期大约需要 2 场战斗突破到筑基”那么每场战斗平均给 100 修为所以突破需求定为 200。再设定“筑基期敌人每回合能打出约 20 点伤害、玩家有 4 回合的战斗窗口”那么玩家生命上限就该在 80 到 120 之间不然不是碾压就是被秒。每回合灵力回复决定玩家能出几张卡。卡牌平均费用是 2所以筑基期每回合 4 点灵力意味着每轮可以打出 2 张中等费用卡牌这个频率在卡牌游戏里是比较舒服的。金丹期 5 点灵力加上一些“灵力转化”效果手牌资源逐渐丰富玩家开始体验到牌组构筑的深度。代码层面没有把数值表写死在 Rust 里而是放在assets/balance/realms.json。游戏启动时加载到RealmsTable资源中在玩家点击“突破”按钮时系统查找当前境界对应的下一级配置判断修为是否足够然后更新Cultivator组件的属性值并触发一次卡牌库解锁事件。这样做的好处是后续调整平衡性不用重新编译只需改 JSON 后热重载配置。3.4 UI 与输入交互把卡牌“点”出去Bevy 的 UI 系统在 0.14 版本已经比较能打但比起传统引擎的 Canvas 还是原始。卡牌 UI 我用了三种元素卡牌实体对象图鉴里的卡牌预览、手牌区域的可点击按钮战斗中的卡牌、以及战斗信息面板。这里的核心交互是“点击一张手牌 → 选择敌方目标 → 确认出牌”。实现方式是用 Bevy 的 UI 按钮组件。手牌从牌库抓起来时会生成一个ButtonBundle上面挂着CardRef(Entity)组件用来记录它对应的卡牌实体。玩家点击按钮Interaction组件变成PressedUI 系统识别后发送CardSelected事件。目标选择阶段稍麻烦。因为没有鼠标悬停拾取的默认支持我用了bevy_mod_picking的二维拾取功能给敌人角色实体挂上Pickable组件。玩家先选卡牌再点击敌人系统组合成一次完整的CardPlayed事件。如果你不想引入额外 crate也可以自己写屏幕坐标碰撞检测把敌人 sprite 的包围盒换算成屏幕坐标和鼠标点击坐标做 AABB 判断。代码量不大但每个界面都要算一遍扩展起来麻烦所以我最后选择了专门的 picking 库。拖拽卡牌虽然直观但实现成本比点击高不少。我第一版试过让卡牌跟随鼠标移动后来发现 UI 和实体坐标转换、拖拽结束的命中检测、以及拖拽过程中手牌布局刷新这些叠加在一起很容易出 bug。最终成品改成了“点击亮牌 → 点击目标出牌”的交互方式虽然少了一点动作感但稳定性高得多也方便做手柄支持。4. 性能优化、测试与踩坑实录4.1 性能瓶颈定位与优化手段回合制卡牌游戏性能压力主要不在战斗计算而在 UI 刷新和实体数量增长。一开始我简单粗暴地每帧刷新整张手牌 UI结果手牌超过 10 张明显能感觉到卡顿。后来把 UI 刷新改成事件驱动抽牌、弃牌、打出时手动触发RefreshHandUi事件系统再批量重建手牌区域节点帧率就稳定了。另外Bevy 的 ECS 查询如果写得不好会产生不必要的开销。比如我第一次实现“灵力结算”时每帧遍历所有带Cultivator的实体即使它们都没有灵力组件。后来拆成Querymut Cultivator, WithPlayer把查询范围缩到最小。如果还想更精准地分析可以接入bevy_tracy或bevy_inspector_egui。Tracy 是业界比较常用的 profiling 工具能直接看到每个系统的耗时和调用次数。我在优化阶段用它发现“效果结算系统”在单帧内被调用了三次因为事件写入和读取的顺序没有控制好调整调度顺序后减少了一半重复计算。4.2 Bevy 开发中的常见坑与解法项目里遇到的坑不少挑几个影响最大的说一说。第一是 Bevy 版本升级带来的破坏性改动。从 0.13 升到 0.14 时Query::single的返回类型变了Commands::entity也增加了许多新方法。我的做法是升级前先把所有bevy开头的依赖单独拉出来写个小 Demo确认 API 用法再动项目代码。别指望直接cargo update就能跑。第二是借用检查器和 ECS 的冲突。写卡牌结算时经常会遇到“既想修改玩家灵力又想读取同一张卡牌组件”的场景此时借用检查器会拦住你。解决办法是拆分系统参数用ParamSet或分开多个系统或者把不冲突的查询合到同一个Query里。我推荐优先拆分系统因为每个独立系统都能单独测试逻辑边界也更清晰。第三是资源文件热重载。Bevy 默认支持 asset hot reload但需要开启AssetPlugin的watch_for_changesfeature。我开发时为了流畅调 JSON专门写了一个ReloadDataSystem监听卡牌文件变化后重新解析并更新资源。这个系统对开源项目的贡献者非常重要——别人改完数值不用重启游戏效率高很多。4.3 开源后的节奏与贡献指南代码整理成开源仓库后我特意做了几件事让它更容易被接手。首先是一份详细的 README包含项目介绍、运行方式、目录结构、卡牌配置写法、数值表说明。开源项目最怕别人 clone 下来不知道从哪入手README 就是门面。其次是 GitHub Actions 的持续集成。每次 push 都会跑一遍cargo check和核心逻辑单元测试至少保证主分支能编译。这样可以减少“明明本地能跑上线就编译失败”的尴尬。最后是 Issue 模板和贡献指南。我把自己踩过的坑整理成文档比如 Bevy 版本锁定、JSON 文件格式、调试方式这样新人提 issue 和提交代码时更有针对性。贡献代码时不要求多复杂哪怕是补充卡牌描述、调整数值平衡都会认真 review。开源对我的意义不仅是分享也是收集反馈。有人提出敌人 AI 太蠢有人建议增加连击系统有人发现 UI 在 4K 分辨率下布局错乱这些都是一个人测试时很难发现的。到目前为止最让我开心的反馈是有个刚学 Rust 的开发者说他参照这个项目的 ECS 结构把自己之前苦于无处下手的卡牌 Demo 终于写出来了。最后分享一点我自己的体会用 Rust 写游戏最大的安全感来自编译器的严格检查而用 Bevy 写游戏最大的乐趣在于 ECS 架构让逻辑变成一堆简洁的系统。只要卡牌 JSON 定义清晰、状态切换保持单向这个组合比想象中靠谱得多。如果你正在犹豫要不要用 Rust 做游戏我建议直接找一个像卡牌这样状态明确、数据驱动的小项目试水而不是一上来就挑战动作类。跳进坑里再爬出来收获会很大。