第六章 - 怪物


关于本教程

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

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

实践中的 Rust


一个没有怪物的类 Rogue 游戏非常不寻常,所以我们来添加一些吧!好消息是我们已经为此做了一些工作:我们可以渲染它们,并且可以计算它们能看到什么。我们将基于上一章的源代码,并引入一些无害的怪物。

在每个房间的中心渲染一个怪物

我们可以简单地为每个怪物添加一个Renderable组件(我们还会添加一个Viewshed,因为我们稍后会用到它)。在我们的main函数(在main.rs中),添加以下内容:

#![allow(unused)]
fn main() {
for room in map.rooms.iter().skip(1) {
    let (x,y) = room.center();
    gs.ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('g'),
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .build();
}

gs.ecs.insert(map);
}

注意使用 skip(1) 忽略第一个房间 - 我们不希望玩家一开始就被怪物压在上面!运行这个(使用 cargo run)会产生类似这样的结果:

截图

这是一个很好的开始!然而,即使我们看不到怪物,我们也在渲染它们。我们可能只想渲染那些我们能看到的。我们可以通过修改我们的渲染循环来实现这一点:

#![allow(unused)]
fn main() {
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) }
}
}

我们从 ECS 获取地图,并使用它来获取索引 - 并检查图块是否可见。如果是 - 我们渲染可渲染对象。不需要为玩家设置特殊情况 - 因为他们通常可以看到自己!结果非常好:

截图

增加一些怪物种类

只有一种怪物类型相当无聊,所以我们会对怪物生成器进行修改,使其能够生成布林和人。 这是生成器代码:

#![allow(unused)]
fn main() {
let mut rng = rltk::RandomNumberGenerator::new();
for room in map.rooms.iter().skip(1) {
    let (x,y) = room.center();

    let glyph : rltk::FontCharType;
    let roll = rng.roll_dice(1, 2);
    match roll {
        1 => { glyph = rltk::to_cp437('g') }
        _ => { glyph = rltk::to_cp437('o') }
    }

    gs.ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: glyph,
            fg: RGB::named(rltk::RED),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .build();
}
}

显然,当我们开始加入战斗时,我们会希望有更多的多样性——但这是一个好的开始。运行程序(cargo run),你会看到大约 50/50 的兽人和哥布林的分布。

让怪物思考

现在开始让怪物思考!目前,除了思考它们孤独的存在之外,它们实际上不会做太多事情。我们应该首先添加一个标签组件,以表明一个实体是怪物。在 components.rs 中,我们添加一个简单的结构体:

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

当然,我们需要在 main.rs 中注册它:gs.ecs.register::<Monster>();。我们还应该修改我们的生成代码以将其应用于怪物:

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

现在我们为一个怪物思维系统创建一个文件。我们将创建一个新文件,monster_ai_system.rs。我们将给它一些基本不存在的智能:

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

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    type SystemData = ( ReadStorage<'a, Viewshed>, 
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, Monster>);

    fn run(&mut self, data : Self::SystemData) {
        let (viewshed, pos, monster) = data;

        for (viewshed,pos,_monster) in (&viewshed, &pos, &monster).join() {
            console::log("Monster considers their own existence");
        }
    }
}
}

注意,我们从 rltk 导入 console - 并使用 console::log 打印。这是 RLTK 提供的一个辅助工具,可以检测你是否在编译为常规程序或 Web Assembly;如果你使用的是常规程序,它会调用 println! 并输出到控制台。如果你在 WASM 中,它会输出到 浏览器 控制台。 我们还将在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);
        self.ecs.maintain();
    }
}
}

如果你现在 cargo run 你的项目,它会非常慢——你的控制台会充满“怪物思考它们的存在”。AI 正在运行——但它每刻都在运行!

回合制游戏,在基于时间的世界中

为了防止这种情况——并制作一个回合制游戏——我们向游戏状态引入了一个新概念。游戏要么是“运行中”,要么是“等待输入”——所以我们创建一个enum来处理这种情况:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { Paused, Running }
}

注意derive宏!派生是一种让 Rust(和 crates)为你的结构添加代码的方式,以减少样板代码的输入。在这种情况下,enum需要一些额外的功能。PartialEq允许你比较RunState与其他RunState变量,以确定它们是否相同(或不同)。Copy将其标记为“复制”类型——它可以安全地在内存中复制(这意味着它没有会在此过程中混乱的指针)。Clone悄悄地为其添加了一个.clone()函数,允许你通过这种方式进行内存复制。

接下来,我们需要将其添加到State结构中:

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

