ASCII 字符粒子特效


关于本教程

本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。 我希望您会喜欢本教程,并制作出色的游戏!

如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon

Hands-On Rust


对于你的操作没有真正的视觉反馈 - 你击中了什么东西,它要么消失,要么不消失。 血迹可以很好地表示一个地点 先前 发生了什么 - 但如果能对你的行为给出某种即时反应就更好了。 这些反应需要快速、非阻塞(这样你就不必等待动画完成才能继续玩游戏),并且不宜过于突兀。 粒子非常适合这种情况,所以我们将实现一个简单的 ASCII/CP437 粒子系统。

粒子组件

和往常一样,我们将从思考粒子 什么开始。 通常,它有一个位置、一些要渲染的东西和一个生命周期(以便它消失)。 我们已经完成了其中三项中的两项,所以让我们继续创建 ParticleLifetime 组件。 在 components.rs 中:

#![allow(unused)]
fn main() {
#[derive(Component, Serialize, Deserialize, Clone)]
pub struct ParticleLifetime {
    pub lifetime_ms : f32
}
}

我们必须在所有通常的地方注册它:main.rssaveload_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 过于混乱。

Screenshot

为物品使用添加特效

为物品使用添加类似的效果会很棒,所以让我们这样做! 在 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 你的项目,你将看到各种视觉效果触发。

Screenshot

为因 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 不再追逐你了:

Screenshot

总结

目前视觉效果就到此为止。 我们为游戏赋予了更强的代入感,并为操作提供了反馈。 这是一个很大的改进,并且在很大程度上使 ASCII 界面现代化!

本章的源代码可以在这里找到

在你的浏览器中使用 WebAssembly 运行本章的示例(需要 WebGL2)


版权 (C) 2019, Herbert Wolverson.