重构:通用地图接口


关于本教程

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

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

实践 Rust 编程


到目前为止,我们实际上只有一个地图设计。每次都不同(除非您使用重复的随机种子),这是一个很好的开始 - 但程序化生成的世界留下了更多可能性。在接下来的几个章节中,我们将开始构建几种不同的地图类型。

重构构建器 - 定义接口

到目前为止,我们所有的地图生成代码都位于 map.rs 文件中。对于单一风格来说,这很好,但是如果我们想要有很多风格呢?现在是创建合适的构建器系统的绝佳时机!如果您查看 main.rs 中的地图生成代码,我们会发现一个已定义的接口的雏形:

  • 我们调用 Map::new_map_rooms_and_corridors,它构建了一组房间。
  • 我们将其传递给 spawner::spawn_room 以填充每个房间。
  • 然后我们将玩家放置在第一个房间中。

为了更好地组织我们的代码,我们将创建一个模块。Rust 允许您创建一个目录,其中包含一个名为 mod.rs 的文件 - 该目录现在就是一个模块。模块通过 modpub mod 暴露,并提供了一种将代码部分放在一起的方法。mod.rs 文件提供了一个接口 - 也就是说,模块提供的内容以及如何与之交互的列表。模块中的其他文件可以做任何他们想做的事情,安全地与代码的其余部分隔离。

因此,我们将在 src 之外创建一个名为 map_builders 的目录。在该目录中,我们将创建一个名为 mod.rs 的空文件。我们正在尝试定义一个接口,所以我们将从一个骨架开始。在 mod.rs 中:

#![allow(unused)]
fn main() {
use super::Map;

trait MapBuilder {
    fn build(new_depth: i32) -> Map;
}
}

trait 的使用是新的!trait 就像其他语言中的接口:您是在说任何其他类型都可以实现该 trait,然后可以将其视为该类型的变量。Rust by Example 有一个关于 trait 的精彩章节,The Rust Book 也是如此。我们声明的是,任何东西都可以声明自己是 MapBuilder - 这包括承诺他们将提供一个 build 函数,该函数接受一个 ECS World 对象,并返回一个地图。

打开 map.rs,并添加一个新函数 - 恰如其分地命名为 new

#![allow(unused)]
fn main() {
/// 生成一个空地图,完全由实心墙组成
pub fn new(new_depth : i32) -> Map {
    Map{
        tiles : vec![TileType::Wall; MAPCOUNT],
        rooms : Vec::new(),
        width : MAPWIDTH as i32,
        height: MAPHEIGHT as i32,
        revealed_tiles : vec![false; MAPCOUNT],
        visible_tiles : vec![false; MAPCOUNT],
        blocked : vec![false; MAPCOUNT],
        tile_content : vec![Vec::new(); MAPCOUNT],
        depth: new_depth,
        bloodstains: HashSet::new()
    }
}
}

我们需要这个用于其他地图生成器,并且 Map 知道如何返回一个新的作为构造函数 - 而不必封装地图布局的所有逻辑,这是有意义的。我们的想法是,任何 Map 基本上都以相同的方式工作,而与我们决定如何填充它无关。

现在我们将创建一个新文件,也在 map_builders 目录中。我们将它命名为 simple_map.rs - 它将是我们放置现有地图生成系统的地方。我们也会在这里放置一个骨架:

#![allow(unused)]
fn main() {
use super::MapBuilder;
use super::Map;
use specs::prelude::*;

pub struct SimpleMapBuilder {}

impl MapBuilder for SimpleMapBuilder {
    fn build(new_depth: i32) -> Map {
        Map::new(new_depth)
    }
}
}

这只是返回一个不可用的、实心的地图。我们稍后会充实细节 - 让我们先让接口工作起来。

现在,回到 map_builders/mod.rs,我们添加一个公共函数。目前,它只是调用 SimpleMapBuilder 中的构建器:

#![allow(unused)]
fn main() {
pub fn build_random_map(new_depth: i32) -> Map {
    SimpleMapBuilder::build(new_depth)
}
}

