预制关卡和关卡片段


关于本教程

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

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

Hands-On Rust


尽管本质上是伪随机的(即随机的 - 但以一种构成有趣、有凝聚力的游戏的方式受到约束),许多 roguelike 游戏都包含一些手工制作的内容。通常,这些内容可以分为几个类别:

  • 手工制作的关卡 - 整个关卡都是预先制作的,内容是静态的。这些通常非常谨慎地使用,用于对故事至关重要的大型场景战役。
  • 手工制作的关卡片段 - 关卡的某些部分是随机创建的,但很大一部分是预先制作的。例如,堡垒可能是一个“场景”,但通往堡垒的地下城是随机的。《地下城爬行:石汤 (Dungeon Crawl Stone Soup)》广泛使用了这些片段 - 您有时会遇到您认出的区域,因为它们是预制的 - 但它们周围的地下城显然是随机的。《Cogmind》在洞穴的某些部分使用了这些片段(我将避免剧透)。《Qud 的洞穴 (Caves of Qud)》有一些场景关卡,似乎是围绕许多预制部件构建的。有些系统将这种机制称为“vaults”——但这个名称也可能适用于第三类。
  • 手工制作的房间(在某些情况下也称为 Vaults)。关卡在很大程度上是随机的,但有时一个房间适合一个 vault - 所以您在那里放置一个。

第一类是特殊的,应该谨慎使用(否则,您的玩家只会学习一种最佳策略并一路过关斩将 - 并且可能会因为缺乏变化而感到无聊)。其他类别受益于提供大量 vault(因此有大量内容可以散布,这意味着游戏每次玩起来感觉不会太相似)或稀有 - 因此您只是偶尔看到它们(原因相同)。

一些清理工作

波函数坍缩章节 中,我们加载了一个预制关卡 - 没有任何实体(这些实体稍后添加)。将地图加载器隐藏在 WFC 内部并不是很好 - 因为这不是它的主要目的 - 所以我们将从删除它开始:

我们将首先删除文件 map_builders/waveform_collapse/image_loader.rs。我们稍后将构建一个更好的加载器。

现在我们编辑 map_builders/waveform_collapse 中的 mod.rs 的开头:

#![allow(unused)]
fn main() {
use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER,
    generate_voronoi_spawn_regions, remove_unreachable_areas_returning_most_distant};
use rltk::RandomNumberGenerator;
use specs::prelude::*;
use std::collections::HashMap;
mod common;
use common::*;
mod constraints;
use constraints::*;
mod solver;
use solver::*;

/// 提供使用波函数坍缩算法的地图构建器。
pub struct WaveformCollapseBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    noise_areas : HashMap<i32, Vec<usize>>,
    derive_from : Option<Box<dyn MapBuilder>>
}
...

impl WaveformCollapseBuilder {
    /// 波函数坍缩的通用构造函数。
    /// # 参数
    /// * new_depth - 新的地图深度
    /// * derive_from - None,或者一个 Box<dyn MapBuilder>,作为 `random_builder` 的输出
    pub fn new(new_depth : i32, derive_from : Option<Box<dyn MapBuilder>>) -> WaveformCollapseBuilder {
        WaveformCollapseBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history: Vec::new(),
            noise_areas : HashMap::new(),
            derive_from
        }
    }

    /// 从预先存在的地图构建器派生地图。
    /// # 参数
    /// * new_depth - 新的地图深度
    /// * derive_from - None,或者一个 Box<dyn MapBuilder>,作为 `random_builder` 的输出
    pub fn derived_map(new_depth: i32, builder: Box<dyn MapBuilder>) -> WaveformCollapseBuilder {
        WaveformCollapseBuilder::new(new_depth, Some(builder))
    }
    ...
}

我们已经删除了所有对 image_loader 的引用,删除了测试地图构造函数,并删除了丑陋的模式枚举。WFC 现在完全名副其实,仅此而已。最后,我们将修改 random_builder 以不再使用测试地图:

#![allow(unused)]
fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 16);
    let mut result : Box<dyn MapBuilder>;
    match builder {
        1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); }
        2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); }
        3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); }
        4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); }
        5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); }
        6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); }
        7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); }
        8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); }
        9 => { result = Box::new(MazeBuilder::new(new_depth)); }
        10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); }
        11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); }
        12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); }
        13 => { result = Box::new(DLABuilder::insectoid(new_depth)); }
        14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); }
        15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); }
        _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); }
    }

    if rng.roll_dice(1, 3)==1 {
        result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result));
    }

    result
}
}

骨架构建器

我们将从一个非常基本的骨架开始,类似于之前使用的那些。我们将在 map_builders 中创建一个新文件 prefab_builder.rs

#![allow(unused)]
fn main() {
use super::{MapBuilder, Map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER,
    remove_unreachable_areas_returning_most_distant};
use rltk::RandomNumberGenerator;
use specs::prelude::*;

pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
}

impl MapBuilder for PrefabBuilder {
    fn get_map(&self) -> Map {
        self.map.clone()
    }

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

    fn get_snapshot_history(&self) -> Vec<Map> {
        self.history.clone()
    }

    fn build_map(&mut self)  {
        self.build();
    }

