造成伤害(并承受一些!)


关于本教程

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

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

实践中的 Rust


现在我们有了怪物,我们希望它们不仅仅是站在控制台上对你大喊大叫!本章将使它们追逐你,并引入一些基本的游戏统计数据,让你能够杀出重围。

追逐玩家

我们需要做的第一件事是为我们的Map类完成实现BaseMap。特别是,我们需要支持get_available_exits——这是用于路径查找的。

在我们的Map实现中,我们需要一个辅助函数:

#![allow(unused)]
fn main() {
fn is_exit_valid(&self, x:i32, y:i32) -> bool {
    if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; }
    let idx = self.xy_idx(x, y);
    self.tiles[idx as usize] != TileType::Wall
}
}

这需要一个索引,并计算它是否可以进入。

然后我们使用这个辅助工具来实现特征:

#![allow(unused)]
fn main() {
fn get_available_exits(&self, idx:usize) -> rltk::SmallVec<[(usize, f32); 10]> {
    let mut exits = rltk::SmallVec::new();
    let x = idx as i32 % self.width;
    let y = idx as i32 / self.width;
    let w = self.width as usize;

    // Cardinal directions
    if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
    if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
    if self.is_exit_valid(x, y-1) { exits.push((idx-w, 1.0)) };
    if self.is_exit_valid(x, y+1) { exits.push((idx+w, 1.0)) };

    exits
}
}

提供没有距离启发式的出口会导致一些糟糕的行为(并且在 RLTK 的未来版本中会导致崩溃)。因此,也要为你的地图实现这一点:

#![allow(unused)]
fn main() {
impl BaseMap for Map {
    ...
    fn get_pathing_distance(&self, idx1:usize, idx2:usize) -> f32 {
        let w = self.width as usize;
        let p1 = Point::new(idx1 % w, idx1 / w);
        let p2 = Point::new(idx2 % w, idx2 / w);
        rltk::DistanceAlg::Pythagoras.distance2d(p1, p2)
    }
}

相当直接:我们评估每个可能的出口,如果可以通行,则将其添加到 exits 向量中。接下来,我们修改 monster_ai_system 中的主循环:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Viewshed, Monster, Name, Map, Position};
use rltk::{Point, console};

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadExpect<'a, Point>,
                        WriteStorage<'a, Viewshed>,
                        ReadStorage<'a, Monster>,
                        ReadStorage<'a, Name>,
                        WriteStorage<'a, Position>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, player_pos, mut viewshed, monster, name, mut position) = data;

        for (mut viewshed,_monster,name,mut pos) in (&mut viewshed, &monster, &name, &mut position).join() {
            if viewshed.visible_tiles.contains(&*player_pos) {
                console::log(&format!("{} shouts insults", name.name));
                let path = rltk::a_star_search(
                    map.xy_idx(pos.x, pos.y) as i32,
                    map.xy_idx(player_pos.x, player_pos.y) as i32,
                    &mut *map
                );
                if path.success && path.steps.len()>1 {
                    pos.x = path.steps[1] as i32 % map.width;
                    pos.y = path.steps[1] as i32 / map.width;
                    viewshed.dirty = true;
                }
            }
        }
    }
}
}

我们更改了一些内容以允许写访问,请求访问地图。我们还添加了一个 #[allow...] 来告诉代码检查工具我们确实打算在一个类型中使用这么多内容!核心是 a_star_search 调用;RLTK 包含一个高性能的 A* 实现,所以我们要求它从怪物的位置到玩家的路径。然后我们检查路径是否成功,并且有超过 2 步(第 0 步总是当前位置)。如果是,那么我们将怪物移动到该点,并将其视野设置为脏状态。

如果你运行项目,怪物现在会追逐玩家——如果失去视线就会停止。我们没有阻止怪物互相站立——或者你——我们也没有让它们做任何其他事情,只是对着你的控制台大喊——但这是一个好的开始。追逐机制并不难实现!

阻止访问

我们不希望怪物互相踩踏,也不希望它们在寻找玩家时陷入交通堵塞;我们宁愿它们愿意尝试包抄玩家!我们将通过跟踪地图上哪些部分被阻挡来辅助这一点。