最后,我们将告诉 main.rs 实际包含该模块:

#![allow(unused)]
fn main() {
pub mod map_builders;
}

好的,做了相当多的工作,但实际上没有做任何事情 - 但我们获得了一个干净的接口,提供地图创建(通过一个函数),并设置了一个 trait 以要求我们的地图构建器以类似的方式工作。这是一个好的开始。

充实简单地图构建器

现在我们开始将功能从 map.rs 移到我们的 SimpleMapBuilder 中。我们将首先向 map_builders 添加另一个文件 - common.rs。这将保存以前是地图一部分的函数,现在在构建时常用。

该文件看起来像这样:

#![allow(unused)]
fn main() {
use super::{Map, Rect, TileType};
use std::cmp::{max, min};

pub fn apply_room_to_map(map : &mut Map, room : &Rect) {
    for y in room.y1 +1 ..= room.y2 {
        for x in room.x1 + 1 ..= room.x2 {
            let idx = map.xy_idx(x, y);
            map.tiles[idx] = TileType::Floor;
        }
    }
}

pub fn apply_horizontal_tunnel(map : &mut Map, x1:i32, x2:i32, y:i32) {
    for x in min(x1,x2) ..= max(x1,x2) {
        let idx = map.xy_idx(x, y);
        if idx > 0 && idx < map.width as usize * map.height as usize {
            map.tiles[idx as usize] = TileType::Floor;
        }
    }
}

pub fn apply_vertical_tunnel(map : &mut Map, y1:i32, y2:i32, x:i32) {
    for y in min(y1,y2) ..= max(y1,y2) {
        let idx = map.xy_idx(x, y);
        if idx > 0 && idx < map.width as usize * map.height as usize {
            map.tiles[idx as usize] = TileType::Floor;
        }
    }
}
}

这些与 map.rs 中的函数完全相同,但 map 作为可变引用传递(因此您正在处理原始地图,而不是新地图),并且所有 self 的痕迹都消失了。这些是自由函数 - 也就是说,它们是从任何地方可用的函数,不与类型绑定。pub fn 表示它们在模块内是公共的 - 除非我们向模块本身添加 pub use,否则它们不会传递到主程序。这有助于保持代码的组织性。

现在我们有了这些助手函数,我们可以开始移植地图构建器本身了。在 simple_map.rs 中,我们首先充实 build 函数:

#![allow(unused)]
fn main() {
impl MapBuilder for SimpleMapBuilder {
    fn build(new_depth: i32) -> Map {
        let mut map = Map::new(new_depth);
        SimpleMapBuilder::rooms_and_corridors(&mut map);
        map
    }
}
}

我们正在调用一个新函数 rooms_and_corridors。让我们构建它:

#![allow(unused)]
fn main() {
impl SimpleMapBuilder {
    fn rooms_and_corridors(map : &mut Map) {
        const MAX_ROOMS : i32 = 30;
        const MIN_SIZE : i32 = 6;
        const MAX_SIZE : i32 = 10;

        let mut rng = RandomNumberGenerator::new();

        for i in 0..MAX_ROOMS {
            let w = rng.range(MIN_SIZE, MAX_SIZE);
            let h = rng.range(MIN_SIZE, MAX_SIZE);
            let x = rng.roll_dice(1, map.width - w - 1) - 1;
            let y = rng.roll_dice(1, map.height - h - 1) - 1;
            let new_room = Rect::new(x, y, w, h);
            let mut ok = true;
            for other_room in map.rooms.iter() {
                if new_room.intersect(other_room) { ok = false }
            }
            if ok {
                apply_room_to_map(map, &new_room);

                if !map.rooms.is_empty() {
                    let (new_x, new_y) = new_room.center();
                    let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center();
                    if rng.range(0,2) == 1 {
                        apply_horizontal_tunnel(map, prev_x, new_x, prev_y);
                        apply_vertical_tunnel(map, prev_y, new_y, new_x);
                    } else {
                        apply_vertical_tunnel(map, prev_y, new_y, prev_x);
                        apply_horizontal_tunnel(map, prev_x, new_x, new_y);
                    }
                }

                map.rooms.push(new_room);
            }
        }

        let stairs_position = map.rooms[map.rooms.len()-1].center();
        let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1);
        map.tiles[stairs_idx] = TileType::DownStairs;
    }
}
}