    fn spawn_entities(&mut self, ecs : &mut World) {
    }

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

impl PrefabBuilder {
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new()
        }
    }

    fn build(&mut self) {
    }
}
}

预制构建器模式 1 - 手工制作关卡

我们将为预制构建器支持多种模式,所以让我们在一开始就将其加入。在 prefab_builder.rs 中:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Clone)]
#[allow(dead_code)]
pub enum PrefabMode {
    RexLevel{ template : &'static str }
}

pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    mode: PrefabMode
}
}

这是新的 - 带有变量的 enum? 这是可行的,因为在底层,Rust 枚举实际上是 联合体 (unions)。它们可以容纳您想要放入的任何内容,并且类型大小被调整为容纳最大的选项。在紧凑的代码中最好谨慎使用,但对于配置之类的事情,这是一种非常干净的方式来传入数据。我们还应该更新构造函数以创建新类型:

#![allow(unused)]
fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::RexLevel{ template : "../../resources/wfc-demo1.xp" }
        }
    }
    ...
}

将地图模板路径包含在模式中使其更易于阅读,即使它稍微复杂一些。我们没有用我们可能使用的所有选项的变量填充 PrefabBuilder - 我们将它们分开保存。这通常是一个好的做法 - 它使阅读您代码的人更容易清楚地了解正在发生的事情。

现在我们将重新实现我们之前从 image_loader.rs 中删除的地图读取器 - 只是我们将它添加为 PrefabBuilder 的成员函数,并使用封闭类特性,而不是传入和传出 Mapnew_depth

#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
    let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < self.map.width as usize && y < self.map.height as usize {
                    let idx = self.map.xy_idx(x as i32, y as i32);
                    match (cell.ch as u8) as char {
                        ' ' => self.map.tiles[idx] = TileType::Floor, // 空格
                        '#' => self.map.tiles[idx] = TileType::Wall, // #
                        _ => {}
                    }
                }
            }
        }
    }
}
}

这非常直接,或多或少是波函数坍缩章节中形式的直接移植。现在让我们开始制作我们的 build 函数:

#![allow(unused)]
fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template)
    }

    // 找到一个起始点;从中间开始向左走,直到找到一个开放的瓦片
    self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
    let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
    while self.map.tiles[start_idx] != TileType::Floor {
        self.starting_position.x -= 1;
        start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
    }
    self.take_snapshot();
}
}

请注意,我们已经复制了寻找起始点代码;我们将在某个时候改进它,但目前它确保您可以玩您的关卡。我们没有生成任何东西 - 所以您将独自一人在关卡中。这里也稍微不同地使用了 match - 我们正在使用枚举中的变量。代码 PrefabMode::RexLevel{template} 表示“匹配 RexLevel,但使用 template任何值 - 并在匹配范围内通过名称 template 使该值可用”。如果您不想访问它,可以使用 _ 来匹配任何值。Rust 的模式匹配系统 确实令人印象深刻 - 您可以使用它做很多事情!

让我们修改我们的 random_builder 函数以始终调用这种类型的地图(这样我们就不必一遍又一遍地测试,希望得到我们想要的那一个!)。在 map_builders/mod.rs 中:

#![allow(unused)]
fn main() {
pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> {
    /*
    let mut rng = rltk::RandomNumberGenerator::new();
    let builder = rng.roll_dice(1, 16);
    let mut result : Box<dyn MapBuilder>;
    match builder {
        1 => { result = Box::new(BspDungeonBuilder::new(new_depth)); }
        2 => { result = Box::new(BspInteriorBuilder::new(new_depth)); }
        3 => { result = Box::new(CellularAutomataBuilder::new(new_depth)); }
        4 => { result = Box::new(DrunkardsWalkBuilder::open_area(new_depth)); }
        5 => { result = Box::new(DrunkardsWalkBuilder::open_halls(new_depth)); }
        6 => { result = Box::new(DrunkardsWalkBuilder::winding_passages(new_depth)); }
        7 => { result = Box::new(DrunkardsWalkBuilder::fat_passages(new_depth)); }
        8 => { result = Box::new(DrunkardsWalkBuilder::fearful_symmetry(new_depth)); }
        9 => { result = Box::new(MazeBuilder::new(new_depth)); }
        10 => { result = Box::new(DLABuilder::walk_inwards(new_depth)); }
        11 => { result = Box::new(DLABuilder::walk_outwards(new_depth)); }
        12 => { result = Box::new(DLABuilder::central_attractor(new_depth)); }
        13 => { result = Box::new(DLABuilder::insectoid(new_depth)); }
        14 => { result = Box::new(VoronoiCellBuilder::pythagoras(new_depth)); }
        15 => { result = Box::new(VoronoiCellBuilder::manhattan(new_depth)); }
        _ => { result = Box::new(SimpleMapBuilder::new(new_depth)); }
    }

    if rng.roll_dice(1, 3)==1 {
        result = Box::new(WaveformCollapseBuilder::derived_map(new_depth, result));
    }

    result*/

    Box::new(PrefabBuilder::new(new_depth))
}
}

如果您现在 cargo run 您的项目,您可以在(原本荒凉的)演示地图中四处奔跑:

Screenshot.

使用预制实体填充测试地图