反过来,我们需要修改我们的状态创建器以包含 runstate: RunState::Running

#![allow(unused)]
fn main() {
let mut gs = State {
    ecs: World::new(),
    runstate : RunState::Running
};
}

现在,我们将 tick 函数改为仅在游戏未暂停时运行模拟,否则请求用户输入:

#![allow(unused)]
fn main() {
if self.runstate == RunState::Running {
    self.run_systems();
    self.runstate = RunState::Paused;
} else {
    self.runstate = player_input(self, ctx);
}
}

如你所见,player_input 现在返回一个状态。这是它的新代码:

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

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

如果你现在运行 cargo run,游戏速度会恢复 - 而怪物只会在你移动时思考下一步该做什么。这是一个基本的回合制 tick 循环!

安静的怪物,直到它们看到你

你可以让怪物在任何东西移动时都思考(当你进入更深层次的模拟时,你可能会这样做),但现在让我们让它们安静一点——如果它们能看到玩家,就让它们做出反应。

系统很可能会经常想知道玩家的位置——所以让我们将其作为一个资源添加。在 main.rs 中,一行代码将其添加进去(我不建议对非玩家实体这样做;资源是有限的——但玩家是我们反复使用的):

#![allow(unused)]
fn main() {
gs.ecs.insert(Point::new(玩家_x, 玩家_y)); 
}

player.rs 中,try_move_player(),更新玩家移动时的资源:

#![allow(unused)]
fn main() {
let mut ppos = ecs.write_resource::<Point>();
ppos.x = pos.x;
ppos.y = pos.y;
}

我们可以在我们的 monster_ai_system 中使用它。以下是一个工作版本:

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

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    type SystemData = ( ReadExpect<'a, Point>,
                        ReadStorage<'a, Viewshed>, 
                        ReadStorage<'a, Monster>);

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

        for (viewshed,_monster) in (&viewshed, &monster).join() {
            if viewshed.visible_tiles.contains(&*player_pos) {
                console::log(format!("Monster shouts insults"));
            }
        }
    }
}
}

如果你 cargo run 这个,你将能够四处移动——当怪物能看到你时,你的控制台会不时显示“怪物大喊侮辱”。

区分我们的怪物

怪物应该有名字,这样我们就知道是谁在对我们大喊大叫!所以我们创建一个新的组件,Name。在 components.rs 中,我们添加:

#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct Name {
    pub name : String
}
}

我们也会在 main.rs 中注册它,你现在应该已经很熟悉了!我们还会添加一些命令来给我们的怪物和玩家添加名字。所以我们的怪物生成器看起来像这样:

#![allow(unused)]
fn main() {
for (i,room) in map.rooms.iter().skip(1).enumerate() {
    let (x,y) = room.center();

    let glyph : rltk::FontCharType;
    let name : String;
    let roll = rng.roll_dice(1, 2);
    match roll {
        1 => { glyph = rltk::to_cp437('g'); name = "Goblin".to_string(); }
        _ => { glyph = rltk::to_cp437('o'); name = "Orc".to_string(); }
    }

    gs.ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: 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) })
        .build();
}
}

现在我们调整 monster_ai_system 以包含怪物的名字。新的 AI 如下所示:

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

pub struct MonsterAI {}

impl<'a> System<'a> for MonsterAI {
    type SystemData = ( ReadExpect<'a, Point>,
                        ReadStorage<'a, Viewshed>, 
                        ReadStorage<'a, Monster>,
                        ReadStorage<'a, Name>);

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

        for (viewshed,_monster,name) in (&viewshed, &monster, &name).join() {
            if viewshed.visible_tiles.contains(&*player_pos) {
                console::log(&format!("{} shouts insults", name.name));
            }
        }
    }
}
}

我们也需要给玩家一个名字;我们在 AI 的加入中明确包含了名字,所以我们最好确保玩家有一个名字!否则,AI 将完全忽略玩家。在main.rs中,我们将在Player创建时包含一个名字:

#![allow(unused)]

fn main() {
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() })
    .build();
}

如果你运行项目,你现在会看到类似 哥布林 #9 大喊侮辱 的内容 - 这样你就能知道谁在喊叫。

截图

第六章就这样结束了;我们添加了各种满口脏话的怪物来侮辱你脆弱的自尊!在本章中,我们开始看到了使用实体组件系统的一些好处:添加新渲染的怪物,带有一些变化,并开始为事物存储名称,都非常容易。我们之前编写的视野代码只需稍作修改即可让怪物可见——而我们新的怪物 AI 能够利用我们已经构建的内容,相当高效地对玩家说坏话。

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

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


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.