导弹和远程攻击


关于本教程

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

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

Hands-On Rust


当您阅读关于黑暗精灵的小说时,他们通常会偷偷地从黑暗中发射导弹武器。这实际上是他们被包含在本教程书中的原因:他们为拓展到精彩的远程战斗世界提供了一个很好的理由。我们已经有了一些远程战斗:法术效果可以发生在远处,但目标选择系统有点笨拙 - 而且对于弓箭决斗来说一点也不符合人体工程学。因此,在本章中,我们将介绍远程武器,并使黑暗精灵更可怕一些。我们还将尝试改进导弹的粒子效果,以便玩家可以看到发生了什么。

引入远程武器

我们将稍微作弊一下,不担心弹药;有些游戏会计算每一支箭,对于远程战斗角色来说,保持箭袋装满箭矢可能非常重要。我们将专注于远程武器方面,并假设弹药充足;这不是最现实的选择,但可以使事情易于管理!

定义短弓

让我们首先打开 spawns.json 文件,并为短弓创建一个条目:

{
    "name" : "Shortbow",
    "renderable": {
        "glyph" : ")",
        "fg" : "#FFAAAA",
        "bg" : "#000000",
        "order" : 2
    },
    "weapon" : {
        "range" : "4",
        "attribute" : "Quickness",
        "base_damage" : "1d4",
        "hit_bonus" : 0
    },
    "weight_lbs" : 2.0,
    "base_value" : 5.0,
    "initiative_penalty" : 1,
    "vendor_category" : "weapon"
},

您会注意到这与匕首条目非常相似;实际上,我复制/粘贴了它,然后将 "range" 从 "melee" 更改为 "4"!我还暂时删除了模板化的魔法部分,以保持事情简单明了。现在我们打开 components.rs,并查看 MeleeWeapon - 目的是制作远程武器。不幸的是,我们看到了一个设计错误!伤害都在武器内部,因此如果我们创建一个通用的 RangedWeapon 组件,我们将重复自己。通常,最好不要将同一件事键入两次,因此我们将 MeleeWeapon 的名称更改为 Weapon - 并添加一个 range 字段。如果它没有射程(它是一个 Option),那么它就只是近战武器:

#![allow(unused)]
fn main() {
#[derive(Component, Serialize, Deserialize, Clone)]
pub struct Weapon {
    pub range : Option<i32>,
    pub attribute : WeaponAttribute,
    pub damage_n_dice : i32,
    pub damage_die_type : i32,
    pub damage_bonus : i32,
    pub hit_bonus : i32,
    pub proc_chance : Option<f32>,
    pub proc_target : Option<String>,
}
}

您需要打开 main.rssaveload_system.rs 并将 MeleeWeapon 更改为 Weapon。还有一些其他的代码也崩溃了。在 melee_combat_system.rs 中,只需将所有 MeleeWeapon 实例替换为 Weapon。您还需要将 range 添加到为处理自然攻击而创建的虚拟武器中:

#![allow(unused)]
fn main() {
let mut weapon_info = Weapon{
    range: None,
    attribute : WeaponAttribute::Might,
    hit_bonus : 0,
    damage_n_dice : 1,
    damage_die_type : 4,
    damage_bonus : 0,
    proc_chance : None,
    proc_target : None
};
}

为了使其像以前一样编译和运行,您可以更改 raws/rawmaster.rs 的一个部分:

#![allow(unused)]
fn main() {
let mut wpn = Weapon{
    range : None,
    attribute : WeaponAttribute::Might,
    damage_n_dice : n_dice,
    damage_die_type : die_type,
    damage_bonus : bonus,
    hit_bonus : weapon.hit_bonus,
    proc_chance : weapon.proc_chance,
    proc_target : weapon.proc_target.clone()
};
}

这足以使旧代码再次运行,并且具有显着的优点:我们基本上保持了武器代码不变,因此所有的 “特性” 和 “魔法模板” 系统仍然有效。但是,有一个明显的限制:短弓仍然是近战武器!

我们可以打开 raws/rawmaster.rs 并更改相同的代码片段,以便在存在射程时实例化 range。这是一个好的开始 - 至少游戏可以选择知道它是一种远程武器!

#![allow(unused)]
fn main() {
let mut wpn = Weapon{
    range : if weapon.range == "melee" { None } else { Some(weapon.range.parse::<i32>().expect("Not a number")) },
    attribute : WeaponAttribute::Might,
    damage_n_dice : n_dice,
    damage_die_type : die_type,
    damage_bonus : bonus,
    hit_bonus : weapon.hit_bonus,
    proc_chance : weapon.proc_chance,
    proc_target : weapon.proc_target.clone()
};
}