让我们假设我们的测试地图是某种超级终极游戏地图。我们将复制一份并将其命名为 wfc-populated.xp。然后我们将在其周围喷溅一堆怪物和物品字形:

Screenshot.

颜色编码是完全可选的,但我为了清晰起见将其放入。您会看到我们有一个 @ 来指示玩家起始位置,一个 > 来指示出口,以及一堆 g 地精,o 兽人,! 药水,% 军粮和 ^ 陷阱。地图还不错,真的。

我们将 wfc-populated.xp 添加到我们的 resources 文件夹中,并扩展 rex_assets.rs 以加载它:

#![allow(unused)]
fn main() {
use rltk::{rex::XpFile};

rltk::embedded_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
rltk::embedded_resource!(WFC_POPULATED, "../../resources/wfc-populated.xp");

pub struct RexAssets {
    pub menu : XpFile
}

impl RexAssets {
    #[allow(clippy::new_without_default)]
    pub fn new() -> RexAssets {
        rltk::link_resource!(SMALL_DUNGEON, "../../resources/SmallDungeon_80x50.xp");
        rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
        rltk::link_resource!(WFC_POPULATED, "../../resources/wfc-populated.xp");

        RexAssets{
            menu : XpFile::from_resource("../../resources/SmallDungeon_80x50.xp").unwrap()
        }
    }
}
}

我们还希望能够列出地图所需的生成物。查看 spawner.rs,我们有一个已建立的 tuple 格式,用于传递生成物 - 所以我们将在结构体中使用它:

#![allow(unused)]
fn main() {
#[allow(dead_code)]
pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    mode: PrefabMode,
    spawns: Vec<(usize, String)>
}
}

现在我们将修改我们的构造函数以使用新地图,并初始化 spawns

#![allow(unused)]
fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::RexLevel{ template : "../../resources/wfc-populated.xp" },
            spawns: Vec::new()
        }
    }
    ...
}

为了使用 spawner.rs 中接受这种类型数据的函数,我们需要使其成为 public 的。所以我们打开文件,并在函数签名中添加单词 pub

#![allow(unused)]
fn main() {
/// 在 (tuple.0) 中的位置生成一个命名的实体(tuple.1 中的名称)
pub fn spawn_entity(ecs: &mut World, spawn : &(&usize, &String)) {
    ...
}

然后我们将修改 PrefabBuilderspawn_entities 函数以使用此数据:

#![allow(unused)]
fn main() {
fn spawn_entities(&mut self, ecs : &mut World) {
    for entity in self.spawns.iter() {
        spawner::spawn_entity(ecs, &(&entity.0, &entity.1));
    }
}
}

我们使用引用做了一些舞蹈,只是为了使用之前的函数签名(并且不必更改它,这将更改许多其他代码)。到目前为止,一切都很好 - 它读取 spawn 列表,并请求将列表中的所有内容放置到地图上。现在是向列表中添加一些内容的好时机!我们将要修改我们的 load_rex_map 以处理新数据:

#![allow(unused)]
fn main() {
 #[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
    let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < self.map.width as usize && y < self.map.height as usize {
                    let idx = self.map.xy_idx(x as i32, y as i32);
                    // 我们正在做一些令人讨厌的类型转换,以便更容易在匹配中键入诸如 '#' 之类的东西
                    match (cell.ch as u8) as char {
                        ' ' => self.map.tiles[idx] = TileType::Floor,
                        '#' => self.map.tiles[idx] = TileType::Wall,
                        '@' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.starting_position = Position{ x:x as i32, y:y as i32 };
                        }
                        '>' => self.map.tiles[idx] = TileType::DownStairs,
                        'g' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Goblin".to_string()));
                        }
                        'o' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Orc".to_string()));
                        }
                        '^' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Bear Trap".to_string()));
                        }
                        '%' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Rations".to_string()));
                        }
                        '!' => {
                            self.map.tiles[idx] = TileType::Floor;
                            self.spawns.push((idx, "Health Potion".to_string()));
                        }
                        _ => {
                            rltk::console::log(format!("加载地图时未知字形: {}", (cell.ch as u8) as char));
                        }
                    }
                }
            }
        }
    }
}
}

这可以识别额外的字形,并且如果我们加载了我们忘记处理的字形,则会在控制台中打印警告。请注意,对于实体,我们将瓦片设置为 Floor然后 添加实体类型。这是因为我们不能在同一瓦片上叠加两个字形 - 但有理由认为实体是站立在地板上的。

最后,我们需要修改我们的 build 函数,使其不移动出口和玩家。我们只需将回退代码包装在一个 if 语句中,以检测我们是否设置了 starting_position(我们将要求如果您设置了起始位置,您也设置出口):

#![allow(unused)]
fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template)
    }
    self.take_snapshot();

    // 找到一个起始点;从中间开始向左走,直到找到一个开放的瓦片
    if self.starting_position.x == 0 {
        self.starting_position = Position{ x: self.map.width / 2, y : self.map.height / 2 };
        let mut start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        while self.map.tiles[start_idx] != TileType::Floor {
            self.starting_position.x -= 1;
            start_idx = self.map.xy_idx(self.starting_position.x, self.starting_position.y);
        }
        self.take_snapshot();

        // 找到我们可以从起点到达的所有瓦片
        let exit_tile = remove_unreachable_areas_returning_most_distant(&mut self.map, start_idx);
        self.take_snapshot();

        // 放置楼梯
        self.map.tiles[exit_tile] = TileType::DownStairs;
        self.take_snapshot();
    }
}
}

