地图构建测试工具


关于本教程

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

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

动手学习 Rust


当我们深入研究生成新的和有趣的地图时,提供一种查看算法正在做什么的方法将很有帮助。本章将构建一个测试工具来实现此目的,并扩展上一章中的 SimpleMapBuilder 以支持它。这将是一项相对较大的任务,我们将在过程中学习一些新技术!

清理地图创建 - 不要重复自己

main.rs 中,我们基本上有三次相同的代码。当程序启动时,我们将一个地图插入到世界中。当我们更改关卡或完成游戏时 - 我们也做同样的事情。最后两个具有不同的语义(因为我们正在更新世界而不是第一次插入) - 但它基本上是冗余的重复。

我们将首先更改第一个,以插入占位符值,而不是我们打算使用的实际值。这样,World 就有了数据的槽位 - 只是目前还不是很有用。这是一个带有注释掉的旧代码的版本:

#![allow(unused)]
fn main() {
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());

gs.ecs.insert(Map::new(1));
gs.ecs.insert(Point::new(0, 0));
gs.ecs.insert(rltk::RandomNumberGenerator::new());

/*let mut builder = map_builders::random_builder(1);
builder.build_map();
let player_start = builder.get_starting_position();
let map = builder.get_map();
let (player_x, player_y) = (player_start.x, player_start.y);
builder.spawn_entities(&mut gs.ecs);
gs.ecs.insert(map);
gs.ecs.insert(Point::new(player_x, player_y));*/ // 注释掉的旧代码

let player_entity = spawner::player(&mut gs.ecs, 0, 0);
gs.ecs.insert(player_entity);
}

因此,我们没有构建地图,而是将一个占位符放入 World 资源中。这显然对于实际开始游戏不是很有用,所以我们还需要一个函数来执行实际的构建并更新资源。并非完全巧合,该函数与我们当前更新地图的其他两个地方相同!换句话说,我们也可以将它们合并到这个函数中。因此,在 State 的实现中,我们添加:

#![allow(unused)]
fn main() {
fn generate_world_map(&mut self, new_depth : i32) {
    let mut builder = map_builders::random_builder(new_depth);
    builder.build_map();
    let player_start;
    {
        let mut worldmap_resource = self.ecs.write_resource::<Map>();
        *worldmap_resource = builder.get_map();
        player_start = builder.get_starting_position();
    }

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

    // 放置玩家并更新资源
    let (player_x, player_y) = (player_start.x, player_start.y);
    let mut player_position = self.ecs.write_resource::<Point>();
    *player_position = Point::new(player_x, player_y);
    let mut position_components = self.ecs.write_storage::<Position>();
    let player_entity = self.ecs.fetch::<Entity>();
    let player_pos_comp = position_components.get_mut(*player_entity);
    if let Some(player_pos_comp) = player_pos_comp {
        player_pos_comp.x = player_x;
        player_pos_comp.y = player_y;
    }

    // 标记玩家的视野为脏
    let mut viewshed_components = self.ecs.write_storage::<Viewshed>();
    let vs = viewshed_components.get_mut(*player_entity);
    if let Some(vs) = vs {
        vs.dirty = true;
    }
}
}

现在我们可以删除注释掉的代码,并大大简化我们的第一次调用:

#![allow(unused)]
fn main() {
gs.ecs.insert(Map::new(1));
gs.ecs.insert(Point::new(0, 0));
gs.ecs.insert(rltk::RandomNumberGenerator::new());
let player_entity = spawner::player(&mut gs.ecs, 0, 0);
gs.ecs.insert(player_entity);
gs.ecs.insert(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame });
gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] });
gs.ecs.insert(particle_system::ParticleBuilder::new());
gs.ecs.insert(rex_assets::RexAssets::new());

gs.generate_world_map(1);
}

我们还可以转到代码中调用我们刚刚添加到 generate_world_map 的相同代码的各个部分,并通过使用新函数来大大简化它们。我们可以用以下代码替换 goto_next_level

#![allow(unused)]
fn main() {
fn goto_next_level(&mut self) {
    // 删除不是玩家或其装备的实体
    let to_delete = self.entities_to_remove_on_level_change();
    for target in to_delete {
        self.ecs.delete_entity(target).expect("Unable to delete entity");
    }

    // 构建新地图并放置玩家
    let current_depth;
    {
        let worldmap_resource = self.ecs.fetch::<Map>();
        current_depth = worldmap_resource.depth;
    }
    self.generate_world_map(current_depth + 1);

    // 通知玩家并给予他们一些生命值
    let player_entity = self.ecs.fetch::<Entity>();
    let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
    gamelog.entries.push("You descend to the next level, and take a moment to heal.".to_string());
    let mut player_health_store = self.ecs.write_storage::<CombatStats>();
    let player_health = player_health_store.get_mut(*player_entity);
    if let Some(player_health) = player_health {
        player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2);
    }
}
}