您会注意到,这是作为附加到 SimpleMapBuilder 结构的方法构建的。它不是 trait 的一部分,所以我们不能在那里定义它 - 但我们希望将其与其他构建器分开,其他构建器可能有自己的函数。代码本身应该看起来非常熟悉:它与 map.rs 中的生成器相同,但 map 作为一个变量,而不是在函数内部生成。

这只是生成的第一部分,但这是一个好的开始!现在转到 map.rs,并删除整个 new_map_rooms_and_corridors 函数。还要删除我们在 common.rs 中复制的那些。map.rs 文件现在看起来更简洁了,没有任何对地图构建策略的引用!当然,您的编译器/IDE 可能会告诉您,我们破坏了一堆东西。没关系 - 这是“重构”的正常部分 - 更改代码以使其更易于使用的过程。

main.rs 中有三行代码现在被编译器标记。

  • 我们可以将 *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1); 替换为 *worldmap_resource = map_builders::build_random_map(current_depth + 1);
  • *worldmap_resource = Map::new_map_rooms_and_corridors(1); 可以变为 *worldmap_resource = map_builders::build_random_map(1);
  • let map : Map = Map::new_map_rooms_and_corridors(1); 转换为 let map : Map = map_builders::build_random_map(1);

如果您现在 cargo run,您会注意到:游戏完全相同!很好:我们已成功地将功能从 Map 重构map_builders 中。

放置玩家

如果您查看 main.rs,几乎每次我们构建地图时 - 我们都会寻找第一个房间,并用它来放置玩家。我们很可能不想在未来的地图中使用相同的策略,因此我们应该指示在构建地图时玩家要去哪里。让我们扩展 map_builders/mod.rs 中的接口,使其也返回一个位置:

#![allow(unused)]
fn main() {
trait MapBuilder {
    fn build(new_depth: i32) -> (Map, Position);
}

pub fn build_random_map(new_depth: i32) -> (Map, Position) {
    SimpleMapBuilder::build(new_depth)
}
}

请注意,我们使用元组一次返回两个值。我们之前已经讨论过这些,但这很好地说明了为什么它们很有用!我们现在需要进入 simple_map 以使 build 函数实际返回正确的数据。simple_map.rsbuild 的定义现在看起来像这样:

#![allow(unused)]
fn main() {
fn build(new_depth: i32) -> (Map, Position) {
    let mut map = Map::new(new_depth);
    let playerpos = SimpleMapBuilder::rooms_and_corridors(&mut map);
    (map, playerpos)
}
}

我们将更新 rooms_and_corridors 的签名:

#![allow(unused)]
fn main() {
fn rooms_and_corridors(map : &mut Map) -> Position {
}

我们将在最后一行添加返回房间 0 的中心:

#![allow(unused)]
fn main() {
let start_pos = map.rooms[0].center();
Position{ x: start_pos.0, y: start_pos.1 }
}

当然破坏了我们在 main.rs 中更新的代码。我们可以快速处理它!第一个错误可以使用以下代码处理:

#![allow(unused)]
fn main() {
// 构建新地图并放置玩家
let worldmap;
let current_depth;
let player_start;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    let (newmap, start) = map_builders::build_random_map(current_depth + 1);
    *worldmap_resource = newmap;
    player_start = start;
    worldmap = worldmap_resource.clone();
}

// 生成坏人
for room in worldmap.rooms.iter().skip(1) {
    spawner::spawn_room(&mut self.ecs, room, current_depth+1);
}

// 放置玩家并更新资源
let (player_x, player_y) = (player_start.x, player_start.y);
}