如果您现在 cargo run 该项目,您将从指定的位置开始 - 并且实体会在您周围生成。

Screenshot.

无 Rex 预制件

您可能不喜欢 Rex Paint(别担心,我不会告诉 Kyzrati!),也许您在不支持它的平台上 - 或者也许您只是不想依赖外部工具。我们将扩展我们的读取器以支持地图的字符串输出。当我们处理小房间预制件/vaults 时,这将非常方便。

我作弊了一下,在 Rex 中打开了 wfc-populated.xp 文件,然后键入 ctrl-tTXT 格式保存。这给了我一个不错的 Notepad 友好的地图文件:

Screenshot.

我也意识到 prefab_builder 将会超出单个文件的大小!幸运的是,Rust 使将模块变成多文件怪物变得非常容易。在 map_builders 中,我创建了一个名为 prefab_builder 的新目录。然后我将 prefab_builder.rs 移动到其中,并将其重命名为 mod.rs。游戏编译并运行的方式与以前完全相同。

在您的 prefab_builder 文件夹中创建一个新文件,并将其命名为 prefab_levels.rs。我们将粘贴地图定义,并稍微装饰一下:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabLevel {
    pub template : &'static str,
    pub width : usize,
    pub height: usize
}

pub const WFC_POPULATED : PrefabLevel = PrefabLevel{
    template : LEVEL_MAP,
    width: 80,
    height: 43
};

const LEVEL_MAP : &str =
"
###############################################################################
          ########################################################    #########
    @     ######    #########       ####     ###################        #######
          ####   g  #                          ###############            #####
          #### #    # #######       ####       #############                ###
#### ######### #    # #######       #########  ####    #####                ###
#### ######### ###### #######   o   #########  #### ## #####                ###
#                        ####       #########   ### ##         o            ###
#### ######### ###       ####       #######         ## #####                ###
#### ######### ###       ####       ####### #   ### ## #####                ###
#### ######### ###       ####       ####### #######    #####     o          ###
##          ## ###       ####       ####### ################                ###
##          ## ###   o   ###### ########### #   ############                ###
##          ## ###       ###### ###########     ###                         ###
##    %                  ###### ########### #   ###   !   ##                ###
##          ## ###              ######   ## #######       ##                ###
##          ## ###       ## ### #####     # ########################      #####
##          ## ###       ## ### #####     # #   ######################    #####
### ## ####### ###### ##### ### ####          o ###########     ######    #####
### ## ####### ###### ####   ## ####        #   #########         ###### ######
    ## ####### ###### ####   ## ####        ############           ##### ######
 g  ## ####### ###### ####   ##        %    ###########   o      o  #### #    #
    ## ###            ####   ## ####        #   #######   ##    ##  ####   g  #
######                  ####### ####            ######     !    !    ### #    #
#####                     ##### ####        #   ######               ### ######
####                            #####     # ##########               ### ######
####           !           ### ######     # ##########      o##o     ### #   ##
####                       ### #######   ## #   ######               ###   g ##
   ##                     #### ######## ###   o #######  ^########^ #### #   ##
 g    #                 ###### ######## #####   #######  ^        ^ #### ######
   ##g####           ######    ######## ################           ##### ######
   ## ########## ##########    ######## #################         ######      #
####   ######### ########## %  ######## ###################     ######## ##   #
### ### ######## ##########    ######## #################### ##########   #   #
## ##### ######   #########    ########          ########### #######   # g#   #
## #####           ###############      ###      ########### #######   ####   #
## ##### ####       ############## ######## g  g ########### ####         # ^ #
### ###^####         ############# ########      #####       ####      # g#   #
####   ######       ###            ########      ##### g     ####   !  ####^^ #
#!%^## ###  ##           ########## ########  gg                 g         # > #
#!%^   ###  ###     ############### ########      ##### g     ####      # g#   #
 %^##  ^   ###     ############### ########      #####       ##################
###############################################################################
";
}

所以我们首先定义一个新的 struct 类型:PrefabLevel。它包含一个地图模板、宽度和高度。然后我们创建一个常量 WFC_POPULATED,并在其中创建一个始终可用的关卡定义。最后,我们将我们的 Notepad 文件粘贴到一个新的常量中,目前称为 MY_LEVEL。这是一个大字符串,将像任何其他字符串一样存储。

让我们修改 mode 以也允许这种类型:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum PrefabMode {
    RexLevel{ template : &'static str },
    Constant{ level : prefab_levels::PrefabLevel }
}
}

我们将修改我们的 build 函数以也处理这种 match 模式:

#![allow(unused)]
fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template),
        PrefabMode::Constant{level} => self.load_ascii_map(&level)
    }
    self.take_snapshot();
    ...
}

并修改我们的构造函数以使用它:

#![allow(unused)]
fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::Constant{level : prefab_levels::WFC_POPULATED},
            spawns: Vec::new()
        }
    }
}