首先,我们将向我们的 Map 添加另一个布尔向量:

#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>
}
}

我们也会初始化它,就像其他向量一样:

#![allow(unused)]
fn main() {
let mut map = Map{
    tiles : vec![TileType::Wall; 80*50],
    rooms : Vec::new(),
    width : 80,
    height: 50,
    revealed_tiles : vec![false; 80*50],
    visible_tiles : vec![false; 80*50],
    blocked : vec![false; 80*50]
};
}

让我们引入一个新功能来填充一个瓦片是否被阻挡。在Map实现中:

#![allow(unused)]
fn main() {
pub fn populate_blocked(&mut self) {
    for (i,tile) in self.tiles.iter_mut().enumerate() {
        self.blocked[i] = *tile == TileType::Wall;
    }
}
}

这个功能非常简单:如果是墙壁,则将瓦片的 blocked 设置为 true,否则设置为 false(当我们添加更多瓦片类型时会扩展它)。在处理 Map 时,让我们调整 is_exit_valid 以使用这些数据:

#![allow(unused)]
fn main() {
fn is_exit_valid(&self, x:i32, y:i32) -> bool {
    if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; }
    let idx = self.xy_idx(x, y);
    !self.blocked[idx]
}
}

这很简单:它检查 xy 是否在地图范围内,如果出口在地图外则返回 false(这种 边界检查 是值得做的,它可以防止你的程序因为试图读取有效内存区域外的内容而崩溃)。然后它检查瓦片数组中指定坐标的 索引,并返回 blocked反值! 在大多数语言中与 not 相同 - 所以可以读作“在 idx 处未被阻挡”)。

现在我们将创建一个新的组件,BlocksTile。你现在应该知道怎么做;在 Components.rs 中:

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

然后在 main.rs 中注册它:gs.ecs.register::<BlocksTile>();

我们应该将 BlocksTile 应用于 NPC - 因此我们的 NPC 创建代码变为:

#![allow(unused)]
fn main() {
gs.ecs.create_entity()
    .with(Position{ x, y })
    .with(Renderable{
        glyph,
        fg: RGB::named(rltk::RED),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
    .with(Monster{})
    .with(Name{ name: format!("{} #{}", &name, i) })
    .with(BlocksTile{})
    .build();
}

最后,我们需要填充阻止列表。我们可能会在以后扩展这个系统,所以我们选择一个很好的通用名称 map_indexing_system.rs

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Map, Position, BlocksTile};

pub struct MapIndexingSystem {}

impl<'a> System<'a> for MapIndexingSystem {
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, BlocksTile>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, position, blockers) = data;

        map.populate_blocked();
        for (position, _blocks) in (&position, &blockers).join() {
            let idx = map.xy_idx(position.x, position.y);
            map.blocked[idx] = true;
        }
    }
}
}

这告诉地图从地形设置阻挡,然后遍历所有带有 BlocksTile 组件的实体,并将它们应用到阻挡列表中。我们需要在 main.rs 中使用 run_systems 注册它。

#![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);
        self.ecs.maintain();
    }
}
}

如果你现在运行 cargo run,怪物不再重叠在一起——但它们确实会出现在玩家上方。我们应该修复这个问题。我们可以让怪物只在靠近玩家时才大叫。在 monster_ai_system.rs 中,在可见性测试上方添加以下内容:

#![allow(unused)]
fn main() {
let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos);
if distance < 1.5 {
    // Attack goes here
    console::log(&format!("{} shouts insults", name.name));
    return;
}
}

最后,我们希望阻止玩家走过怪物。在 player.rs 中,我们将查找墙壁的 if 语句替换为:

#![allow(unused)]
fn main() {
if !map.blocked[destination_idx] {
}

由于我们已经将墙壁加入到阻止列表中,这应该暂时解决了这个问题。cargo run显示怪物现在会阻挡玩家。它们阻挡得完美无缺——所以一个想要挡在你面前的怪物是一个无法通过的障碍!

允许对角移动

能够绕过怪物会很不错——而对角线移动是 Roguelike 游戏的主要特点。所以让我们继续支持它。在 map.rsget_available_exits 函数中,我们添加它们:

#![allow(unused)]
fn main() {
fn get_available_exits(&self, idx:usize) -> rltk::SmallVec<[(usize, f32); 10]> {
    let mut exits = rltk::SmallVec::new();
    let x = idx as i32 % self.width;
    let y = idx as i32 / self.width;
    let w = self.width as usize;

    // Cardinal directions
    if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
    if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
    if self.is_exit_valid(x, y-1) { exits.push((idx-w, 1.0)) };
    if self.is_exit_valid(x, y+1) { exits.push((idx+w, 1.0)) };

    // Diagonals
    if self.is_exit_valid(x-1, y-1) { exits.push(((idx-w)-1, 1.45)); }
    if self.is_exit_valid(x+1, y-1) { exits.push(((idx-w)+1, 1.45)); }
    if self.is_exit_valid(x-1, y+1) { exits.push(((idx+w)-1, 1.45)); }
    if self.is_exit_valid(x+1, y+1) { exits.push(((idx+w)+1, 1.45)); }

    exits
}
}

我们还修改了 player.rs 输入代码:

#![allow(unused)]
fn main() {
pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState {
    // Player movement
    match ctx.key {
        None => { return RunState::Paused } // Nothing happened
        Some(key) => match key {
            VirtualKeyCode::Left |
            VirtualKeyCode::Numpad4 |
            VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs),

            VirtualKeyCode::Right |
            VirtualKeyCode::Numpad6 |
            VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs),

            VirtualKeyCode::Up |
            VirtualKeyCode::Numpad8 |
            VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs),

            VirtualKeyCode::Down |
            VirtualKeyCode::Numpad2 |
            VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs),

            // Diagonals
            VirtualKeyCode::Numpad9 |
            VirtualKeyCode::Y => try_move_player(1, -1, &mut gs.ecs),

            VirtualKeyCode::Numpad7 |
            VirtualKeyCode::U => try_move_player(-1, -1, &mut gs.ecs),

            VirtualKeyCode::Numpad3 |
            VirtualKeyCode::N => try_move_player(1, 1, &mut gs.ecs),

            VirtualKeyCode::Numpad1 |
            VirtualKeyCode::B => try_move_player(-1, 1, &mut gs.ecs),

            _ => { return RunState::Paused }
        },
    }
    RunState::Running
}
}

你现在可以斜向躲避怪物 - 它们也可以斜向移动/攻击。

给怪物和玩家一些战斗属性

你可能已经猜到了,添加属性到实体的方法是通过另一个组件!在 components.rs 中,我们添加 CombatStats。以下是一个简单的定义:

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct CombatStats {
    pub max_hp : i32,
    pub hp : i32,
    pub defense : i32,
    pub power : i32
}
}

像往常一样,别忘了在 main.rs 中注册它!

我们将给Player 30 点生命值,2 点防御和 5 点力量:

#![allow(unused)]
fn main() {
.with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
}

同样,我们会给怪物一组较弱的属性(我们稍后再考虑怪物的差异化):

#![allow(unused)]
fn main() {
.with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 })
}

索引什么在哪里

在地图上旅行时——无论是作为玩家还是怪物——了解一个格子里的内容非常方便。你可以将其与可见性系统结合,以便对可见的事物做出明智的选择,你可以用它来查看是否试图进入敌人的空间(并攻击他们),等等。一种方法是迭代Position组件并查看我们是否击中了任何东西;对于少量实体来说,这已经足够快了。我们将采取不同的方法,并让map_indexing_system帮助我们。我们将首先在地图中添加一个字段:

#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub tile_content : Vec<Vec<Entity>>
}
}

并且我们将为新地图代码添加一个基本初始化器:

#![allow(unused)]
fn main() {
tile_content : vec![Vec::new(); 80*50]
}

当我们在 map 中时,我们还需要一个函数:

#![allow(unused)]
fn main() {
pub fn clear_content_index(&mut self) {
    for content in self.tile_content.iter_mut() {
        content.clear();
    }
}

}

