远程卷轴和瞄准


关于本教程

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

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

实践中的 Rust


在上一章中,我们添加了物品和库存——以及一种物品类型,即生命药水。现在我们将添加第二种物品类型:魔法飞弹卷轴,它可以让你远程攻击实体。

使用组件来描述物品的功能

在上一章中,我们基本上编写了代码以确保所有物品都是治疗药水。这使事情开始运转,但不是很灵活。因此,我们将从将物品分解为更多组件类型开始。我们将从一个简单的标志组件Consumable开始:

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct Consumable {}
}

拥有此物品表示使用它会销毁它(使用时消耗)。因此,我们将 PotionUseSystem(我们将其重命名为 ItemUseSystem!)中总是调用的 entities.delete(useitem.item).expect("Delete failed"); 替换为:

#![allow(unused)]
fn main() {
let consumable = consumables.get(useitem.item);
match consumable {
    None => {}
    Some(_) => {
        entities.delete(useitem.item).expect("Delete failed");
    }
}
}

这很简单:检查组件是否有Consumable标签,如果有就销毁它。同样,我们可以用ProvidesHealing替换Potion部分,以表明这是药水实际的作用。在components.rs中:

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct ProvidesHealing {
    pub heal_amount : i32
}
}

在我们的 ItemUseSystem 中:

#![allow(unused)]
fn main() {
let item_heals = healing.get(useitem.item);
match item_heals {
    None => {}
    Some(healer) => {
        stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount);
        if entity == *player_entity {
            gamelog.entries.push(format!("You drink the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount));
        }
    }
}
}

综合起来,我们创建药水的代码(在 spawner.rs 中)如下所示:

#![allow(unused)]
fn main() {
fn health_potion(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('¡'),
            fg: RGB::named(rltk::MAGENTA),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Health Potion".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(ProvidesHealing{ heal_amount: 8 })
        .build();
}
}

所以我们描述了它的位置、外观、名称,表示它是一个物品,使用时会被消耗,并提供 8 点治疗。这描述得很详细——未来的物品可以混合/匹配。随着我们添加组件,物品系统将变得越来越灵活。

描述远程魔法飞弹卷轴

我们需要添加一些更多的组件!在 components.rs(并在 main.rs 中注册):

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct Ranged {
    pub range : i32
}

#[derive(Component, Debug)]
pub struct InflictsDamage {
    pub damage : i32
}
}

这反过来让我们可以在 spawner.rs 中编写一个 magic_missile_scroll 函数,该函数有效地描述了卷轴:

#![allow(unused)]
fn main() {
fn magic_missile_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Magic Missile Scroll".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(Ranged{ range: 6 })
        .with(InflictsDamage{ damage: 8 })
        .build();
}
}

这清楚地列出了它的特性:它有一个位置,一个外观,一个名字,它是一个使用后会被销毁的物品,它的范围是 6 个格子,并造成 8 点伤害。这就是我喜欢组件的地方:过了一段时间,听起来更像是在描述一个设备的蓝图,而不是写很多行代码!

我们将继续将它们添加到生成列表中:

#![allow(unused)]
fn main() {
fn random_item(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 2);
    }
    match roll {
        1 => { health_potion(ecs, x, y) }
        _ => { magic_missile_scroll(ecs, x, y) }
    }
}
}

在物品生成代码中,将调用 health_potion 替换为调用 random_item

如果你现在运行程序(使用 cargo run),你会发现周围有卷轴和药水。组件系统已经提供了相当多的功能:

  • 你可以在地图上看到它们(感谢 RenderablePosition
  • 你可以捡起它们并放下它们(感谢 Item
  • 你可以在你的库存中列出它们
  • 你可以对它们调用 use,它们会被销毁:但什么也不会发生。

截图

为物品实现范围伤害

我们希望魔法飞弹能够被瞄准:你激活它,然后必须选择一个受害者。这将是一种新的输入模式,所以我们再次在 main.rs 中扩展 RunState

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, 
    ShowTargeting { range : i32, item : Entity} }
}