让玩家射击物体

所以现在我们知道武器 远程武器,这是一个很好的开始。让我们进入 spawner.rs 并让玩家从一把短弓开始。我们可能不会保留它,但这为我们构建提供了一个良好的基础:

#![allow(unused)]
fn main() {
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Rusty Longsword", SpawnType::Equipped{by : player});
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Dried Sausage", SpawnType::Carried{by : player} );
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Beer", SpawnType::Carried{by : player});
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Stained Tunic", SpawnType::Equipped{by : player});
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Torn Trousers", SpawnType::Equipped{by : player});
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Old Boots", SpawnType::Equipped{by : player});
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Shortbow", SpawnType::Carried{by : player});
}

我们已经让它从背包开始,所以玩家仍然必须有意识地决定切换到使用远程武器(我们已经做了足够的近战工作,以至于射击物体不应该是默认选项!) - 但这使我们不必在测试我们正在构建的系统时四处寻找武器。继续并 cargo run 以快速测试您是否可以装备新的弓。您还不能射击任何东西,但至少可以装备它(并确信我们没有因组件更改而破坏太多东西)。

远程武器最难的部分是它有一个 目标:您正在射击的东西。我们希望目标选择易于操作,以免玩家弄不清楚如何射击物体!让我们首先向玩家展示他们装备的武器的信息 - 如果它有射程,我们将包含它。在 gui.rs 中,找到我们迭代已装备物品并显示它们的部分(在我的版本中大约在第 162 行)。我们将稍微扩展它:

#![allow(unused)]
fn main() {
// Equipped
// 已装备
let mut y = 13;
let entities = ecs.entities();
let equipped = ecs.read_storage::<Equipped>();
let weapon = ecs.read_storage::<Weapon>();
for (entity, equipped_by) in (&entities, &equipped).join() {
    if equipped_by.owner == *player_entity {
        let name = get_item_display_name(ecs, entity);
        ctx.print_color(50, y, get_item_color(ecs, entity), black, &name);
        y += 1;

        if let Some(weapon) = weapon.get(entity) {
            let mut weapon_info = if weapon.damage_bonus < 0 {
                format!("┤ {} ({}d{}{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus)
            } else if weapon.damage_bonus == 0 {
                format!("┤ {} ({}d{})", &name, weapon.damage_n_dice, weapon.damage_die_type)
            } else {
                format!("┤ {} ({}d{}+{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus)
            };

            if let Some(range) = weapon.range {
                weapon_info += &format!(" (range: {}, F to fire)", range);
            }
            weapon_info += " ├";
            ctx.print_color(3, 45, yellow, black, &weapon_info);
        }
    }
}
}

这是一个好的开始,因为现在我们正在告诉用户他们拥有一件远程武器(并且通常显示武器升级的即时结果是好的!):

Screenshot

那么,现在让玩家可以轻松地瞄准敌人!我们将从制作一个 Target 组件开始。在 components.rs 中(和往常一样,在 main.rssaveload_system.rs 中注册):

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Target {}
}

这个想法很简单:我们将把 Target 组件附加到我们当前正在瞄准的任何人身上。我们应该在地图上突出显示目标;所以我们转到 camera.rs 并将以下内容添加到实体渲染代码中:

#![allow(unused)]
fn main() {
// Render entities
// 渲染实体
let positions = ecs.read_storage::<Position>();
let renderables = ecs.read_storage::<Renderable>();
let hidden = ecs.read_storage::<Hidden>();
let map = ecs.fetch::<Map>();
let sizes = ecs.read_storage::<TileSize>();
let entities = ecs.entities();
let targets = ecs.read_storage::<Target>();

let mut data = (&positions, &renderables, &entities, !&hidden).join().collect::<Vec<_>>();
data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
for (pos, render, entity, _hidden) in data.iter() {
    if let Some(size) = sizes.get(*entity) {
        for cy in 0 .. size.y {
            for cx in 0 .. size.x {
                let tile_x = cx + pos.x;
                let tile_y = cy + pos.y;
                let idx = map.xy_idx(tile_x, tile_y);
                if map.visible_tiles[idx] {
                    let entity_screen_x = (cx + pos.x) - min_x;
                    let entity_screen_y = (cy + pos.y) - min_y;
                    if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height {
                        ctx.set(entity_screen_x + 1, entity_screen_y + 1, render.fg, render.bg, render.glyph);
                    }
                }
            }
        }
    } else {
        let idx = map.xy_idx(pos.x, pos.y);
        if map.visible_tiles[idx] {
            let entity_screen_x = pos.x - min_x;
            let entity_screen_y = pos.y - min_y;
            if entity_screen_x > 0 && entity_screen_x < map_width && entity_screen_y > 0 && entity_screen_y < map_height {
                ctx.set(entity_screen_x + 1, entity_screen_y + 1, render.fg, render.bg, render.glyph);
            }
        }
    }

    if targets.get(*entity).is_some() {
        let entity_screen_x = pos.x - min_x;
        let entity_screen_y = pos.y - min_y;
        ctx.set(entity_screen_x , entity_screen_y + 1, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::YELLOW), rltk::to_cp437('['));
        ctx.set(entity_screen_x +2, entity_screen_y + 1, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::YELLOW), rltk::to_cp437(']'));
    }
}
}