请注意,我们如何使用解构从构建器中检索地图和起始位置。然后我们将它们放在适当的位置。由于 Rust 中的赋值是移动操作,因此这非常高效 - 并且编译器可以为我们摆脱临时赋值。

我们在第二个错误(大约在第 369 行)再次执行相同的操作。它几乎是完全相同的代码,因此如果您遇到困难,请随时查看本章的源代码

最后,最后一个错误可以简单地替换为:

#![allow(unused)]
fn main() {
let (map, player_start) = map_builders::build_random_map(1);
let (player_x, player_y) = (player_start.x, player_start.y);
}

好的,让我们 cargo run 那个小家伙!如果一切顺利,那么... 没有任何变化。但是,我们取得了重大进展:我们的地图构建策略现在决定了玩家在关卡中的起点,而不是地图本身。

清理房间生成

在某些地图设计中,我们很可能不会房间的概念,因此我们也希望将生成移动为地图构建器的函数。我们将在 map_builders/mod.rs 中的接口中添加一个通用生成器:

#![allow(unused)]
fn main() {
trait MapBuilder {
    fn build(new_depth: i32) -> (Map, Position);
    fn spawn(map : &Map, ecs : &mut World, new_depth: i32);
}
}

足够简单:它需要 ECS(因为我们正在添加实体)和地图。我们还将添加一个公共函数 spawn,以提供一个外部接口来布置怪物:

#![allow(unused)]
fn main() {
pub fn spawn(map : &mut Map, ecs : &mut World, new_depth: i32) {
    SimpleMapBuilder::spawn(map, ecs, new_depth);
}
}

现在我们打开 simple_map.rs 并实际实现 spawn。幸运的是,这非常简单:

#![allow(unused)]
fn main() {
fn spawn(map : &mut Map, ecs : &mut World) {
    for room in map.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, 1);
    }
}
}

现在,我们可以进入 main.rs 并找到每次我们循环调用 spawn_room 并将其替换为对 map_builders::spawn 的调用。

再一次,cargo run 应该给您与我们在 22 章中看到的相同的游戏!

维护构建器状态

如果您仔细查看我们目前的内容,会发现一个问题:构建器无法知道第二次调用构建器(生成事物)应该使用什么。这是因为我们的函数是无状态的 - 我们实际上并没有创建一个构建器并给它一种记住任何东西的方法。由于我们希望支持各种各样的构建器,因此我们应该纠正这一点。

这引入了一个新的 Rust 概念:动态分发The Rust Book 如果您熟悉这个概念,那么其中有一个关于此的很好的章节。如果您以前使用过面向对象编程语言,那么您也会遇到过这种情况。基本思想是您有一个“基本对象”,它指定一个接口 - 并且多个对象实现来自该接口的函数。然后,您可以在运行时(程序运行时,而不是编译时)将任何实现该接口的对象放入由接口键入的变量中 - 并且当您调用接口中的方法时,实现会从实际类型运行。这很好,因为您的底层程序不必了解实际的实现 - 只需了解如何与接口对话。这有助于保持程序的整洁。

动态分发确实会带来成本,这就是实体组件系统(以及一般的 Rust)倾向于不将其用于性能关键代码的原因。实际上有两个成本:

  1. 由于您事先不知道对象的类型,因此必须通过指针分配它。Rust 通过提供 Box 系统(稍后会详细介绍)使其变得容易,但有一个成本:代码不是简单地跳转到预定义的内存片段(您的 CPU/内存通常可以提前轻松地弄清楚并确保缓存已准备就绪),而是必须跟随指针 - 然后运行在指针末尾找到的内容。这就是为什么一些 C++ 程序员将 ->(解引用运算符)称为“缓存未命中运算符”。仅仅通过装箱,您的代码就会稍微变慢。
  2. 由于多种类型可以实现方法,因此计算机需要知道要运行哪一种。它通过 vtable 来实现这一点 - 也就是说,方法实现的“虚拟表”。因此,每次调用都必须检查表,找出要运行的方法,然后从那里运行。那是另一个缓存未命中,并且 CPU 需要更多时间来弄清楚该做什么。