现在我们需要创建一个可以处理它的加载器。我们将修改我们的 load_rex_map 以与其共享一些代码,这样我们就不会重复键入所有内容 - 并制作我们的新 load_ascii_map 函数:

#![allow(unused)]
fn main() {
fn char_to_map(&mut self, ch : char, idx: usize) {
    match ch {
        ' ' => self.map.tiles[idx] = TileType::Floor,
        '#' => self.map.tiles[idx] = TileType::Wall,
        '@' => {
            let x = idx as i32 % self.map.width;
            let y = idx as i32 / self.map.width;
            self.map.tiles[idx] = TileType::Floor;
            self.starting_position = Position{ x:x as i32, y:y as i32 };
        }
        '>' => self.map.tiles[idx] = TileType::DownStairs,
        'g' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Goblin".to_string()));
        }
        'o' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Orc".to_string()));
        }
        '^' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Bear Trap".to_string()));
        }
        '%' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Rations".to_string()));
        }
        '!' => {
            self.map.tiles[idx] = TileType::Floor;
            self.spawns.push((idx, "Health Potion".to_string()));
        }
        _ => {
            rltk::console::log(format!("加载地图时未知字形: {}", (ch as u8) as char));
        }
    }
}

#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
    let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();

    for layer in &xp_file.layers {
        for y in 0..layer.height {
            for x in 0..layer.width {
                let cell = layer.get(x, y).unwrap();
                if x < self.map.width as usize && y < self.map.height as usize {
                    let idx = self.map.xy_idx(x as i32, y as i32);
                    // 我们正在做一些令人讨厌的类型转换,以便更容易在匹配中键入诸如 '#' 之类的东西
                    self.char_to_map(cell.ch as u8 as char, idx);
                }
            }
        }
    }
}

#[allow(dead_code)]
fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel) {
    // 首先转换为一个向量,删除换行符
    let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a != '\r' && *a !='\n').collect();
    for c in string_vec.iter_mut() { if *c as u8 == 160u8 { *c = ' '; } }

    let mut i = 0;
    for ty in 0..level.height {
        for tx in 0..level.width {
            if tx < self.map.width as usize && ty < self.map.height as usize {
                let idx = self.map.xy_idx(tx as i32, ty as i32);
                self.char_to_map(string_vec[i], idx);
            }
            i += 1;
        }
    }
}
}

首先要注意的是 load_rex_map 中的巨大 match 现在是一个函数 - char_to_map。由于我们多次使用该功能,因此这是一个好的做法:现在如果搞砸了,我们只需要修复一次!否则,load_rex_map 几乎相同。我们的新函数是 load_ascii_map。它从一些丑陋的代码开始,这些代码需要解释:

  1. let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a != '\r' && *a !='\n').collect(); 是一种常见的 Rust 模式,但根本不是不言自明的。它以从左到右的顺序将方法链接在一起。所以它实际上是一大堆粘合在一起的指令:
    1. let mut string_vec : Vec<char> 只是说“创建一个名为 string_vec 的变量,或 Vec<char> 类型,并允许我编辑它。
    2. level.template 是我们的关卡模板所在的字符串。
    3. .chars() 将字符串转换为迭代器 - 与我们之前键入 myvector.iter() 时相同。
    4. .filter(|a| *a != '\r' && *a !='\n') 很有趣。过滤器接受一个 lambda 函数,并保留任何返回 true 的条目。所以在这种情况下,我们正在剥离 \r\n - 两个换行符。我们将保留其他所有内容。
    5. .collect() 表示“获取我之前的所有内容的結果,并将它们放入一个向量中。”
  2. 然后,我们可变地迭代字符串向量,并将字符 160 转换为空格。我真的不知道为什么文本将空格读取为字符 160 而不是 32,但我们将接受它并将其转换。
  3. 然后我们从 0 到指定的高度迭代 y
    1. 然后我们从 0 到指定的宽度迭代 x
      1. 如果 xy 值在我们正在创建的地图范围内,我们计算地图瓦片的 idx - 并调用我们的 char_to_map 函数来转换它。

如果您现在 cargo run,您将看到与以前完全相同的内容 - 但是我们不是加载 Rex Paint 文件,而是从 prefab_levels.rs 中的常量 ASCII 加载它。

构建关卡片段

你勇敢的冒险家从蜿蜒的隧道中出现,遇到了一个古老的地下防御工事的墙壁! 这是伟大的 D&D 故事的素材,也是《地下城爬行:石汤》等游戏中偶尔发生的事情。很可能实际发生的是你勇敢的冒险家从程序生成的地图中出现,并找到了一个关卡片段预制件!

我们将扩展我们的地图系统以明确支持这一点:一个常规构建器创建一个地图,然后一个分段预制件用您令人兴奋的预制内容替换地图的一部分。我们将首先创建一个新文件(在 map_builders/prefab_builder 中),名为 prefab_sections.rs,并放置我们想要的描述:

#![allow(unused)]
fn main() {
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub enum HorizontalPlacement { Left, Center, Right }

#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub enum VerticalPlacement { Top, Center, Bottom }

#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabSection {
    pub template : &'static str,
    pub width : usize,
    pub height: usize,
    pub placement : (HorizontalPlacement, VerticalPlacement)
}

