深入地下城


关于本教程

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

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

Hands-On Rust


我们现在拥有了地下城探索游戏的所有基础要素,但只有一个关卡是一个很大的限制! 本章将介绍深度,每个更深的层级都会生成一个新的地下城。 我们将跟踪玩家的深度,并鼓励更深入的探索。 玩家可能会遇到什么问题呢?

指示和存储深度

我们将首先将当前深度添加到地图中。 在 map.rs 中,我们调整 Map 结构以包含深度的整数:

#![allow(unused)]
fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
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 depth : i32,

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

i32 是一种基本类型,由序列化库 Serde 自动处理。 因此,在此处添加它会自动将其添加到我们的游戏保存/加载机制中。 我们的地图创建代码还需要指示我们位于地图的第 1 层。 我们希望能够将地图生成器用于其他层级,因此我们还添加一个参数。 更新后的函数如下所示:

#![allow(unused)]
fn main() {
pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map {
    let mut 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
    };
    ...
}

我们从 main.rs 中的设置代码调用此函数,因此我们也需要修改对地下城构建器的调用:

#![allow(unused)]
fn main() {
let map : Map = Map::new_map_rooms_and_corridors(1);
}

就这样! 我们的地图现在知道了深度。 你需要删除你拥有的任何 savegame.json 文件,因为我们已经更改了格式 - 加载将会失败。

向玩家显示地图深度

我们将修改玩家的抬头显示器 (heads-up-display) 以指示当前的地图深度。 在 gui.rs 中,在 draw_ui 函数内部,我们添加以下内容:

#![allow(unused)]
fn main() {
let map = ecs.fetch::<Map>();
let depth = format!("Depth: {}", map.depth);
ctx.print_color(2, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &depth);
}

如果你现在 cargo run 项目,你将看到我们正在向你显示当前的深度:

Screenshot

添加下楼楼梯

map.rs 中,我们有一个枚举 - TileType - 列出了可用的地块类型。 我们想要添加一个新的:下楼楼梯。 像这样修改枚举:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum TileType {
    Wall, Floor, DownStairs
}
}

我们还希望能够渲染楼梯。 map.rs 包含 draw_map,添加地块类型是一项相对简单的任务:

#![allow(unused)]
fn main() {
match tile {
    TileType::Floor => {
        glyph = rltk::to_cp437('.');
        fg = RGB::from_f32(0.0, 0.5, 0.5);
    }
    TileType::Wall => {
        glyph = rltk::to_cp437('#');
        fg = RGB::from_f32(0., 1.0, 0.);
    }
    TileType::DownStairs => {
        glyph = rltk::to_cp437('>');
        fg = RGB::from_f32(0., 1.0, 1.0);
    }
}
}

最后,我们应该放置下楼楼梯。 我们将上楼楼梯放置在地图生成的第一个房间的中心 - 因此我们将楼梯放置在最后一个房间的中心! 回到 map.rs 中的 new_map_rooms_and_corridors,我们像这样修改它:

#![allow(unused)]
fn main() {
pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map {
    let mut 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
    };

    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 {
            map.apply_room_to_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 {
                    map.apply_horizontal_tunnel(prev_x, new_x, prev_y);
                    map.apply_vertical_tunnel(prev_y, new_y, new_x);
                } else {
                    map.apply_vertical_tunnel(prev_y, new_y, prev_x);
                    map.apply_horizontal_tunnel(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;

    map
}
}

如果你现在 cargo run 项目,并四处走动一下 - 你可以找到一组下楼楼梯! 它们目前没有任何作用,但它们在地图上。

Screenshot

实际进入下一层

player.rs 中,我们有一个大的 match 语句来处理用户输入。 让我们将进入下一层绑定到句点键 (在美式键盘上,那是 >,不带 shift 键)。 将此添加到 match

#![allow(unused)]
fn main() {
// 层级变更
VirtualKeyCode::Period => {
    if try_next_level(&mut gs.ecs) {
        return RunState::NextLevel;
    }
}
}

当然,现在我们需要实现 try_next_level

#![allow(unused)]
fn main() {
pub fn try_next_level(ecs: &mut World) -> bool {
    let player_pos = ecs.fetch::<Point>();
    let map = ecs.fetch::<Map>();
    let player_idx = map.xy_idx(player_pos.x, player_pos.y);
    if map.tiles[player_idx] == TileType::DownStairs {
        true
    } else {
        let mut gamelog = ecs.fetch_mut::<GameLog>();
        gamelog.entries.push("这里没有下去的路。".to_string());
        false
    }
}
}

眼尖的程序员会注意到我们返回了一个新的 RunState - NextLevel。 由于它还不存在,我们将打开 main.rs 并实现它:

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

你的 IDE 现在会抱怨我们实际上没有实现新的 RunState! 因此,我们进入 main.rs 中不断增长的状态处理程序并添加:

#![allow(unused)]
fn main() {
RunState::NextLevel => {
    self.goto_next_level();
    newrunstate = RunState::PreRun;
}
}

我们将为 State 添加一个新的 impl 部分,以便我们可以将方法附加到它。 我们首先要创建一个辅助方法:

#![allow(unused)]
fn main() {
impl State {
    fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> {
        let entities = self.ecs.entities();
        let player = self.ecs.read_storage::<Player>();
        let backpack = self.ecs.read_storage::<InBackpack>();
        let player_entity = self.ecs.fetch::<Entity>();

        let mut to_delete : Vec<Entity> = Vec::new();
        for entity in entities.join() {
            let mut should_delete = true;

            // 不要删除玩家
            let p = player.get(entity);
            if let Some(_p) = p {
                should_delete = false;
            }

            // 不要删除玩家的装备
            let bp = backpack.get(entity);
            if let Some(bp) = bp {
                if bp.owner == *player_entity {
                    should_delete = false;
                }
            }

            if should_delete {
                to_delete.push(entity);
            }
        }

        to_delete
    }
}
}

当我们进入下一层时,我们想要删除所有实体 - 除了玩家和玩家拥有的任何装备。 这个辅助函数查询 ECS 以获取要删除的实体列表。 它有点冗长,但相对简单:我们创建一个向量,然后迭代所有实体。 如果实体是玩家,我们将其标记为 should_delete=false。 如果它在背包中(具有 InBackpack 组件),我们检查所有者是否是玩家 - 如果是,我们就不删除它。

有了这个,我们开始创建 goto_next_level 函数,也在 State 实现内部:

#![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("无法删除实体");
    }

    // 构建新地图并放置玩家
    let worldmap;
    {
        let mut worldmap_resource = self.ecs.write_resource::<Map>();
        let current_depth = worldmap_resource.depth;
        *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);
        worldmap = worldmap_resource.clone();
    }

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

    // 放置玩家并更新资源
    let (player_x, player_y) = worldmap.rooms[0].center();
    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;
    }

    // 将玩家的视野标记为脏 (dirty)
    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;
    }

    // 通知玩家并给他们一些治疗
    let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
    gamelog.entries.push("你下到下一层,并花点时间治疗。".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);
    }
}
}

这是一个很长的函数,但它完成了我们需要的一切。 让我们逐步分解它:

  1. 我们使用刚刚编写的辅助函数来获取要删除的实体列表,并要求 ECS 处理它们。
  2. 我们创建一个 worldmap 变量,并进入一个新的作用域。 否则,我们会遇到 ECS 的不可变与可变借用的问题。
  3. 在此作用域中,我们获得对当前 Map 资源的写引用。 我们获取当前层级,并将地图替换为新地图 - 新深度为 current_depth + 1。 然后,我们将它的 clone 存储在外部变量中并退出作用域(避免任何借用/生命周期问题)。
  4. 现在我们使用与初始设置中相同的代码,在每个房间中生成坏人和物品。
  5. 现在我们获取第一个房间的位置,并更新玩家的资源,将其位置设置为第一个房间的中心。 我们还获取玩家的 Position 组件并更新它。
  6. 我们获取玩家的 Viewshed 组件,因为它现在已经过时了,因为整个地图都围绕他/她发生了变化! 我们将其标记为 dirty - 并让各种系统处理其余部分。
  7. 我们给玩家一个日志条目,表明他们已下到下一层。
  8. 我们获取玩家的生命值组件,如果他们的生命值低于 50% - 则将其提升到一半。

如果你现在 cargo run 项目,你可以四处奔跑并下降层级。 你的深度指示器会增加 - 告诉你你做对了!

Screenshot

总结

本章比前几章稍微容易一些! 你现在可以下降到一个实际上无限的地下城(它实际上受 32 位整数大小的限制,但祝你好运通过那么多层级)。 我们已经了解了 ECS 如何提供帮助,以及我们的序列化工作如何轻松扩展以包含像这样的新功能,随着我们添加到项目中。

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

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


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