这也很简单:它迭代(访问)tile_content 列表中的每个向量,可变地(iter_mut 获取一个可变迭代器)。然后它告诉每个向量清除自身——移除所有内容(它实际上并不保证会释放内存;向量可以保留空的部分以备更多数据。这实际上是一件好事,因为获取新内存是程序可以做的最慢的事情之一——所以它有助于保持运行速度)。

然后我们将升级索引系统,按图块索引所有实体:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Map, Position, BlocksTile};

pub struct MapIndexingSystem {}

impl<'a> System<'a> for MapIndexingSystem {
    type SystemData = ( WriteExpect<'a, Map>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, BlocksTile>,
                        Entities<'a>,);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, position, blockers, entities) = data;

        map.populate_blocked();
        map.clear_content_index();
        for (entity, position) in (&entities, &position).join() {
            let idx = map.xy_idx(position.x, position.y);

            // If they block, update the blocking list
            let _p : Option<&BlocksTile> = blockers.get(entity);
            if let Some(_p) = _p {
                map.blocked[idx] = true;
            }

            // Push the entity to the appropriate index slot. It's a Copy
            // type, so we don't need to clone it (we want to avoid moving it out of the ECS!)
            map.tile_content[idx].push(entity);
        }
    }
}
}

让玩家进行攻击

大多数 Roguelike 游戏角色花费大量时间攻击事物,所以让我们实现这一点!碰撞攻击(走进目标)是实现这一点的典型方式。我们希望扩展player.rs中的try_move_player,以检查我们试图进入的瓷砖是否包含目标。

我们将为CombatStats添加一个读取器到数据存储列表中,并快速插入一个敌人检测器:

#![allow(unused)]
fn main() {
let combat_stats = ecs.read_storage::<CombatStats>();
let map = ecs.fetch::<Map>();

for (_player, pos, viewshed) in (&mut players, &mut positions, &mut viewsheds).join() {
    let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);

    for potential_target in map.tile_content[destination_idx].iter() {
        let target = combat_stats.get(*potential_target);
        match target {
            None => {}
            Some(t) => {
                // Attack it
                console::log(&format!("From Hell's Heart, I stab thee!"));
                return; // So we don't move after attacking
            }
        }
    }
}

如果你 cargo run 这个,你会看到你可以走到一个生物旁边并试图移动到它上面。从地狱的心脏,我刺向你! 出现在控制台上。所以检测是有效的,攻击也在正确的位置。

玩家攻击和杀死事物

我们将以 ECS 的方式进行,所以有一些样板代码。在components.rs中,我们添加一个表示攻击意图的组件:

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

我们也希望跟踪传入的伤害。在一个回合中,你可能会从多个来源受到伤害,而 Specs 非常不喜欢你在实体上尝试使用多个相同类型的组件。这里有两种可能的方法:将伤害本身作为一个实体(并跟踪受害者),或者将伤害作为一个向量。后者似乎更容易实现;因此我们将创建一个SufferDamage组件来跟踪伤害,并附加/实现一个方法以便于使用:

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct SufferDamage {
    pub amount : Vec<i32>
}

impl SufferDamage {
    pub fn new_damage(store: &mut WriteStorage<SufferDamage>, victim: Entity, amount: i32) {
        if let Some(suffering) = store.get_mut(victim) {
            suffering.amount.push(amount);
        } else {
            let dmg = SufferDamage { amount : vec![amount] };
            store.insert(victim, dmg).expect("Unable to insert damage");
        }
    }
}
}

(别忘了在main.rs中注册它们!)。我们修改玩家的移动命令以创建一个表示攻击意图的组件(将wants_to_melee附加到攻击者上):

#![allow(unused)]
fn main() {
let entities = ecs.entities();
let mut wants_to_melee = ecs.write_storage::<WantsToMelee>();

for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() {
    if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return; }
    let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);

    for potential_target in map.tile_content[destination_idx].iter() {
        let target = combat_stats.get(*potential_target);
        if let Some(_target) = target {
            wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed");
            return;
        }
    }
}
}

我们需要一个近战战斗系统来处理近战。这使用了我们创建的新伤害系统,以确保在一个回合中可以应用多个伤害来源:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{CombatStats, WantsToMelee, Name, SufferDamage};

