ASCII 字符粒子特效
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
对于你的操作没有真正的视觉反馈 - 你击中了什么东西,它要么消失,要么不消失。 血迹可以很好地表示一个地点 先前 发生了什么 - 但如果能对你的行为给出某种即时反应就更好了。 这些反应需要快速、非阻塞(这样你就不必等待动画完成才能继续玩游戏),并且不宜过于突兀。 粒子非常适合这种情况,所以我们将实现一个简单的 ASCII/CP437 粒子系统。
粒子组件
和往常一样,我们将从思考粒子 是 什么开始。 通常,它有一个位置、一些要渲染的东西和一个生命周期(以便它消失)。 我们已经完成了其中三项中的两项,所以让我们继续创建 ParticleLifetime
组件。 在 components.rs
中:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Clone)] pub struct ParticleLifetime { pub lifetime_ms : f32 } }
我们必须在所有通常的地方注册它:main.rs
和 saveload_system.rs
(两次)。
将粒子代码分组在一起
我们将创建一个新文件 particle_system.rs
。 它不会是一个常规的 system,因为我们需要访问 RLTK Context
对象 - 但它必须为其他 system 提供服务。
首先要支持的是让粒子在生命周期结束后消失。 因此,我们在 particle_system.rs
中从以下内容开始:
#![allow(unused)] fn main() { use specs::prelude::*; use super::{ Rltk, ParticleLifetime}; pub fn cull_dead_particles(ecs : &mut World, ctx : &Rltk) { let mut dead_particles : Vec<Entity> = Vec::new(); { // 让粒子老化消亡 let mut particles = ecs.write_storage::<ParticleLifetime>(); let entities = ecs.entities(); for (entity, mut particle) in (&entities, &mut particles).join() { particle.lifetime_ms -= ctx.frame_time_ms; if particle.lifetime_ms < 0.0 { dead_particles.push(entity); } } } for dead in dead_particles.iter() { ecs.delete_entity(*dead).expect("Particle will not die"); } } }
然后我们修改 main.rs
中的渲染循环来调用它:
#![allow(unused)] fn main() { ctx.cls(); particle_system::cull_dead_particles(&mut self.ecs, ctx); }
通过服务生成粒子
让我们扩展 particle_system.rs
以提供一个构建器系统:你获得一个 ParticleBuilder
并向其添加请求,然后一起批量创建你的粒子。 我们将把粒子系统作为 resource 提供 - 这样它就可以在任何地方使用。 这避免了在每个系统中添加太多侵入性代码,并允许我们将实际的粒子生成作为一个单一的(快速)批处理来处理。
我们基本的 ParticleBuilder
看起来像这样。 我们还没有做任何实际 添加 粒子的事情,但这提供了请求器服务:
#![allow(unused)] fn main() { struct ParticleRequest { x: i32, y: i32, fg: RGB, bg: RGB, glyph: rltk::FontCharType, lifetime: f32 } pub struct ParticleBuilder { requests : Vec<ParticleRequest> } impl ParticleBuilder { #[allow(clippy::new_without_default)] pub fn new() -> ParticleBuilder { ParticleBuilder{ requests : Vec::new() } } pub fn request(&mut self, x:i32, y:i32, fg: RGB, bg:RGB, glyph: rltk::FontCharType, lifetime: f32) { self.requests.push( ParticleRequest{ x, y, fg, bg, glyph, lifetime } ); } } }
在 main.rs
中,我们将它变成一个 resource:
#![allow(unused)] fn main() { gs.ecs.insert(particle_system::ParticleBuilder::new()); }
现在,我们将返回 particle_system.rs
并构建一个实际的 system 来生成粒子。 该 system 看起来像这样:
#![allow(unused)] fn main() { pub struct ParticleSpawnSystem {} impl<'a> System<'a> for ParticleSpawnSystem { #[allow(clippy::type_complexity)] type SystemData = ( Entities<'a>, WriteStorage<'a, Position>, WriteStorage<'a, Renderable>, WriteStorage<'a, ParticleLifetime>, WriteExpect<'a, ParticleBuilder> ); fn run(&mut self, data : Self::SystemData) { let (entities, mut positions, mut renderables, mut particles, mut particle_builder) = data; for new_particle in particle_builder.requests.iter() { let p = entities.create(); positions.insert(p, Position{ x: new_particle.x, y: new_particle.y }).expect("Unable to inser position"); renderables.insert(p, Renderable{ fg: new_particle.fg, bg: new_particle.bg, glyph: new_particle.glyph, render_order: 0 }).expect("Unable to insert renderable"); particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime }).expect("Unable to insert lifetime"); } particle_builder.requests.clear(); } } }
这是一个非常简单的服务:它迭代请求,并为每个粒子创建一个 entity,其中包含来自请求的组件参数。 然后它清除构建器列表。 最后一步是在 main.rs
中将其添加到 system 调度中:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); let mut mob = MonsterAI{}; mob.run_now(&self.ecs); let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut melee = MeleeCombatSystem{}; melee.run_now(&self.ecs); let mut damage = DamageSystem{}; damage.run_now(&self.ecs); let mut pickup = ItemCollectionSystem{}; pickup.run_now(&self.ecs); let mut itemuse = ItemUseSystem{}; itemuse.run_now(&self.ecs); let mut drop_items = ItemDropSystem{}; drop_items.run_now(&self.ecs); let mut item_remove = ItemRemoveSystem{}; item_remove.run_now(&self.ecs); let mut particles = particle_system::ParticleSpawnSystem{}; particles.run_now(&self.ecs); self.ecs.maintain(); } } }
我们使其依赖于可能的粒子生成器。 我们需要小心一点,避免意外地使其与任何可能向其添加粒子的东西并发执行。
实际为战斗生成一些粒子
让我们从在有人攻击时生成粒子开始。 打开 melee_combat_system.rs
,我们将 ParticleBuilder
添加到 system 请求的 resource 列表中。 首先,是 include:
#![allow(unused)] fn main() { use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog, MeleePowerBonus, DefenseBonus, Equipped, particle_system::ParticleBuilder, Position}; }
然后,一个 WriteExpect
,以便能够写入 resource:
#![allow(unused)] fn main() { type SystemData = ( Entities<'a>, WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, ReadStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, MeleePowerBonus>, ReadStorage<'a, DefenseBonus>, ReadStorage<'a, Equipped>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position> ); }
以及 run
方法本身扩展的 resource 列表:
#![allow(unused)] fn main() { let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage, melee_power_bonuses, defense_bonuses, equipped, mut particle_builder, positions) = data; }
最后,我们将添加请求:
#![allow(unused)] fn main() { let pos = positions.get(wants_melee.target); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } let damage = i32::max(0, (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus)); }
如果你现在 cargo run
,你将看到相对微妙的粒子反馈,以显示发生了近战。 这绝对有助于游戏的 感觉,并且足够不突兀,以至于我们不会使我们的其他 system 过于混乱。
为物品使用添加特效
为物品使用添加类似的效果会很棒,所以让我们这样做! 在 inventory_system.rs
中,我们将扩展 ItemUseSystem
的介绍以包含 ParticleBuilder
:
#![allow(unused)] fn main() { impl<'a> System<'a> for ItemUseSystem { #[allow(clippy::type_complexity)] type SystemData = ( ReadExpect<'a, Entity>, WriteExpect<'a, GameLog>, ReadExpect<'a, Map>, Entities<'a>, WriteStorage<'a, WantsToUseItem>, ReadStorage<'a, Name>, ReadStorage<'a, Consumable>, ReadStorage<'a, ProvidesHealing>, ReadStorage<'a, InflictsDamage>, WriteStorage<'a, CombatStats>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, Confusion>, ReadStorage<'a, Equippable>, WriteStorage<'a, Equipped>, WriteStorage<'a, InBackpack>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position> ); #[allow(clippy::cognitive_complexity)] fn run(&mut self, data : Self::SystemData) { let (player_entity, mut gamelog, map, entities, mut wants_use, names, consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions) = data; }
我们将从在你喝下治疗药水时显示一颗心形开始。 在 healing 部分:
#![allow(unused)] fn main() { stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount); if entity == *player_entity { gamelog.entries.push(format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount)); } used_item = true; let pos = positions.get(*target); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::GREEN), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('♥'), 200.0); } }
我们可以对 confusion 使用类似的效果 - 只是使用品红色问号。 在 confusion 部分:
#![allow(unused)] fn main() { gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name)); let pos = positions.get(*mob); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0); } }
我们还应该使用粒子来指示造成了伤害。 在 system 的 damage 部分:
#![allow(unused)] fn main() { gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage)); let pos = positions.get(*mob); if let Some(pos) = pos { particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0); } }
最后,如果效果击中整个区域(例如,火球),最好指示该区域是什么。 在 system 的 targeting 部分,添加:
#![allow(unused)] fn main() { for mob in map.tile_content[idx].iter() { targets.push(*mob); } particle_builder.request(tile_idx.x, tile_idx.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('░'), 200.0); }
这并不太难,不是吗? 如果你现在 cargo run
你的项目,你将看到各种视觉效果触发。
为因 confusion 错过回合添加指示器
最后,当怪物回合时,如果它们因 confusion 而跳过回合,我们将重复 confusion 效果。 这应该减少对它们为何站在原地发呆的困惑。 在 monster_ai_system.rs
中,我们首先修改 system 标头以请求适当的 helper:
#![allow(unused)] fn main() { impl<'a> System<'a> for MonsterAI { #[allow(clippy::type_complexity)] type SystemData = ( WriteExpect<'a, Map>, ReadExpect<'a, Point>, ReadExpect<'a, Entity>, ReadExpect<'a, RunState>, Entities<'a>, WriteStorage<'a, Viewshed>, ReadStorage<'a, Monster>, WriteStorage<'a, Position>, WriteStorage<'a, WantsToMelee>, WriteStorage<'a, Confusion>, WriteExpect<'a, ParticleBuilder>); fn run(&mut self, data : Self::SystemData) { let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, monster, mut position, mut wants_to_melee, mut confused, mut particle_builder) = data; }
然后我们在 confusion 测试的末尾添加一个请求:
#![allow(unused)] fn main() { can_act = false; particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0); }
我们不需要担心在这里获取 Position
组件,因为我们已经在循环中获取了它。 如果你现在 cargo run
你的项目,并找到一个 confusion 卷轴 - 你将获得视觉反馈,了解为什么 goblin 不再追逐你了:
总结
目前视觉效果就到此为止。 我们为游戏赋予了更强的代入感,并为操作提供了反馈。 这是一个很大的改进,并且在很大程度上使 ASCII 界面现代化!
本章的源代码可以在这里找到
在你的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.