同样,我们可以清理 game_over_cleanup

#![allow(unused)]
fn main() {
fn game_over_cleanup(&mut self) {
    // 删除所有内容
    let mut to_delete = Vec::new();
    for e in self.ecs.entities().join() {
        to_delete.push(e);
    }
    for del in to_delete.iter() {
        self.ecs.delete_entity(*del).expect("Deletion failed");
    }

    // 生成一个新的玩家
    {
        let player_entity = spawner::player(&mut self.ecs, 0, 0);
        let mut player_entity_writer = self.ecs.write_resource::<Entity>();
        *player_entity_writer = player_entity;
    }

    // 构建新地图并放置玩家
    self.generate_world_map(1);
}
}

这样就可以了 - cargo run 给出了我们已经玩了一段时间的相同游戏,并且我们删减了很多代码。使事情更小的重构真是太棒了!

制作生成器

有时,组合两种范例出奇地困难:

  • RLTK(以及底层 GUI 环境)的图形“tick”特性鼓励您快速地一蹴而就地完成所有操作。
  • 在生成地图时实际可视化进度鼓励您以许多阶段作为“状态机”运行,并在过程中产生地图结果。

我的第一个想法是使用协程,特别是 Generators。它们确实非常适合这种类型的事情:您可以编写在函数中同步(按顺序)运行的代码,并在计算继续时“yield”值。我甚至深入到获得了可工作的实现 - 但它需要 nightly 支持(不稳定的,未完成的 Rust)并且与 web assembly 不能很好地配合。所以我放弃了它。这里有一个教训:有时工具并不完全为你真正想要的东西做好准备!

相反,我决定采用更传统的方式。地图可以在生成时拍摄“快照”,并且可以在可视化工具中逐帧播放大量的快照。这不如协程那么好,但它有效且稳定。这些是理想的特性!

首先,我们应该确保可视化地图生成是完全可选的。当您将游戏发布给玩家时,您可能不希望在他们开始游戏时向他们展示整个地图 - 但当您处理地图算法时,这非常有价值。所以在 main.rs 的顶部,我们添加一个常量:

#![allow(unused)]
fn main() {
const SHOW_MAPGEN_VISUALIZER : bool = true;
}

常量就是这样:一旦程序启动就无法更改的变量。Rust 使只读常量非常容易,并且编译器通常会完全优化掉它们,因为值是预先知道的。在这种情况下,我们声明一个名为 SHOW_MAPGEN_VISUALIZERbool 值为 true。我们的想法是,当我们不想显示地图生成进度时,可以将其设置为 false

有了这个前提,是时候为我们的地图构建器接口添加快照支持了。在 map_builders/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(&self) -> Map;
    fn get_starting_position(&self) -> Position;
    fn get_snapshot_history(&self) -> Vec<Map>;
    fn take_snapshot(&mut self);
}
}

请注意新条目:get_snapshot_historytake_snapshot。前者将用于向生成器请求其地图帧的历史记录;后者告诉生成器支持拍摄快照(并由他们决定如何操作)。

现在是提及 Rust 和 C++(以及其他提供面向对象编程支持的语言)之间一个主要区别的好时机。Rust 特征不支持向特征签名添加变量。因此,即使 history : Vec<Map> 恰好是您在所有实现中用于存储快照的内容,您也不能将其包含在特征中。我真的不知道为什么会这样,但这可以解决 - 只是与 OOP 规范略有不同。

simple_map.rs 内部,我们需要为我们的 SimpleMapBuilder 实现这些方法。我们首先向我们的 struct 添加支持变量:

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

请注意,我们已将 history: Vec<Map> 添加到结构中。它的意思正如其字面意思:Map 结构的向量(可调整大小的数组)。我们的想法是,我们将为地图生成的每个“帧”不断地将地图的副本添加到其中。

接下来是特征实现:

#![allow(unused)]
fn main() {
fn get_snapshot_history(&self) -> Vec<Map> {
    self.history.clone()
}
}

非常简单:我们将历史向量的副本返回给调用者。我们还需要:

#![allow(unused)]
fn main() {
fn take_snapshot(&mut self) {
    if SHOW_MAPGEN_VISUALIZER {
        let mut snapshot = self.map.clone();
        for v in snapshot.revealed_tiles.iter_mut() {
            *v = true;
        }
        self.history.push(snapshot);
    }
}
}

我们首先检查是否正在使用快照功能(如果没有使用,则没有必要浪费内存!)。如果正在使用,我们获取当前地图的副本,迭代每个 revealed_tiles 单元格并将其设置为 true(以便地图渲染将显示所有内容,包括无法访问的墙壁),并将其添加到历史记录列表中。

我们现在可以在地图生成的任何时候调用 self.take_snapshot(),它会被添加为地图生成器的帧。在 simple_map.rs 中,我们在添加房间或走廊后添加几个调用:

#![allow(unused)]
fn main() {
...
if ok {
    apply_room_to_map(&mut self.map, &new_room);
    self.take_snapshot();

    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);
    self.take_snapshot();
}
...
}

渲染可视化工具

可视化地图开发是另一种游戏状态,所以我们将其添加到 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},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    ShowRemoveItem,
    GameOver,
    MagicMapReveal { row : i32 },
    MapGeneration
}
}

可视化实际上需要几个变量,但我遇到了一个问题:其中一个变量真的应该是我们在可视化之后要转换到的下一个状态。我们可能正在从三个来源之一(新游戏、游戏结束、下一关)构建新地图 - 并且它们在生成后具有不同的状态。不幸的是,你不能将第二个 RunState 放入第一个 RunState 中 - Rust 会给你循环错误,并且它不会编译。您可以使用 Box<RunState> - 但这不适用于从 Copy 派生的 RunState!我为此奋斗了一段时间,并决定添加到 State 中:

#![allow(unused)]
fn main() {
pub struct State {
    pub ecs: World,
    mapgen_next_state : Option<RunState>,
    mapgen_history : Vec<Map>,
    mapgen_index : usize,
    mapgen_timer : f32
}
}

我们添加了:

  • mapgen_next_state - 游戏接下来应该去哪里。
  • mapgen_history - 要播放的地图历史帧的副本。
  • mapgen_index - 我们在回放过程中历史记录的进度。
  • mapgen_timer - 用于回放期间的帧定时。

由于我们修改了 State,我们还必须修改 State 对象的创建:

#![allow(unused)]
fn main() {
let mut gs = State {
    ecs: World::new(),
    mapgen_next_state : Some(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }),
    mapgen_index : 0,
    mapgen_history: Vec::new(),
    mapgen_timer: 0.0
};
}

我们使下一个状态与我们一直在使用的起始状态相同:因此游戏将渲染地图创建,然后转到菜单。我们可以将我们的初始状态更改为 MapGeneration

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

现在我们需要实现渲染器。在我们的 tick 函数中,我们添加以下状态:

#![allow(unused)]
fn main() {
match newrunstate {
    RunState::MapGeneration => {
        if !SHOW_MAPGEN_VISUALIZER {
            newrunstate = self.mapgen_next_state.unwrap();
        }
        ctx.cls();
        draw_map(&self.mapgen_history[self.mapgen_index], ctx);

        self.mapgen_timer += ctx.frame_time_ms;
        if self.mapgen_timer > 300.0 {
            self.mapgen_timer = 0.0;
            self.mapgen_index += 1;
            if self.mapgen_index >= self.mapgen_history.len() {
                newrunstate = self.mapgen_next_state.unwrap();
            }
        }
    }
    ...
}

这相对简单:

  1. 如果未启用可视化工具,则立即转换到下一个状态。
  2. 清空屏幕。
  3. 调用 draw_map,使用我们状态中的地图历史记录 - 在当前帧。
  4. 将帧持续时间添加到 mapgen_timer,如果它大于 300ms:
    1. 将计时器设置回 0。
    2. 增加帧计数器。
    3. 如果帧计数器已到达历史记录的末尾,则转换到下一个游戏状态。

眼尖的读者会注意到这里有一个细微的变化。draw_map 以前不接受 map - 它会从 ECS 中获取!在 map.rs 中,draw_map 的开头更改为:

#![allow(unused)]
fn main() {
pub fn draw_map(map : &Map, ctx : &mut Rltk) {
}

我们在 tick 中的常规 draw_map 调用也更改为:

#![allow(unused)]
fn main() {
draw_map(&self.ecs.fetch::<Map>(), ctx);
}

这是一个微小的更改,它允许我们渲染我们需要的任何 Map 结构!

最后,我们需要实际为可视化工具提供一些数据来渲染。我们调整 generate_world_map 以重置各种 mapgen_ 变量,清除历史记录,并在运行后检索快照历史记录:

#![allow(unused)]
fn main() {
fn generate_world_map(&mut self, new_depth : i32) {
        self.mapgen_index = 0;
        self.mapgen_timer = 0.0;
        self.mapgen_history.clear();
        let mut builder = map_builders::random_builder(new_depth);
        builder.build_map();
        self.mapgen_history = builder.get_snapshot_history();
        let player_start;
        {
            let mut worldmap_resource = self.ecs.write_resource::<Map>();
            *worldmap_resource = builder.get_map();
            player_start = builder.get_starting_position();
        }
}

如果您现在 cargo run 该项目,您可以在开始游戏之前观看简单的地图生成器构建您的关卡。

截图

总结

这完成了构建测试工具 - 您可以观看地图生成,这应该使生成地图(接下来几章的主题)更加直观。

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

在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)

版权 (C) 2019, Herbert Wolverson.