在这种情况下,我们只是在生成地图 - 并且很少调用构建器。这使得速度减慢是可以接受的,因为它真的很小并且不经常运行。如果可以避免,您将不希望在主循环中执行此操作!

所以 - 实现。我们将首先将我们的 trait 更改为 public,并让方法接受 &mut self - 这意味着“此方法是 trait 的成员,并且应该在调用时接收对 self - 附加对象的访问权限”。代码看起来像这样:

#![allow(unused)]
fn main() {
pub trait MapBuilder {
    fn build_map(&mut self, new_depth: i32) -> (Map, Position);
    fn spawn_entities(&mut self, map : &Map, ecs : &mut World, new_depth: i32);
}
}

请注意,我还花时间使名称更具描述性!现在我们用一个工厂函数替换我们的自由函数调用:它创建一个 MapBuilder 并返回它。在有更多地图实现之前,这个名字有点谎言 - 它声称是随机的,但是当只有一个选择时,不难猜测它会选择哪一个(只需询问苏联选举系统!):

#![allow(unused)]
fn main() {
pub fn random_builder() -> Box<dyn MapBuilder> {
    // 请注意,在我们有第二种地图类型之前,这甚至不是稍微随机的
    Box::new(SimpleMapBuilder{})
}
}

请注意,它不返回 MapBuilder - 而是返回 Box<dyn MapBuilder>!这相当复杂(在 Rust 的早期版本中,dyn 是可选的)。Box 是一种包装在指针中的类型,其大小在编译时可能未知。它与 C++ MapBuilder * 相同 - 它指向 MapBuilder 而不是实际一个。dyn 是一个标志,表示“这应该使用动态分发”;代码在没有它的情况下也能工作(它将被推断出来),但标记您正在这里做一些复杂/昂贵的事情是一种好的做法。

该函数仅返回 Box::new(SimpleMapBuilder{})。这实际上是两个调用,现在:我们用 Box::new(...) 创建一个 box,并将一个空的 SimpleMapBuilder 放入 box 中。

main.rs 中,我们再次必须更改对地图构建器的所有三个调用。我们现在需要使用以下模式:

  1. 从工厂获取 boxed MapBuilder 对象。
  2. build_map 作为方法调用 - 也就是说,附加到对象的函数。
  3. 也将 spawn_entities 作为方法调用。

来自 goto_next_level 的实现现在读取如下:

#![allow(unused)]
fn main() {
// 构建新地图并放置玩家
let mut builder = map_builders::random_builder(current_depth + 1);
let worldmap;
let current_depth;
let player_start;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    let (newmap, start) = builder.build_map(current_depth + 1);
    *worldmap_resource = newmap;
    player_start = start;
    worldmap = worldmap_resource.clone();
}

// 生成坏人
builder.spawn_entities(&worldmap, &mut self.ecs, current_depth+1);
}

它并没有什么不同,但现在我们保留了构建器对象 - 因此对构建器的后续调用将应用于相同的实现(有时称为“具体对象” - 实际物理存在的对象)。

如果我们添加 5 个以上的地图构建器,main.rs 中的代码将不会在意!我们可以将它们添加到工厂中,程序的其余部分愉快地不知道地图构建器的工作原理。这是一个很好的例子,说明动态分发如何有用:您有一个明确定义的接口,程序的其余部分不需要理解内部工作原理。

向 SimpleMapBuilder 添加构造函数

我们目前正在将 SimpleMapBuilder 作为空对象创建。如果它需要跟踪一些数据怎么办?以防我们需要它,让我们向其添加一个简单的构造函数,并使用它来代替空白对象。在 simple_map.rs 中,修改 struct 实现如下:

#![allow(unused)]
fn main() {
impl SimpleMapBuilder {
    pub fn new(new_depth : i32) -> SimpleMapBuilder {
        SimpleMapBuilder{}
    }
    ...
}

现在这只是返回一个空对象。在 mod.rs 中,更改 random_map_builder 函数以使用它:

#![allow(unused)]
fn main() {
pub fn random_builder(new_depth : i32) -> Box<dyn MapBuilder> {
    // 请注意,在我们有第二种地图类型之前,这甚至不是稍微随机的
    Box::new(SimpleMapBuilder::new(new_depth))
}
}

这并没有给我们带来任何好处,但更简洁一些 - 当您编写更多地图时,它们可能会在其构造函数中做一些事情!

清理 trait - 简单、明显的步骤和单一返回类型

既然我们已经走了这么远,让我们扩展 trait 以在一个函数中获取玩家的位置,在另一个函数中获取地图,并分别构建/生成。使用小函数往往使代码更易于阅读,这本身就是一个有价值的目标。在 mod.rs 中,我们按如下方式更改接口:

#![allow(unused)]
fn main() {
pub trait MapBuilder {
    fn build_map(&mut self);
    fn spawn_entities(&mut self, ecs : &mut World);
    fn get_map(&mut self) -> Map;
    fn get_starting_position(&mut self) -> Position;
}
}

这里有几件事需要注意:

  1. build_map 不再返回任何内容。我们将其用作构建地图状态的函数。
  2. spawn_entities 不再要求 Map 参数。由于所有地图构建器必须实现地图才能有意义,因此我们将假定地图构建器有一个地图。
  3. get_map 返回一个地图。同样,我们假设构建器实现保留一个。
  4. get_starting_position 也假设构建器会保留一个。

显然,我们的 SimpleMapBuilder 现在需要修改为以这种方式工作。我们将首先修改 struct 以包含所需的变量。这是地图构建器的状态 - 并且由于我们正在进行动态面向对象的代码,因此状态仍然附加到对象。这是来自 simple_map.rs 的代码:

#![allow(unused)]
fn main() {
pub struct SimpleMapBuilder {
    map : Map,
    starting_position : Position,
    depth: i32
}
}

接下来,我们将实现 getter 函数。这些非常简单:它们只是返回结构状态中的变量:

#![allow(unused)]
fn main() {
impl MapBuilder for SimpleMapBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

    fn get_starting_position(&self) -> Position {
        self.starting_position.clone()
    }
    ...
}

我们还将更新构造函数以创建状态:

#![allow(unused)]
fn main() {
pub fn new(new_depth : i32) -> SimpleMapBuilder {
    SimpleMapBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth
    }
}
}

这也简化了 build_mapspawn_entities

#![allow(unused)]
fn main() {
fn build_map(&mut self) {
    SimpleMapBuilder::rooms_and_corridors();
}

fn spawn_entities(&mut self, ecs : &mut World) {
    for room in self.map.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, self.depth);
    }
}
}

最后,我们需要修改 rooms_and_corridors 以使用此接口:

#![allow(unused)]
fn main() {
fn rooms_and_corridors(&mut self) {
    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for i in 0..MAX_ROOMS {
        let w = rng.range(MIN_SIZE, MAX_SIZE);
        let h = rng.range(MIN_SIZE, MAX_SIZE);
        let x = rng.roll_dice(1, self.map.width - w - 1) - 1;
        let y = rng.roll_dice(1, self.map.height - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in self.map.rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            apply_room_to_map(&mut self.map, &new_room);

            if !self.map.rooms.is_empty() {
                let (new_x, new_y) = new_room.center();
                let (prev_x, prev_y) = self.map.rooms[self.map.rooms.len()-1].center();
                if rng.range(0,2) == 1 {
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y);
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x);
                } else {
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x);
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y);
                }
            }

            self.map.rooms.push(new_room);
        }
    }

    let stairs_position = self.map.rooms[self.map.rooms.len()-1].center();
    let stairs_idx = self.map.xy_idx(stairs_position.0, stairs_position.1);
    self.map.tiles[stairs_idx] = TileType::DownStairs;

    let start_pos = self.map.rooms[0].center();
    self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };
}
}

这与我们之前的非常相似,但现在使用 self.map 来引用其自己的地图副本,并将玩家位置存储在 self.starting_position 中。