这段代码正在检查我们渲染的每个实体,以查看它是否被瞄准,如果是,则在其周围渲染颜色鲜艳的方括号。我们还应该提供一些关于如何使用目标选择系统的提示,所以在 gui.rs 中,我们按如下方式修改我们的远程武器代码:

#![allow(unused)]
fn main() {
if let Some(weapon) = weapon.get(entity) {
    let mut weapon_info = if weapon.damage_bonus < 0 {
        format!("┤ {} ({}d{}{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus)
    } else if weapon.damage_bonus == 0 {
        format!("┤ {} ({}d{})", &name, weapon.damage_n_dice, weapon.damage_die_type)
    } else {
        format!("┤ {} ({}d{}+{})", &name, weapon.damage_n_dice, weapon.damage_die_type, weapon.damage_bonus)
    };

    if let Some(range) = weapon.range {
        weapon_info += &format!(" (range: {}, F to fire, V cycle targets)", range);
    }
    weapon_info += " ├";
    ctx.print_color(3, 45, yellow, black, &weapon_info);
}
}

我们正在告诉用户按下 V 键来更改目标,所以我们需要实现该功能!在我们这样做之前,我们需要提出一个默认的目标选择方案。由于我们正在担心 玩家的 目标,我们将前往 player.rs 并添加一些新函数。第一个函数确定哪些实体有资格作为目标:

#![allow(unused)]
fn main() {
fn get_player_target_list(ecs : &mut World) -> Vec<(f32,Entity)> {
    let mut possible_targets : Vec<(f32,Entity)> = Vec::new();
    let viewsheds = ecs.read_storage::<Viewshed>();
    let player_entity = ecs.fetch::<Entity>();
    let equipped = ecs.read_storage::<Equipped>();
    let weapon = ecs.read_storage::<Weapon>();
    let map = ecs.fetch::<Map>();
    let positions = ecs.read_storage::<Position>();
    let factions = ecs.read_storage::<Faction>();
    for (equipped, weapon) in (&equipped, &weapon).join() {
        if equipped.owner == *player_entity && weapon.range.is_some() {
            let range = weapon.range.unwrap();

            if let Some(vs) = viewsheds.get(*player_entity) {
                let player_pos = positions.get(*player_entity).unwrap();
                for tile_point in vs.visible_tiles.iter() {
                    let tile_idx = map.xy_idx(tile_point.x, tile_point.y);
                    let distance_to_target = rltk::DistanceAlg::Pythagoras.distance2d(*tile_point, rltk::Point::new(player_pos.x, player_pos.y));
                    if distance_to_target < range as f32 {
                        crate::spatial::for_each_tile_content(tile_idx, |possible_target| {
                            if possible_target != *player_entity && factions.get(possible_target).is_some() {
                                possible_targets.push((distance_to_target, possible_target));
                            }
                        });
                    }
                }
            }
        }
    }

    possible_targets.sort_by(|a,b| a.0.partial_cmp(&b.0).unwrap());
    possible_targets
}
}

这是一个稍微复杂的函数,所以让我们逐步了解它:

  1. 我们创建一个空的结果列表,其中包含可作为目标的实体及其与玩家的距离。
  2. 我们迭代已装备的武器,以查看玩家是否拥有远程武器。
  3. 如果他们有,我们记下它的射程。
  4. 然后我们查看他们的视野,并检查每个瓦片是否在武器的射程内。
  5. 如果它在射程内,我们通过 tile_content 系统查看该瓦片中的实体。如果该实体实际上是一个有效的目标(他们拥有 Faction 成员资格),我们会将它们添加到可能的目标列表中。
  6. 我们按射程对可能的目标列表进行排序。

现在我们需要在玩家移动时选择一个新目标。我们将选择最近的目标,因为您更有可能瞄准直接威胁。以下函数完成了这项工作:

#![allow(unused)]
fn main() {
pub fn end_turn_targeting(ecs: &mut World) {
    let possible_targets = get_player_target_list(ecs);
    let mut targets = ecs.write_storage::<Target>();
    targets.clear();

    if !possible_targets.is_empty() {
        targets.insert(possible_targets[0].1, Target{}).expect("Insert fail");
    }
}
}

我们希望在新回合 开始 时调用此函数。所以我们前往 main.rs,并修改游戏循环以捕获新回合的开始并调用此函数:

#![allow(unused)]
fn main() {
RunState::Ticking => {
    let mut should_change_target = false;
    while newrunstate == RunState::Ticking {
        self.run_systems();
        self.ecs.maintain();
        match *self.ecs.fetch::<RunState>() {
            RunState::AwaitingInput => {
                newrunstate = RunState::AwaitingInput;
                should_change_target = true;
            }
            RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 },
            RunState::TownPortal => newrunstate = RunState::TownPortal,
            RunState::TeleportingToOtherLevel{ x, y, depth } => newrunstate = RunState::TeleportingToOtherLevel{ x, y, depth },
            RunState::ShowRemoveCurse => newrunstate = RunState::ShowRemoveCurse,
            RunState::ShowIdentify => newrunstate = RunState::ShowIdentify,
            _ => newrunstate = RunState::Ticking
        }
    }
    if should_change_target {
        player::end_turn_targeting(&mut self.ecs);
    }
}
}