我们将在 main.rs 中扩展 ShowInventory 的处理程序,以处理远程物品并引发模式切换:

#![allow(unused)]
fn main() {
RunState::ShowInventory => {
    let result = gui::show_inventory(self, ctx);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let item_entity = result.1.unwrap();
            let is_ranged = self.ecs.read_storage::<Ranged>();
            let is_item_ranged = is_ranged.get(item_entity);
            if let Some(is_item_ranged) = is_item_ranged {
                newrunstate = RunState::ShowTargeting{ range: is_item_ranged.range, item: item_entity };
            } else {
                let mut intent = self.ecs.write_storage::<WantsToUseItem>();
                intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item: item_entity, target: None }).expect("Unable to insert intent");
                newrunstate = RunState::PlayerTurn;
            }
        }
    }
}
}

所以在 main.rs 中,当我们匹配适当的游戏模式时,我们可以暂时插入:

#![allow(unused)]
fn main() {
RunState::ShowTargeting{range, item} => {
    let target = gui::ranged_target(self, ctx, range);
}
}

这自然会导致实际编写 gui::ranged_target。这看起来很复杂,但实际上非常简单:

#![allow(unused)]
fn main() {
pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) {
    let player_entity = gs.ecs.fetch::<Entity>();
    let player_pos = gs.ecs.fetch::<Point>();
    let viewsheds = gs.ecs.read_storage::<Viewshed>();

    ctx.print_color(5, 0, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Select Target:");

    // Highlight available target cells
    let mut available_cells = Vec::new();
    let visible = viewsheds.get(*player_entity);
    if let Some(visible) = visible {
        // We have a viewshed
        for idx in visible.visible_tiles.iter() {
            let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx);
            if distance <= range as f32 {
                ctx.set_bg(idx.x, idx.y, RGB::named(rltk::BLUE));
                available_cells.push(idx);
            }
        }
    } else {
        return (ItemMenuResult::Cancel, None);
    }

    // Draw mouse cursor
    let mouse_pos = ctx.mouse_pos();
    let mut valid_target = false;
    for idx in available_cells.iter() { if idx.x == mouse_pos.0 && idx.y == mouse_pos.1 { valid_target = true; } }
    if valid_target {
        ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::CYAN));
        if ctx.left_click {
            return (ItemMenuResult::Selected, Some(Point::new(mouse_pos.0, mouse_pos.1)));
        }
    } else {
        ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED));
        if ctx.left_click {
            return (ItemMenuResult::Cancel, None);
        }
    }

    (ItemMenuResult::NoResponse, None)
}
}

所以我们首先获取玩家的位置和视野,并迭代他们可以看到的单元格。我们检查单元格的范围与物品的范围,如果在范围内,我们将单元格高亮为蓝色。我们还维护一个可能目标单元格的列表。然后,我们获取鼠标位置;如果指向有效目标,我们将其高亮为青色——否则使用红色。如果你点击一个有效单元格,它会返回你瞄准的目标信息——否则,它会取消。

现在我们将 ShowTargeting 代码扩展以处理这种情况:

#![allow(unused)]
fn main() {
RunState::ShowTargeting{range, item} => {
    let result = gui::ranged_target(self, ctx, range);
    match result.0 {
        gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::ItemMenuResult::NoResponse => {}
        gui::ItemMenuResult::Selected => {
            let mut intent = self.ecs.write_storage::<WantsToUseItem>();
            intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item, target: result.1 }).expect("Unable to insert intent");
            newrunstate = RunState::PlayerTurn;
        }
    }
}
}

这是什么target?我在components.rs中的WantsToUseItem添加了另一个字段:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, ConvertSaveload, Clone)]
pub struct WantsToUseItem {
    pub item : Entity,
    pub target : Option<rltk::Point>
}
}

所以现在当你收到一个 WantsToUseItem 时,你可以知道 用户 是拥有实体,物品item 字段,并且它瞄准的是 target - 如果有的话(瞄准对治疗药水来说没有太大意义!)。

所以现在我们可以为我们的 ItemUseSystem 添加另一个条件:

#![allow(unused)]
fn main() {
// If it inflicts damage, apply it to the target cell
let item_damages = inflict_damage.get(useitem.item);
match item_damages {
    None => {}
    Some(damage) => {
        let target_point = useitem.target.unwrap();
        let idx = map.xy_idx(target_point.x, target_point.y);
        used_item = false;
        for mob in map.tile_content[idx].iter() {
            SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage);
            if entity == *player_entity {
                let mob_name = names.get(*mob).unwrap();
                let item_name = names.get(useitem.item).unwrap();
                gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
            }

            used_item = true;
        }
    }
}
}

这检查物品上是否有造成伤害组件——如果有,则对目标单元格内的所有人施加伤害。

如果你运行游戏,你现在可以用你的魔法飞弹卷轴攻击实体!

介绍效果范围

我们将添加另一种滚动类型 - 火球。这是一个老牌经典,并引入了 AoE - 区域效果 - 伤害。我们将首先添加一个组件来表明我们的意图:

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct AreaOfEffect {
    pub radius : i32
}
}

我们将扩展spawner.rs中的random_item函数,使其成为一个选项:

#![allow(unused)]
fn main() {
fn random_item(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 3);
    }
    match roll {
        1 => { health_potion(ecs, x, y) }
        2 => { fireball_scroll(ecs, x, y) }
        _ => { magic_missile_scroll(ecs, x, y) }
    }
}
}

所以现在我们可以编写一个fireball_scroll函数来实际生成它们。这与其他物品非常相似:

#![allow(unused)]
fn main() {
fn fireball_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::ORANGE),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Fireball Scroll".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(Ranged{ range: 6 })
        .with(InflictsDamage{ damage: 20 })
        .with(AreaOfEffect{ radius: 3 })
        .build();
}
}

请注意,基本上是一样的——但我们正在添加一个AreaOfEffect组件,以表明这是我们想要的。如果你现在运行cargo run,你会在游戏中看到火球卷轴——它们会对单个实体造成伤害。显然,我们必须解决这个问题!

在我们的 UseItemSystem 中,我们将构建一个新部分来确定效果的目标列表:

#![allow(unused)]
fn main() {
// Targeting
let mut targets : Vec<Entity> = Vec::new();
match useitem.target {
    None => { targets.push( *player_entity ); }
    Some(target) => {
        let area_effect = aoe.get(useitem.item);
        match area_effect {
            None => {
                // Single target in tile
                let idx = map.xy_idx(target.x, target.y);
                for mob in map.tile_content[idx].iter() {
                    targets.push(*mob);
                }
            }
            Some(area_effect) => {
                // AoE
                let mut blast_tiles = rltk::field_of_view(target, area_effect.radius, &*map);
                blast_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );
                for tile_idx in blast_tiles.iter() {
                    let idx = map.xy_idx(tile_idx.x, tile_idx.y);
                    for mob in map.tile_content[idx].iter() {
                        targets.push(*mob);
                    }
                }
            }
        }
    }
}

}

这表示“如果没有目标,则将其应用于玩家”。如果目标,检查它是否是范围效果事件;如果是 - 从该点绘制适当半径的视野,并添加目标区域中的每个实体。如果不是,我们只需获取目标图块中的实体。

现在我们需要使效果代码通用化。我们不想假设效果是独立的;以后我们可能会决定用卷轴攻击某物会产生各种效果!所以对于治疗,它看起来像这样:

#![allow(unused)]
fn main() {
// If it heals, apply the healing
let item_heals = healing.get(useitem.item);
match item_heals {
    None => {}
    Some(healer) => {
        for target in targets.iter() {
            let stats = combat_stats.get_mut(*target);
            if let Some(stats) = stats {
                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));
                }
            }                        
        }
    }
}
}

伤害代码实际上已经简化,因为我们已经计算了目标:

#![allow(unused)]
fn main() {
// If it inflicts damage, apply it to the target cell
let item_damages = inflict_damage.get(useitem.item);
match item_damages {
    None => {}
    Some(damage) => {
        used_item = false;
        for mob in targets.iter() {
            SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage);
            if entity == *player_entity {
                let mob_name = names.get(*mob).unwrap();
                let item_name = names.get(useitem.item).unwrap();
                gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
            }

            used_item = true;
        }
    }
}
}

如果你现在 cargo run 这个项目,你可以使用魔法飞弹卷轴、火球卷轴和治疗药水。

混乱卷轴

让我们添加另一个物品 - 混乱卷轴。这些将对范围内的单个实体生效,并使它们在几个回合内陷入混乱 - 在此期间它们将什么也不做。我们将从描述我们希望在物品生成代码中实现的内容开始:

#![allow(unused)]
fn main() {
fn confusion_scroll(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437(')'),
            fg: RGB::named(rltk::PINK),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Confusion Scroll".to_string() })
        .with(Item{})
        .with(Consumable{})
        .with(Ranged{ range: 6 })
        .with(Confusion{ turns: 4 })
        .build();
}
}

我们也会将其添加到选项中:

#![allow(unused)]
fn main() {
fn random_item(ecs: &mut World, x: i32, y: i32) {
    let roll :i32;
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        roll = rng.roll_dice(1, 4);
    }
    match roll {
        1 => { health_potion(ecs, x, y) }
        2 => { fireball_scroll(ecs, x, y) }
        3 => { confusion_scroll(ecs, x, y) }
        _ => { magic_missile_scroll(ecs, x, y) }
    }
}
}

我们将添加一个新组件(并注册它!):

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct Confusion {
    pub turns : i32
}
}

这足以使它们出现、可触发并导致目标选择发生——但使用时不会发生任何事情。我们将添加将混乱传递给 ItemUseSystem 的能力:

#![allow(unused)]
fn main() {
// Can it pass along confusion? Note the use of scopes to escape from the borrow checker!
let mut add_confusion = Vec::new();
{
    let causes_confusion = confused.get(useitem.item);
    match causes_confusion {
        None => {}
        Some(confusion) => {
            used_item = false;
            for mob in targets.iter() {
                add_confusion.push((*mob, confusion.turns ));
                if entity == *player_entity {
                    let mob_name = names.get(*mob).unwrap();
                    let item_name = names.get(useitem.item).unwrap();
                    gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name));
                }
            }
        }
    }
}
for mob in add_confusion.iter() {
    confused.insert(mob.0, Confusion{ turns: mob.1 }).expect("Unable to insert status");
}
}

好的!现在我们可以将 Confused 状态添加到任何东西上。我们应该更新 monster_ai_system 以使用它。将循环替换为:

#![allow(unused)]
fn main() {
for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() {
    let mut can_act = true;

    let is_confused = confused.get_mut(entity);
    if let Some(i_am_confused) = is_confused {
        i_am_confused.turns -= 1;
        if i_am_confused.turns < 1 {
            confused.remove(entity);
        }
        can_act = false;
    }

    if can_act {
        let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos);
        if distance < 1.5 {
            wants_to_melee.insert(entity, WantsToMelee{ target: *player_entity }).expect("Unable to insert attack");
        }
        else if viewshed.visible_tiles.contains(&*player_pos) {
            // Path to the player
            let path = rltk::a_star_search(
                map.xy_idx(pos.x, pos.y),
                map.xy_idx(player_pos.x, player_pos.y),
                &mut *map
            );
            if path.success && path.steps.len()>1 {
                let mut idx = map.xy_idx(pos.x, pos.y);
                map.blocked[idx] = false;
                pos.x = path.steps[1] as i32 % map.width;
                pos.y = path.steps[1] as i32 / map.width;
                idx = map.xy_idx(pos.x, pos.y);
                map.blocked[idx] = true;
                viewshed.dirty = true;
            }
        }
    }
}
}

如果看到一个Confused组件,它会减少计时器。如果计时器达到 0,它会移除它。然后返回,使怪物跳过它的回合。

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

运行本章的示例与 Web Assembly,在您的浏览器中(需要 WebGL2)


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.