pub struct MeleeCombatSystem {}

impl<'a> System<'a> for MeleeCombatSystem {
    type SystemData = ( Entities<'a>,
                        WriteStorage<'a, WantsToMelee>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut wants_melee, names, combat_stats, mut inflict_damage) = data;

        for (_entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() {
            if stats.hp > 0 {
                let target_stats = combat_stats.get(wants_melee.target).unwrap();
                if target_stats.hp > 0 {
                    let target_name = names.get(wants_melee.target).unwrap();

                    let damage = i32::max(0, stats.power - target_stats.defense);

                    if damage == 0 {
                        console::log(&format!("{} is unable to hurt {}", &name.name, &target_name.name));
                    } else {
                        console::log(&format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));
                        SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage);
                    }
                }
            }
        }

        wants_melee.clear();
    }
}
}

我们需要一个damage_system来应用伤害(我们将其分开,因为伤害可能来自多种来源!)。我们使用迭代器来求和伤害,确保所有伤害都被应用:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{CombatStats, SufferDamage};

pub struct DamageSystem {}

impl<'a> System<'a> for DamageSystem {
    type SystemData = ( WriteStorage<'a, CombatStats>,
                        WriteStorage<'a, SufferDamage> );

    fn run(&mut self, data : Self::SystemData) {
        let (mut stats, mut damage) = data;

        for (mut stats, damage) in (&mut stats, &damage).join() {
            stats.hp -= damage.amount.iter().sum::<i32>();
        }

        damage.clear();
    }
}
}

我们还将添加一个方法来清理死亡的实体:

#![allow(unused)]
fn main() {
pub fn delete_the_dead(ecs : &mut World) {
    let mut dead : Vec<Entity> = Vec::new();
    // Using a scope to make the borrow checker happy
    {
        let combat_stats = ecs.read_storage::<CombatStats>();
        let entities = ecs.entities();
        for (entity, stats) in (&entities, &combat_stats).join() {
            if stats.hp < 1 { dead.push(entity); }
        }
    }

    for victim in dead {
        ecs.delete_entity(victim).expect("Unable to delete");
    }
}
}

这是在我们运行系统后从 tick 命令调用的:damage_system::delete_the_dead(&mut self.ecs);

如果你现在 cargo run,你可以在地图上四处跑动并击打物体——它们死后会消失!

让怪物击中你

由于我们已经编写了处理攻击和伤害的系统,因此使用相同的代码来处理怪物相对容易——只需添加一个WantsToMelee组件,它们就可以攻击/杀死玩家。

我们将首先将玩家实体变成一个游戏资源,这样它可以很容易地被引用。像玩家的位置一样,这是我们可能需要在各处使用的东西——而且由于实体 ID 是稳定的,我们可以依赖它的存在。在main.rs中,我们将玩家的create_entity改为返回实体对象:

#![allow(unused)]
fn main() {
let player_entity = gs.ecs
    .create_entity()
    .with(Position { x: player_x, y: player_y })
    .with(Renderable {
        glyph: rltk::to_cp437('@'),
        fg: RGB::named(rltk::YELLOW),
        bg: RGB::named(rltk::BLACK),
    })
    .with(Player{})
    .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
    .with(Name{name: "Player".to_string() })
    .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
    .build();
}

然后我们将其插入到世界中:

#![allow(unused)]
fn main() {
gs.ecs.insert(player_entity);
}

现在我们修改 monster_ai_system。这里有一些清理工作,并且“投掷侮辱”代码被完全替换为一个单一组件插入:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Viewshed, Monster, Map, Position, WantsToMelee, RunState};
use rltk::{Point};

pub struct MonsterAI {}

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>);

    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) = data;

        for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() {
            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;
                }
            }
        }
    }
}
}

如果你现在运行 cargo run,你可以杀死怪物——它们也可以攻击你。如果一个怪物杀死了你——游戏会崩溃!它会崩溃,因为 delete_the_dead 删除了玩家。这显然不是我们想要的结果。以下是一个不会崩溃的 delete_the_dead 版本:

#![allow(unused)]
fn main() {
pub fn delete_the_dead(ecs : &mut World) {
    let mut dead : Vec<Entity> = Vec::new();
    // Using a scope to make the borrow checker happy
    {
        let combat_stats = ecs.read_storage::<CombatStats>();
        let players = ecs.read_storage::<Player>();
        let entities = ecs.entities();
        for (entity, stats) in (&entities, &combat_stats).join() {
            if stats.hp < 1 { 
                let player = players.get(entity);
                match player {
                    None => dead.push(entity),
                    Some(_) => console::log("You are dead")
                }
            }
        }
    }

    for victim in dead {
        ecs.delete_entity(victim).expect("Unable to delete");
    }    
}
}

我们将在后面的章节中讨论结束游戏的问题。

扩展转向系统

如果你仔细观察,你会发现即使敌人已经受到致命伤害,他们仍然可以反击。虽然这与某些莎士比亚戏剧相符(他们真的应该发表演讲),但这不是 rogue 类游戏鼓励的战术玩法。问题在于我们的游戏状态只有“运行”和“暂停”——甚至在玩家行动时我们也没有运行系统。此外,系统不知道我们处于哪个阶段——所以它们无法考虑到这一点。

让我们用更能描述每个阶段的名称替换 RunState

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn }
}

如果你在使用带有 RLS 的 Visual Studio Code,你的项目一半可能会变成红色。没关系,我们会一步一步地重构。我们将从主GameState中完全移除RunState

#![allow(unused)]
fn main() {
pub struct State {
    pub ecs: World
}
}

这使得更多的红色出现!我们这样做,是因为我们要将 RunState 变成一个资源。所以在 main.rs 中我们插入其他资源的地方,我们添加:

#![allow(unused)]
fn main() {
gs.ecs.insert(RunState::PreRun);
}

现在开始重构 Tick。我们的新 tick 函数如下:

#![allow(unused)]
fn main() {
fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();
        let mut newrunstate;
        {
            let runstate = self.ecs.fetch::<RunState>();
            newrunstate = *runstate;
        }

        match newrunstate {
            RunState::PreRun => {
                self.run_systems();
                newrunstate = RunState::AwaitingInput;
            }
            RunState::AwaitingInput => {
                newrunstate = player_input(self, ctx);
            }
            RunState::PlayerTurn => {
                self.run_systems();
                newrunstate = RunState::MonsterTurn;
            }
            RunState::MonsterTurn => {
                self.run_systems();
                newrunstate = RunState::AwaitingInput;
            }
        }

        {
            let mut runwriter = self.ecs.write_resource::<RunState>();
            *runwriter = newrunstate;
        }
        damage_system::delete_the_dead(&mut self.ecs);

        draw_map(&self.ecs, ctx);

        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();
        let map = self.ecs.fetch::<Map>();

        for (pos, render) in (&positions, &renderables).join() {
            let idx = map.xy_idx(pos.x, pos.y);
            if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
        }
    }
}
}

注意我们现在有一个状态机在进行,有一个“预运行”阶段来启动游戏!这非常清晰,而且很明显发生了什么。为了使借用检查器满意,我们使用了一些作用域魔法:如果你在一个作用域内声明和使用一个变量,它在作用域退出时会被丢弃(你也可以手动丢弃事物,但我认为这样看起来更清晰)。

player.rs 中,我们只需将所有 Paused 替换为 AwaitingInput,并将 Running 替换为 PlayerTurn

最后,我们修改 monster_ai_system 仅在状态为 MonsterTurn 时运行(代码片段):

#![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>);

    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) = data;

        if *runstate != RunState::MonsterTurn { return; }
}

不要忘记确保所有系统现在都在 run_systems(在 main.rs 中):

#![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);
        self.ecs.maintain();
    }
}
}

如果你 cargo run 这个项目,它现在会按预期运行:玩家移动,他/她杀死的生物在还击之前就会死亡。

总结

那是相当精彩的一章!我们加入了位置索引、伤害和击杀功能。好消息是这是最难的部分;你现在有了一个简单的地牢闯关游戏!虽然不是特别有趣,而且你肯定会死(因为没有治疗)——但基本功能已经齐备。

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

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


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.