现在我们将返回 player.rs 并添加另一个函数来循环目标:

#![allow(unused)]
fn main() {
fn cycle_target(ecs: &mut World) {
    let possible_targets = get_player_target_list(ecs);
    let mut targets = ecs.write_storage::<Target>();
    let entities = ecs.entities();
    let mut current_target : Option<Entity> = None;

    for (e,_t) in (&entities, &targets).join() {
        current_target = Some(e);
    }

    targets.clear();
    if let Some(current_target) = current_target {
        if !possible_targets.len() > 1 {
            let mut index = 0;
            for (i, target) in possible_targets.iter().enumerate() {
                if target.1 == current_target {
                    index = i;
                }
            }

            if index > possible_targets.len()-2 {
                targets.insert(possible_targets[0].1, Target{});
            } else {
                targets.insert(possible_targets[index+1].1, Target{});
            }
        }
    }
}
}

这是一个很长的函数,但我为了清晰起见而保持它的长度。它在当前目标列表中找到当前目标的索引。如果存在多个目标,它会选择列表中的下一个目标。如果它在列表的末尾,它会移动到开头。现在我们需要捕获 V 键的按下并调用此函数。在 player_input 函数中,我们将添加一个新部分:

#![allow(unused)]
fn main() {
// Ranged
// 远程
VirtualKeyCode::V => {
    cycle_target(&mut gs.ecs);
    return RunState::AwaitingInput;
}
}

如果您现在 cargo run,您可以装备您的弓并开始瞄准:

Screenshot

射击物体

我们有一个完善的战斗模式:使用 WantsToMelee 组件标记动作,然后它会在 MeleeCombatSystem 中被拾取。我们对想要接近、使用技能或物品的情况使用了类似的模式 - 因此对于想要射击的情况,我们再次这样做是有道理的。在 components.rs 中(并在 main.rssaveload_system.rs 中注册),我们将添加以下内容:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, ConvertSaveload, Clone)]
pub struct WantsToShoot {
    pub target : Entity
}
}

我们还需要创建一个新的系统,并将其存储在 ranged_combat_system.rs 中。它基本上是 melee_combat_system 的剪切和粘贴,但查找的是 WantsToShoot 而不是 WantsToMelee

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Attributes, Skills, WantsToShoot, Name, gamelog::GameLog,
    HungerClock, HungerState, Pools, skill_bonus,
    Skill, Equipped, Weapon, EquipmentSlot, WeaponAttribute, Wearable, NaturalAttackDefense,
    effects::*, Map, Position};
use rltk::{to_cp437, RGB, Point};

pub struct RangedCombatSystem {}

