导弹和远程攻击
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon。
当您阅读关于黑暗精灵的小说时,他们通常会偷偷地从黑暗中发射导弹武器。这实际上是他们被包含在本教程书中的原因:他们为拓展到精彩的远程战斗世界提供了一个很好的理由。我们已经有了一些远程战斗:法术效果可以发生在远处,但目标选择系统有点笨拙 - 而且对于弓箭决斗来说一点也不符合人体工程学。因此,在本章中,我们将介绍远程武器,并使黑暗精灵更可怕一些。我们还将尝试改进导弹的粒子效果,以便玩家可以看到发生了什么。
引入远程武器
我们将稍微作弊一下,不担心弹药;有些游戏会计算每一支箭,对于远程战斗角色来说,保持箭袋装满箭矢可能非常重要。我们将专注于远程武器方面,并假设弹药充足;这不是最现实的选择,但可以使事情易于管理!
定义短弓
让我们首先打开 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.rs
,saveload_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); } } } }
这是一个好的开始,因为现在我们正在告诉用户他们拥有一件远程武器(并且通常显示武器升级的即时结果是好的!):
那么,现在让玩家可以轻松地瞄准敌人!我们将从制作一个 Target
组件开始。在 components.rs
中(和往常一样,在 main.rs
和 saveload_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 } }
这是一个稍微复杂的函数,所以让我们逐步了解它:
- 我们创建一个空的结果列表,其中包含可作为目标的实体及其与玩家的距离。
- 我们迭代已装备的武器,以查看玩家是否拥有远程武器。
- 如果他们有,我们记下它的射程。
- 然后我们查看他们的视野,并检查每个瓦片是否在武器的射程内。
- 如果它在射程内,我们通过
tile_content
系统查看该瓦片中的实体。如果该实体实际上是一个有效的目标(他们拥有Faction
成员资格),我们会将它们添加到可能的目标列表中。 - 我们按射程对可能的目标列表进行排序。
现在我们需要在玩家移动时选择一个新目标。我们将选择最近的目标,因为您更有可能瞄准直接威胁。以下函数完成了这项工作:
#![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
,您可以装备您的弓并开始瞄准:
射击物体
我们有一个完善的战斗模式:使用 WantsToMelee
组件标记动作,然后它会在 MeleeCombatSystem
中被拾取。我们对想要接近、使用技能或物品的情况使用了类似的模式 - 因此对于想要射击的情况,我们再次这样做是有道理的。在 components.rs
中(并在 main.rs
和 saveload_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_step
和 timer
将用于跟踪抛射物的进度。
您需要进入 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
该项目,您可以瞄准和射击物体 - 并享受一点动画:
让怪物反击
只有玩家拥有弓有点不公平。这也大大降低了游戏的挑战性:您可以射击接近您的物体,但它们无法反击。让我们添加一个新的怪物,强盗弓箭手。它主要是一个 强盗 的副本,但他们拥有一把短弓而不是匕首。在 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。