#[allow(dead_code)]
pub const UNDERGROUND_FORT : PrefabSection = PrefabSection{
    template : RIGHT_FORT,
    width: 15,
    height: 43,
    placement: ( HorizontalPlacement::Right, VerticalPlacement::Top )
};

#[allow(dead_code)]
const RIGHT_FORT : &str = "
              
  ######      
       #      
       #######
    g        #
       #######
       #      
  ## ###      
     #        
     #        
     ##       
    ^          
    ^          
     ##       
     #        
     #        
     #        
     #        
  ## ###      
       #      
       #      
    g  #      
       #      
       #      
  ## ###      
     #        
     #        
     #        
     ##       
    ^          
    ^          
     ##       
     #        
     #        
     #        
  ## ###      
       #      
       #######
    g        #
       #######
       #      
  ######      
              
";
}

所以我们有 RIGHT_FORT 作为一个字符串,描述了我们可能遇到的防御工事。我们构建了一个结构体 PrefabSection,其中包括放置提示,以及我们的实际堡垒 (UNDERGROUND_FORT) 的常量,指定我们希望在地图的右侧,顶部(垂直位置在这个例子中并不重要,因为它与地图的完整大小相同)。

关卡片段与我们之前制作的构建器不同,因为它们采用已完成的地图 - 并替换其中的一部分。我们在波函数坍缩中做了一些类似的事情,所以我们将采用类似的模式。我们将首先修改我们的 PrefabBuilder 以了解新型的地图装饰:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
#[allow(dead_code)]
pub enum PrefabMode {
    RexLevel{ template : &'static str },
    Constant{ level : prefab_levels::PrefabLevel },
    Sectional{ section : prefab_sections::PrefabSection }
}

#[allow(dead_code)]
pub struct PrefabBuilder {
    map : Map,
    starting_position : Position,
    depth: i32,
    history: Vec<Map>,
    mode: PrefabMode,
    spawns: Vec<(usize, String)>,
    previous_builder : Option<Box<dyn MapBuilder>>
}
}

尽管我很previous_builder 放入枚举中,但我一直遇到生命周期问题。也许有一种方法可以做到这一点(并且一些好心的读者会帮助我?),但目前我已将其放入 PrefabBuilder 中。但是,请求的地图片段在参数中。我们还更新了构造函数以使用这种类型的地图:

#![allow(unused)]
fn main() {
impl PrefabBuilder {
    #[allow(dead_code)]
    pub fn new(new_depth : i32, previous_builder : Option<Box<dyn MapBuilder>>) -> PrefabBuilder {
        PrefabBuilder{
            map : Map::new(new_depth),
            starting_position : Position{ x: 0, y : 0 },
            depth : new_depth,
            history : Vec::new(),
            mode : PrefabMode::Sectional{ section: prefab_sections::UNDERGROUND_FORT },
            spawns: Vec::new(),
            previous_builder
        }
    }
    ...
}

map_builders/mod.rsrandom_builder 中,我们将修改构建器以首先运行细胞自动机地图,然后应用分段:

#![allow(unused)]
fn main() {
Box::new(
    PrefabBuilder::new(
        new_depth,
        Some(
            Box::new(
                CellularAutomataBuilder::new(new_depth)
            )
        )
    )
)
}

这可能是一行代码,但由于括号数量众多,我将其分开了。

接下来,我们更新我们的 match 语句(在 build() 中)以实际调用构建器:

#![allow(unused)]
fn main() {
fn build(&mut self) {
    match self.mode {
        PrefabMode::RexLevel{template} => self.load_rex_map(&template),
        PrefabMode::Constant{level} => self.load_ascii_map(&level),
        PrefabMode::Sectional{section} => self.apply_sectional(&section)
    }
    self.take_snapshot();
    ...
}

现在,我们将编写 apply_sectional

#![allow(unused)]
fn main() {
pub fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection) {
    // 构建地图
    let prev_builder = self.previous_builder.as_mut().unwrap();
    prev_builder.build_map();
    self.starting_position = prev_builder.get_starting_position();
    self.map = prev_builder.get_map().clone();
    self.take_snapshot();

    use prefab_sections::*;

    let string_vec = PrefabBuilder::read_ascii_to_vec(section.template);

    // 放置新片段
    let chunk_x;
    match section.placement.0 {
        HorizontalPlacement::Left => chunk_x = 0,
        HorizontalPlacement::Center => chunk_x = (self.map.width / 2) - (section.width as i32 / 2),
        HorizontalPlacement::Right => chunk_x = (self.map.width-1) - section.width as i32
    }

    let chunk_y;
    match section.placement.1 {
        VerticalPlacement::Top => chunk_y = 0,
        VerticalPlacement::Center => chunk_y = (self.map.height / 2) - (section.height as i32 / 2),
        VerticalPlacement::Bottom => chunk_y = (self.map.height-1) - section.height as i32
    }
    println!("{},{}", chunk_x, chunk_y);

    let mut i = 0;
    for ty in 0..section.height {
        for tx in 0..section.width {
            if tx < self.map.width as usize && ty < self.map.height as usize {
                let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                self.char_to_map(string_vec[i], idx);
            }
            i += 1;
        }
    }
    self.take_snapshot();
}
}

这与我们编写的其他代码非常相似,但无论如何让我们逐步了解它:

  1. let prev_builder = self.previous_builder.as_mut().unwrap(); 非常拗口。之前的构建器是一个 Option - 但如果我们调用此代码,它必须有一个值。所以我们想 unwrap 它(如果没有任何值,它会 panic 并崩溃),但我们不能!如果我们只是调用 previous_builder.unwrap,借用检查器会抱怨 - 所以我们必须在那里注入一个 as_mut()Option 为此目的提供了它。
  2. 我们在之前的构建器上调用 build_map,以构建基础地图。
  3. 我们将起始位置从之前的构建器复制到我们的新构建器。
  4. 我们将地图从之前的构建器复制到我们自己(新的构建器)。
  5. 我们调用 read_ascii_to_vec,它与关卡示例中的字符串到向量代码相同;实际上,我们已经更新了关卡示例以也使用它,在源代码中。
  6. 我们创建两个变量 chunk_xchunk_y 并查询片段的放置偏好以确定将新块放在哪里。
  7. 我们像之前迭代关卡一样迭代片段 - 但将 chunk_x 添加到 tx,将 chunk_y 添加到 ty 以偏移关卡内的片段。

如果您现在 cargo run 该示例,您将看到一张用洞穴建造的地图 - 以及右侧的防御工事。

Screenshot.

您可能还会注意到,除了预制区域之外,根本没有任何实体!

向片段添加实体

生成和确定生成点在逻辑上是分开的,以帮助保持地图生成代码的清洁。不同的地图可以有自己的实体放置策略,因此没有一种直接的方法可以简单地吸取先前算法中的数据并添加到其中。应该有,并且它应该启用过滤以及稍后“元地图构建器”(例如 WFC 或此构建器)的各种调整。我们在预制件中放置实体的代码中偶然发现了一个关于良好接口的线索:生成系统已经支持 (position, type string)tuples。我们将使用它作为新设置的基础。

我们将首先打开 map_builders/mod.rs 并编辑 MapBuilder trait:

#![allow(unused)]
fn main() {
pub trait MapBuilder {
    fn build_map(&mut self);
    fn get_map(&self) -> Map;
    fn get_starting_position(&self) -> Position;
    fn get_snapshot_history(&self) -> Vec<Map>;
    fn take_snapshot(&mut self);
    fn get_spawn_list(&self) -> &Vec<(usize, String)>;

    fn spawn_entities(&mut self, ecs : &mut World) {
        for entity in self.get_spawn_list().iter() {
            spawner::spawn_entity(ecs, &(&entity.0, &entity.1));
        }
    }
}
}

恭喜,您一半的源代码在您的 IDE 中都变成了红色。这就是更改基本接口的危险 - 您最终会在所有地方实现它。此外,spawn_entities 的设置已更改 - 现在有一个默认实现。trait 的实现者如果愿意,可以覆盖它 - 否则他们实际上不需要再编写它了。由于一切应该通过 get_spawn_list 函数可用,因此 trait 拥有提供该实现所需的一切。

我们将回到 simple_map 并更新它以遵守新的 trait 规则。我们将扩展 SimpleMapBuiler 结构以包含生成列表:

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

get_spawn_list 的实现很简单:

#![allow(unused)]
fn main() {
fn get_spawn_list(&self) -> &Vec<(usize, String)> {
    &self.spawn_list
}
}

现在到了有趣的部分。以前,我们直到调用 spawn_entities 才考虑生成。让我们提醒自己它做了什么(已经有一段时间了!):

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

它迭代所有房间,并在房间内生成实体。我们经常使用这种模式,所以现在是时候访问 spawner.rs 中的 spawn_room 了。我们将修改它以生成 spawn_list 中,而不是直接在地图上生成。所以我们打开 spawner.rs,并修改 spawn_roomspawn_region(由于它们是交织在一起的,我们将一起修复它们):

#![allow(unused)]
fn main() {
/// 用东西填充房间!
pub fn spawn_room(map: &Map, rng: &mut RandomNumberGenerator, room : &Rect, map_depth: i32, spawn_list : &mut Vec<(usize, String)>) {
    let mut possible_targets : Vec<usize> = Vec::new();
    { // 借用范围 - 保持对地图的访问分开
        for y in room.y1 + 1 .. room.y2 {
            for x in room.x1 + 1 .. room.x2 {
                let idx = map.xy_idx(x, y);
                if map.tiles[idx] == TileType::Floor {
                    possible_targets.push(idx);
                }
            }
        }
    }

    spawn_region(map, rng, &possible_targets, map_depth, spawn_list);
}

/// 用东西填充区域!
pub fn spawn_region(map: &Map, rng: &mut RandomNumberGenerator, area : &[usize], map_depth: i32, spawn_list : &mut Vec<(usize, String)>) {
    let spawn_table = room_table(map_depth);
    let mut spawn_points : HashMap<usize, String> = HashMap::new();
    let mut areas : Vec<usize> = Vec::from(area);

    // 范围以使借用检查器满意
    {
        let num_spawns = i32::min(areas.len() as i32, rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3);
        if num_spawns == 0 { return; }

        for _i in 0 .. num_spawns {
            let array_index = if areas.len() == 1 { 0usize } else { (rng.roll_dice(1, areas.len() as i32)-1) as usize };

            let map_idx = areas[array_index];
            spawn_points.insert(map_idx, spawn_table.roll(rng));
            areas.remove(array_index);
        }
    }

    // 实际生成怪物
    for spawn in spawn_points.iter() {
        spawn_list.push((*spawn.0, spawn.1.to_string()));
    }
}
}

您会注意到最大的变化是在每个函数中获取对 spawn_list 的可变引用,而不是实际生成实体 - 我们通过在末尾将生成信息推送到 spawn_list 向量中来延迟操作。我们没有传入 ECS,而是传入 MapRandomNumberGenerator

回到 simple_map.rs,我们将生成代码移动到 build 的末尾:

#![allow(unused)]
fn main() {
...
self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };

// 生成一些实体
for room in self.rooms.iter().skip(1) {
    spawner::spawn_room(&self.map, &mut rng, room, self.depth, &mut self.spawn_list);
}
}