main.rs 中新代码的调用再次更改。来自 goto_next_level 的调用现在看起来像这样:

#![allow(unused)]
fn main() {
let mut builder;
let worldmap;
let current_depth;
let player_start;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    builder = map_builders::random_builder(current_depth + 1);
    builder.build_map();
    *worldmap_resource = builder.get_map();
    player_start = builder.get_starting_position();
    worldmap = worldmap_resource.clone();
}

// 生成坏人
builder.spawn_entities(&mut self.ecs);
}

我们基本上为其他调用重复这些更改(请参阅源代码)。我们现在有一个非常舒适的地图构建器接口:它暴露了足够多的内容使其易于使用,而没有暴露其用于实际构建地图的魔法的细节!

如果您现在 cargo run 该项目:再一次,没有任何可见的变化 - 它仍然像以前一样工作。当您进行重构时,这是一件好事!

那么为什么地图仍然有房间?

房间实际上在游戏中并没有做太多事情:它们是我们构建地图的方式的产物。以后的地图构建器很可能实际上并不关心房间,至少不是在“这是一个矩形,我们称之为房间”的意义上。让我们尝试将这种抽象移出地图,也移出生成器。

第一步,在 map.rs 中,我们完全删除 rooms 结构:

#![allow(unused)]
fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub depth : i32,
    pub bloodstains : HashSet<usize>,

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
}

我们也从 new 函数中删除它。看看您的 IDE,您会注意到您只破坏了 simple_map.rs 中的代码!我们没有在其他任何地方使用 rooms - 这很明显地表明它们不属于我们在整个主程序中传递的地图中。

我们可以通过将 rooms 放入构建器而不是地图来修复 simple_map。我们将其放入结构中:

#![allow(unused)]
fn main() {
pub struct SimpleMapBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    rooms: Vec<Rect>
}
}

这需要我们修复构造函数:

#![allow(unused)]
fn main() {
pub fn new(new_depth : i32) -> SimpleMapBuilder {
    SimpleMapBuilder{
        map : Map::new(new_depth),
        starting_position : Position{ x: 0, y : 0 },
        depth : new_depth,
        rooms: Vec::new()
    }
}
}

生成函数变为:

#![allow(unused)]
fn main() {
fn spawn_entities(&mut self, ecs : &mut World) {
    for room in self.rooms.iter().skip(1) {
        spawner::spawn_room(ecs, room, self.depth);
    }
}
}

我们在 rooms_and_corridors 中将每个 map.rooms 实例替换为 self.rooms

#![allow(unused)]
fn main() {
fn rooms_and_corridors(&mut self) {
    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for i in 0..MAX_ROOMS {
        let w = rng.range(MIN_SIZE, MAX_SIZE);
        let h = rng.range(MIN_SIZE, MAX_SIZE);
        let x = rng.roll_dice(1, self.map.width - w - 1) - 1;
        let y = rng.roll_dice(1, self.map.height - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in self.rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            apply_room_to_map(&mut self.map, &new_room);

            if !self.rooms.is_empty() {
                let (new_x, new_y) = new_room.center();
                let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center();
                if rng.range(0,2) == 1 {
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y);
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x);
                } else {
                    apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x);
                    apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y);
                }
            }

            self.rooms.push(new_room);
        }
    }

    let stairs_position = self.rooms[self.rooms.len()-1].center();
    let stairs_idx = self.map.xy_idx(stairs_position.0, stairs_position.1);
    self.map.tiles[stairs_idx] = TileType::DownStairs;

    let start_pos = self.rooms[0].center();
    self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };
}
}

再一次,cargo run 该项目:应该没有任何变化。

总结

这是一个有趣的章节,因为目标是以与之前完全相同的代码结束 - 但地图构建器已清理到其自己的模块中,与代码的其余部分完全隔离。这为我们提供了一个很好的起点,可以开始构建新的地图构建器,而无需更改游戏本身。

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

在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)。没有太多意义,因为重构旨在更改可见结果!

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