impl<'a> System<'a> for RangedCombatSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( Entities<'a>,
                        WriteExpect<'a, GameLog>,
                        WriteStorage<'a, WantsToShoot>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, Attributes>,
                        ReadStorage<'a, Skills>,
                        ReadStorage<'a, HungerClock>,
                        ReadStorage<'a, Pools>,
                        WriteExpect<'a, rltk::RandomNumberGenerator>,
                        ReadStorage<'a, Equipped>,
                        ReadStorage<'a, Weapon>,
                        ReadStorage<'a, Wearable>,
                        ReadStorage<'a, NaturalAttackDefense>,
                        ReadStorage<'a, Position>,
                        ReadExpect<'a, Map>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut log, mut wants_shoot, names, attributes, skills,
            hunger_clock, pools, mut rng, equipped_items, weapon, wearables, natural,
            positions, map) = data;

        for (entity, wants_shoot, name, attacker_attributes, attacker_skills, attacker_pools) in (&entities, &wants_shoot, &names, &attributes, &skills, &pools).join() {
            // Are the attacker and defender alive? Only attack if they are
            // 攻击者和防御者还活着吗?只有当他们活着时才攻击
            let target_pools = pools.get(wants_shoot.target).unwrap();
            let target_attributes = attributes.get(wants_shoot.target).unwrap();
            let target_skills = skills.get(wants_shoot.target).unwrap();
            if attacker_pools.hit_points.current > 0 && target_pools.hit_points.current > 0 {
                let target_name = names.get(wants_shoot.target).unwrap();

                // Fire projectile effect
                // 发射抛射物效果
                let apos = positions.get(entity).unwrap();
                let dpos = positions.get(wants_shoot.target).unwrap();
                add_effect(
                    None,
                    EffectType::ParticleProjectile{
                        glyph: to_cp437('*'),
                        fg : RGB::named(rltk::CYAN),
                        bg : RGB::named(rltk::BLACK),
                        lifespan : 300.0,
                        speed: 50.0,
                        path: rltk::line2d(
                            rltk::LineAlg::Bresenham,
                            Point::new(apos.x, apos.y),
                            Point::new(dpos.x, dpos.y)
                        )
                     },
                    Targets::Tile{tile_idx : map.xy_idx(apos.x, apos.y) as i32}
                );

                // Define the basic unarmed attack - overridden by wielding check below if a weapon is equipped
                // 定义基本的徒手攻击 - 如果装备了武器,则会被下面的挥舞检查覆盖
                let mut weapon_info = Weapon{
                    range: None,
                    attribute : WeaponAttribute::Might,
                    hit_bonus : 0,
                    damage_n_dice : 1,
                    damage_die_type : 4,
                    damage_bonus : 0,
                    proc_chance : None,
                    proc_target : None
                };

                if let Some(nat) = natural.get(entity) {
                    if !nat.attacks.is_empty() {
                        let attack_index = if nat.attacks.len()==1 { 0 } else { rng.roll_dice(1, nat.attacks.len() as i32) as usize -1 };
                        weapon_info.hit_bonus = nat.attacks[attack_index].hit_bonus;
                        weapon_info.damage_n_dice = nat.attacks[attack_index].damage_n_dice;
                        weapon_info.damage_die_type = nat.attacks[attack_index].damage_die_type;
                        weapon_info.damage_bonus = nat.attacks[attack_index].damage_bonus;
                    }
                }

                let mut weapon_entity : Option<Entity> = None;
                for (weaponentity,wielded,melee) in (&entities, &equipped_items, &weapon).join() {
                    if wielded.owner == entity && wielded.slot == EquipmentSlot::Melee {
                        weapon_info = melee.clone();
                        weapon_entity = Some(weaponentity);
                    }
                }

                let natural_roll = rng.roll_dice(1, 20);
                let attribute_hit_bonus = if weapon_info.attribute == WeaponAttribute::Might
                    { attacker_attributes.might.bonus }
                    else { attacker_attributes.quickness.bonus};
                let skill_hit_bonus = skill_bonus(Skill::Melee, &*attacker_skills);
                let weapon_hit_bonus = weapon_info.hit_bonus;
                let mut status_hit_bonus = 0;
                if let Some(hc) = hunger_clock.get(entity) { // Well-Fed grants +1
                    // Well-Fed 状态提供 +1 加成
                    if hc.state == HungerState::WellFed {
                        status_hit_bonus += 1;
                    }
                }
                let modified_hit_roll = natural_roll + attribute_hit_bonus + skill_hit_bonus
                    + weapon_hit_bonus + status_hit_bonus;
                //println!("Natural roll: {}", natural_roll);
                //println!("Modified hit roll: {}", modified_hit_roll);

                let mut armor_item_bonus_f = 0.0;
                for (wielded,armor) in (&equipped_items, &wearables).join() {
                    if wielded.owner == wants_shoot.target {
                        armor_item_bonus_f += armor.armor_class;
                    }
                }
                let base_armor_class = match natural.get(wants_shoot.target) {
                    None => 10,
                    Some(nat) => nat.armor_class.unwrap_or(10)
                };
                let armor_quickness_bonus = target_attributes.quickness.bonus;
                let armor_skill_bonus = skill_bonus(Skill::Defense, &*target_skills);
                let armor_item_bonus = armor_item_bonus_f as i32;
                let armor_class = base_armor_class + armor_quickness_bonus + armor_skill_bonus
                    + armor_item_bonus;

                //println!("Armor class: {}", armor_class);
                if natural_roll != 1 && (natural_roll == 20 || modified_hit_roll > armor_class) {
                    // Target hit! Until we support weapons, we're going with 1d4
                    // 目标命中!在我们支持武器之前,我们先使用 1d4
                    let base_damage = rng.roll_dice(weapon_info.damage_n_dice, weapon_info.damage_die_type);
                    let attr_damage_bonus = attacker_attributes.might.bonus;
                    let skill_damage_bonus = skill_bonus(Skill::Melee, &*attacker_skills);
                    let weapon_damage_bonus = weapon_info.damage_bonus;

                    let damage = i32::max(0, base_damage + attr_damage_bonus +
                        skill_damage_bonus + weapon_damage_bonus);

                    /*println!("Damage: {} + {}attr + {}skill + {}weapon = {}",
                        base_damage, attr_damage_bonus, skill_damage_bonus,
                        weapon_damage_bonus, damage
                    );*/
                    add_effect(
                        Some(entity),
                        EffectType::Damage{ amount: damage },
                        Targets::Single{ target: wants_shoot.target }
                    );
                    log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));

                    // Proc effects
                    // 触发效果
                    if let Some(chance) = &weapon_info.proc_chance {
                        let roll = rng.roll_dice(1, 100);
                        //println!("Roll {}, Chance {}", roll, chance);
                        if roll <= (chance * 100.0) as i32 {
                            //println!("Proc!");
                            let effect_target = if weapon_info.proc_target.unwrap() == "Self" {
                                Targets::Single{ target: entity }
                            } else {
                                Targets::Single { target : wants_shoot.target }
                            };
                            add_effect(
                                Some(entity),
                                EffectType::ItemUse{ item: weapon_entity.unwrap() },
                                effect_target
                            )
                        }
                    }

                } else  if natural_roll == 1 {
                    // Natural 1 miss
                    // 自然 1 失误
                    log.entries.push(format!("{} considers attacking {}, but misjudges the timing.", name.name, target_name.name));
                    add_effect(
                        None,
                        EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg: rltk::RGB::named(rltk::BLUE), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 },
                        Targets::Single{ target: wants_shoot.target }
                    );
                } else {
                    // Miss
                    // 失误
                    log.entries.push(format!("{} attacks {}, but can't connect.", name.name, target_name.name));
                    add_effect(
                        None,
                        EffectType::Particle{ glyph: rltk::to_cp437('‼'), fg: rltk::RGB::named(rltk::CYAN), bg : rltk::RGB::named(rltk::BLACK), lifespan: 200.0 },
                        Targets::Single{ target: wants_shoot.target }
                    );
                }
            }
        }

        wants_shoot.clear();
    }
}
}