我们现在可以删除 SimpleMapBuilderspawn_entities 实现 - 默认实现将正常工作。

同样的更改可以应用于所有依赖房间生成的构建器;为简洁起见,我不会在此处详细说明所有内容 - 您可以在源代码中找到它们。使用 Voronoi 图的各种构建器也同样易于更新。例如,细胞自动机。将 spawn_list 添加到构建器结构,并在构造函数中添加 spawn_list : Vec::new()。将怪物生成从 spawn_entities 移动到 build 的末尾并删除该函数。从其他实现中复制 get_spawn_list。我们稍微更改了区域生成代码,所以这是来自 cellular_automata.rs 的实现:

#![allow(unused)]
fn main() {
// 现在我们构建一个噪声地图,用于稍后生成实体
self.noise_areas = generate_voronoi_spawn_regions(&self.map, &mut rng);

// 生成实体
for area in self.noise_areas.iter() {
    spawner::spawn_region(&self.map, &mut rng, area.1, self.depth, &mut self.spawn_list);
}
}

再次,在其他 Voronoi 生成算法上重复此操作。如果您想看一下,我已经在源代码中为您完成了这项工作。

如果重构很无聊,请跳转到这里!

所以 - 既然我们已经重构了我们的生成系统,我们如何在我们的 PrefabBuilder使用它?我们可以在我们的 apply_sectional 函数中添加一行,并从之前的地图中获取所有实体。您可以简单地复制它,但这可能不是您想要的;您需要过滤掉新预制件内部的实体,既要为新的实体腾出空间,又要确保生成是有意义的。我们还需要稍微重新排列一下,以使借用检查器满意。这是现在的函数:

#![allow(unused)]
fn main() {
pub fn apply_sectional(&mut self, section : &prefab_sections::PrefabSection) {
    use prefab_sections::*;

    let string_vec = PrefabBuilder::read_ascii_to_vec(section.template);

    // 放置新片段
    let chunk_x;
    match section.placement.0 {
        HorizontalPlacement::Left => chunk_x = 0,
        HorizontalPlacement::Center => chunk_x = (self.map.width / 2) - (section.width as i32 / 2),
        HorizontalPlacement::Right => chunk_x = (self.map.width-1) - section.width as i32
    }

    let chunk_y;
    match section.placement.1 {
        VerticalPlacement::Top => chunk_y = 0,
        VerticalPlacement::Center => chunk_y = (self.map.height / 2) - (section.height as i32 / 2),
        VerticalPlacement::Bottom => chunk_y = (self.map.height-1) - section.height as i32
    }

    // 构建地图
    let prev_builder = self.previous_builder.as_mut().unwrap();
    prev_builder.build_map();
    self.starting_position = prev_builder.get_starting_position();
    self.map = prev_builder.get_map().clone();
    for e in prev_builder.get_spawn_list().iter() {
        let idx = e.0;
        let x = idx as i32 % self.map.width;
        let y = idx as i32 / self.map.width;
        if x < chunk_x || x > (chunk_x + section.width as i32) ||
            y < chunk_y || y > (chunk_y + section.height as i32) {
                self.spawn_list.push(
                    (idx, e.1.to_string())
                )
            }
    }
    self.take_snapshot();

    let mut i = 0;
    for ty in 0..section.height {
        for tx in 0..section.width {
            if tx > 0 && tx < self.map.width as usize -1 && ty < self.map.height as usize -1 && ty > 0 {
                let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 + chunk_y);
                self.char_to_map(string_vec[i], idx);
            }
            i += 1;
        }
    }
    self.take_snapshot();
}
}

如果您现在 cargo run,您将在地图的两个部分都面对敌人。

Screenshot.

总结

在本章中,我们涵盖了很多内容:

  • 我们可以加载 Rex Paint 关卡,完成手工放置的实体并进行游戏。
  • 我们可以在游戏中定义 ASCII 预制地图,并进行游戏(无需使用 Rex Paint)。
  • 我们可以加载关卡片段,并将它们应用于关卡。
  • 我们可以调整构建器链中先前关卡的生成物。

...

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

在您的浏览器中使用 Web 程序集运行本章的示例(需要 WebGL2)

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