大部分内容都直接来自之前的系统。您还需要将其添加到 main.rs 中的 run_systems 中;紧随近战之后的位置是一个不错的选择:

#![allow(unused)]
fn main() {
let mut ranged = RangedCombatSystem{};
ranged.run_now(&self.ecs);
}

眼尖的读者会注意到,我们还偷偷添加了一个额外的 add_effect 调用,这次调用的是 EffectType::ParticleProjectile。这不是必需的,但显示飞行的抛射物确实突出了远程战斗的风味。到目前为止,我们的粒子是静止的,所以让我们为它们添加一些 “活力”!

components.rs 中,我们将更新 ParticleLifetime 组件以包含可选的动画:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone)]
pub struct ParticleAnimation {
    pub step_time : f32,
    pub path : Vec<Point>,
    pub current_step : usize,
    pub timer : f32
}

#[derive(Component, Serialize, Deserialize, Clone)]
pub struct ParticleLifetime {
    pub lifetime_ms : f32,
    pub animation : Option<ParticleAnimation>
}
}

这增加了一个 step_time - 粒子应该在每个步骤停留多长时间。一个 path - 一个 Point 向量,列出了沿途的每个步骤。current_steptimer 将用于跟踪抛射物的进度。

您需要进入 particle_system.rs 并修改粒子生成,使其默认包含 None

#![allow(unused)]
fn main() {
particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime, animation: None }).expect("Unable to insert lifetime");
}

当我们在这里时,我们将重命名剔除函数 (cull_dead_particles) 为 update_particles - 更好地反映它所做的事情。我们还将添加一些逻辑来查看是否存在动画,并使其更新其在动画轨道上的位置:

#![allow(unused)]
fn main() {
pub fn update_particles(ecs : &mut World, ctx : &Rltk) {
    let mut dead_particles : Vec<Entity> = Vec::new();
    {
        // Age out particles
        // 使粒子老化
        let mut particles = ecs.write_storage::<ParticleLifetime>();
        let entities = ecs.entities();
        let map = ecs.fetch::<Map>();
        for (entity, mut particle) in (&entities, &mut particles).join() {
            if let Some(animation) = &mut particle.animation {
                animation.timer += ctx.frame_time_ms;
                if animation.timer > animation.step_time && animation.current_step < animation.path.len()-2 {
                    animation.current_step += 1;

                    if let Some(pos) = ecs.write_storage::<Position>().get_mut(entity) {
                        pos.x = animation.path[animation.current_step].x;
                        pos.y = animation.path[animation.current_step].y;
                    }
                }
            }

            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,搜索 cull_dead_particles 并将其替换为 update_particles

这足以实际动画化粒子,并在完成后仍然使它们消失,但我们需要更新 Effects 系统以生成新型粒子。在 effects/mod.rs 中,我们将扩展 EffectType 枚举以包含新的粒子类型:

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum EffectType {
    ...
    ParticleProjectile { glyph: rltk::FontCharType, fg : rltk::RGB, bg: rltk::RGB, lifespan: f32, speed: f32, path: Vec<Point> },
    ...
}

我们还必须更新同一文件中的 affect_tile

#![allow(unused)]
fn main() {
fn affect_tile(ecs: &mut World, effect: &mut EffectSpawner, tile_idx : i32) {
    if tile_effect_hits_entities(&effect.effect_type) {
        let content = ecs.fetch::<Map>().tile_content[tile_idx as usize].clone();
        content.iter().for_each(|entity| affect_entity(ecs, effect, *entity));
    }

    match &effect.effect_type {
        EffectType::Bloodstain => damage::bloodstain(ecs, tile_idx),
        EffectType::Particle{..} => particles::particle_to_tile(ecs, tile_idx, &effect),
        EffectType::ParticleProjectile{..} => particles::projectile(ecs, tile_idx, &effect),
        _ => {}
    }
}
}

这将调用 particles::projectile,所以打开 effects/particles.rs,我们将添加该函数:

#![allow(unused)]
fn main() {
pub fn projectile(ecs: &mut World, tile_idx : i32, effect: &EffectSpawner) {
    if let EffectType::ParticleProjectile{ glyph, fg, bg,
        lifespan, speed, path } = &effect.effect_type
    {
        let map = ecs.fetch::<Map>();
        let x = tile_idx % map.width;
        let y = tile_idx / map.width;
        std::mem::drop(map);
        ecs.create_entity()
            .with(Position{ x, y })
            .with(Renderable{ fg: *fg, bg: *bg, glyph: *glyph, render_order: 0 })
            .with(ParticleLifetime{
                lifetime_ms: path.len() as f32 * speed,
                animation: Some(ParticleAnimation{
                    step_time: *speed,
                    path: path.to_vec(),
                    current_step: 0,
                    timer: 0.0
                })
            })
            .build();
    }
}
}

如果您现在 cargo run 该项目,您可以瞄准和射击物体 - 并享受一点动画:

Screenshot

让怪物反击

只有玩家拥有弓有点不公平。这也大大降低了游戏的挑战性:您可以射击接近您的物体,但它们无法反击。让我们添加一个新的怪物,强盗弓箭手。它主要是一个 强盗 的副本,但他们拥有一把短弓而不是匕首。在 spawns.json 中:

{ "name" : "Bandit Archer", "weight" : 9, "min_depth" : 2, "max_depth" : 3 },
...
{
    "name" : "Bandit Archer",
    "renderable": {
        "glyph" : "☻",
        "fg" : "#FF5500",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "vision_range" : 6,
    "movement" : "random_waypoint",
    "quips" : [ "Stand and deliver!", "Alright, hand it over" ],
    "attributes" : {},
    "equipped" : [ "Shortbow", "Shield", "Leather Armor", "Leather Boots" ],
    "light" : {
        "range" : 6,
        "color" : "#FFFF55"
    },
    "faction" : "Bandits",
    "gold" : "1d6"
},

我们稍微改变了它们的颜色,并在它们的装备列表中添加了一个 Shortbow。我们已经支持装备生成,所以这应该足以使弓出现在它们的装备中 - 但他们不知道如何使用它。我们已经在 ai/visible_ai_systems.rs 中处理了施法(以及诸如龙息之类的东西)- 所以这是一个考虑添加射击的逻辑位置。我们可以非常简单地添加它:检查是否装备了远程武器,如果有 - 检查射程并生成一个 WantsToShoot。我们将修改反应 Attack

#![allow(unused)]
fn main() {
Reaction::Attack => {
    let range = rltk::DistanceAlg::Pythagoras.distance2d(
        rltk::Point::new(pos.x, pos.y),
        rltk::Point::new(reaction.0 as i32 % map.width, reaction.0 as i32 / map.width)
    );
    if let Some(abilities) = abilities.get(entity) {
        for ability in abilities.abilities.iter() {
            if range >= ability.min_range && range <= ability.range &&
                rng.roll_dice(1,100) <= (ability.chance * 100.0) as i32
            {
                use crate::raws::find_spell_entity_by_name;
                casting.insert(
                    entity,
                    WantsToCastSpell{
                        spell : find_spell_entity_by_name(&ability.spell, &names, &spells, &entities).unwrap(),
                        target : Some(rltk::Point::new(reaction.0 as i32 % map.width, reaction.0 as i32 / map.width))}
                ).expect("Unable to insert");
                done = true;
            }
        }
    }

    if !done {
        for (weapon, equip) in (&weapons, &equipped).join() {
            if let Some(wrange) = weapon.range {
                if equip.owner == entity {
                    rltk::console::log(format!("Owner found. Ranges: {}/{}", wrange, range));
                    if wrange >= range as i32 {
                        rltk::console::log("Inserting shoot");
                        wants_shoot.insert(entity, WantsToShoot{ target: reaction.2 }).expect("Insert fail");
                        done = true;
                    }
                }
            }
        }
    }
    ...
}

如果您现在 cargo run,强盗会反击!

模板化魔法弓

将短弓添加到您的生成列表中:

{ "name" : "Shortbow", "weight" : 2, "min_depth" : 3, "max_depth" : 100 },

您还可以为其添加魔法模板:

{
    "name" : "Shortbow",
    "renderable": {
        "glyph" : ")",
        "fg" : "#FFAAAA",
        "bg" : "#000000",
        "order" : 2
    },
    "weapon" : {
        "range" : "4",
        "attribute" : "Quickness",
        "base_damage" : "1d4",
        "hit_bonus" : 0
    },
    "weight_lbs" : 2.0,
    "base_value" : 5.0,
    "initiative_penalty" : 1,
    "vendor_category" : "weapon",
    "template_magic" : {
        "unidentified_name" : "Unidentified Shortbow",
        "bonus_min" : 1,
        "bonus_max" : 5,
        "include_cursed" : true
    }
},

让黑暗精灵更可怕

所以现在我们可以引入一些地精弓箭手,让洞穴更可怕一些。我们不会在龙/蜥蜴关卡中引入任何远程武器,以稍微平衡一下几率(游戏刚刚变得更容易了!)。我们可以像对强盗一样剪切和粘贴地精:

{
    "name" : "Goblin Archer",
    "renderable": {
        "glyph" : "g",
        "fg" : "#FFFF00",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "vision_range" : 8,
    "movement" : "static",
    "attributes" : {},
    "faction" : "Cave Goblins",
    "gold" : "1d6",
    "equipped" : [ "Shortbow", "Leather Armor", "Leather Boots" ],
},

这使我们达到了本章开始时的目标。我们想给黑暗精灵手弩。我们将首先在 spawns.json 中生成新的武器类型:

{
    "name" : "Hand Crossbow",
    "renderable": {
        "glyph" : ")",
        "fg" : "#FFAAAA",
        "bg" : "#000000",
        "order" : 2
    },
    "weapon" : {
        "range" : "6",
        "attribute" : "Quickness",
        "base_damage" : "1d6",
        "hit_bonus" : 0
    },
    "weight_lbs" : 2.0,
    "base_value" : 5.0,
    "initiative_penalty" : 1,
    "vendor_category" : "weapon",
    "template_magic" : {
        "unidentified_name" : "Unidentified Hand Crossbow",
        "bonus_min" : 1,
        "bonus_max" : 5,
        "include_cursed" : true
    }
},

我们还应该将其添加到生成表中,但仅适用于黑暗精灵关卡:

{ "name" : "Hand Crossbow", "weight" : 2, "min_depth" : 9, "max_depth" : 11 }

最后,我们将其提供给黑暗精灵:

{
    "name" : "Dark Elf",
    "renderable": {
        "glyph" : "e",
        "fg" : "#FF0000",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "vision_range" : 8,
    "movement" : "random_waypoint",
    "attributes" : {},
    "equipped" : [ "Hand Crossbow", "Scimitar", "Buckler", "Drow Chain", "Drow Leggings", "Drow Boots" ],
    "faction" : "DarkElf",
    "gold" : "3d6",
    "level" : 6
},

就这样!当您到达守卫他们城市入口的黑暗精灵时 - 他们现在可以射击您了。我们将在下一章中充实这座城市。

...

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

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

版权所有 (C) 2019, Herbert Wolverson。