第四章 - 一个更有趣的地图


关于本教程

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

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

实践中的 Rust


在本章中,我们将制作一个更有趣的地图。它将是基于房间的,看起来有点像早期的许多 roguelike 游戏,如 Moria,但复杂度较低。它还将为放置怪物提供一个很好的起点!

清理

我们将从清理代码开始,并使用单独的文件。随着项目的复杂性/大小增加,最好将它们保持为一组干净的文件/模块,这样我们可以快速找到所需的内容(有时还可以提高编译时间)。

如果你查看本章的源代码,你会看到我们已经将许多功能拆分到单独的文件中。当你在 Rust 中创建一个新文件时,它会自动成为一个模块。然后,你必须告诉 Rust 使用这些模块,因此main.rs增加了一些mod map和类似的语句,接着是pub use map::*。这表示“导入模块 map,然后使用并使其公共内容对其他模块可用”。

我们也把一堆struct变成了pub struct,并为它们的成员添加了pub。如果你这样做,那么结构体将仅保留在该模块内部——你无法在代码的其他部分使用它。这类似于在类定义中添加public: C++行,并在头文件中导出类型。Rust 使其更加简洁,无需写两次!

制作更有趣的地图

我们将首先将 new_map(现在在 map.rs 中)重命名为 new_map_test。我们将停止使用它,但暂时保留它——这是一个测试我们地图代码的好方法!我们还将使用 Rust 的文档标签来发布这个函数的功能,以防我们以后忘记:

#![allow(unused)]
fn main() {
/// Makes a map with solid boundaries and 400 randomly placed walls. No guarantees that it won't
/// look awful.
pub fn new_map_test() -> Vec<TileType> {
    ...
}
}

在标准的 Rust 中,如果你在函数前加上以 /// 开头的注释,它就会变成一个函数注释。当你将鼠标悬停在函数头时,你的 IDE 会显示你的注释文本,你可以使用 Cargo 的文档功能 为你正在编写的系统制作漂亮的文档页面。如果你计划分享代码或与他人合作,这非常有用——但拥有它也是很好的!

所以现在,本着原始 libtcod 教程的精神,我们将开始制作地图。我们的目标是在随机位置放置房间,并通过走廊将它们连接起来。

制作几个矩形房间

我们将从一个新函数开始:

#![allow(unused)]
fn main() {
pub fn new_map_rooms_and_corridors() -> Vec<TileType> {
    let mut map = vec![TileType::Wall; 80*50];

    map
}
}

这创建了一个坚固的 80x50 地图,所有瓷砖上都有墙壁——你无法移动!我们保持了函数签名,因此在main.rs中更改我们想要使用的地图只需将gs.ecs.insert(new_map_test());改为gs.ecs.insert(new_map_rooms_and_corridors());。我们再次使用vec!宏来简化我们的生活——有关其工作原理的讨论,请参见上一章。

由于该算法大量使用矩形,并且有一个 Rect 类型 - 我们将在 rect.rs 中创建一个。我们将包含一些在本章后面有用的实用函数:

#![allow(unused)]
fn main() {
pub struct Rect {
    pub x1 : i32,
    pub x2 : i32,
    pub y1 : i32,
    pub y2 : i32
}

impl Rect {
    pub fn new(x:i32, y: i32, w:i32, h:i32) -> Rect {
        Rect{x1:x, y1:y, x2:x+w, y2:y+h}
    }

    // Returns true if this overlaps with other
    pub fn intersect(&self, other:&Rect) -> bool {
        self.x1 <= other.x2 && self.x2 >= other.x1 && self.y1 <= other.y2 && self.y2 >= other.y1
    }

    pub fn center(&self) -> (i32, i32) {
        ((self.x1 + self.x2)/2, (self.y1 + self.y2)/2)
    }
}
}

这里没有什么真正新的东西,但让我们分解一下:

  1. 我们定义了一个名为 Rectstruct。我们添加了 pub 标签以使其 公开 - 它可以在该模块外部使用(通过将其放入新文件中,我们自动创建了一个代码模块;这是 Rust 内置的代码分隔方式)。在 main.rs 中,我们可以添加 pub mod Rect 来说明“我们使用 Rect,并且因为我们前面加了 pub,任何人都可以从我们这里获取 Rect 作为 super::rect::Rect。这样输入不太方便,所以第二行 use rect::Rect 将其简化为 super::Rect
  2. 我们创建了一个新的 构造函数,名为 new。它使用返回简写并根据传入的 xywidthheight 返回一个矩形。
  3. 我们定义了一个 成员 方法,intersect。它有一个 &self,这意味着它可以查看附加到的 Rect - 但不能修改它(它是一个“纯”函数)。它返回一个布尔值:如果两个矩形重叠则返回 true,否则返回 false
  4. 我们定义了 center,也是一个纯成员方法。它简单地返回矩形中心的坐标,作为一个 xy元组,在 val.0val.1 中。

我们还将创建一个新函数来将房间应用到地图上:

#![allow(unused)]
fn main() {
fn apply_room_to_map(room : &Rect, map: &mut [TileType]) {
    for y in room.y1 +1 ..= room.y2 {
        for x in room.x1 + 1 ..= room.x2 {
            map[xy_idx(x, y)] = TileType::Floor;
        }
    }
}
}

注意我们使用的是 for y in room.y1 +1 ..= room.y2 - 这是一个包含范围。我们希望一直到达 y2 的值,而不是 y2-1!否则,这相对简单:使用两个 for 循环来访问房间矩形内的每个瓦片,并将该瓦片设置为 Floor

使用这两段代码,我们可以在任何位置创建一个新的矩形,使用 Rect::new(x, y, width, height)。我们可以将其作为地板添加到地图中,使用 apply_room_to_map(rect, map)。这足以添加几个测试房间。我们的地图函数现在看起来像这样:

#![allow(unused)]
fn main() {
pub fn new_map_rooms_and_corridors() -> Vec<TileType> {
    let mut map = vec![TileType::Wall; 80*50];

    let room1 = Rect::new(20, 15, 10, 15);
    let room2 = Rect::new(35, 15, 10, 15);

    apply_room_to_map(&room1, &mut map);
    apply_room_to_map(&room2, &mut map);

    map
}
}

如果你运行你的项目,你会看到我们现在有两个房间——没有连接在一起。

制作走廊

两个不连通的房间没什么意思,所以我们在它们之间加一条走廊。我们需要一些比较函数,所以我们必须告诉 Rust 导入它们(在 map.rs 的顶部):use std::cmp::{max, min};minmax 如其名:它们返回两个值中的最小值或最大值。你可以使用 if 语句来实现同样的功能,但有些计算机会将其优化为一个简单的(快速)调用;我们让 Rust 来处理这个问题!

然后我们创建两个函数,用于水平和垂直隧道:

#![allow(unused)]
fn main() {
fn apply_horizontal_tunnel(map: &mut [TileType], x1:i32, x2:i32, y:i32) {
    for x in min(x1,x2) ..= max(x1,x2) {
        let idx = xy_idx(x, y);
        if idx > 0 && idx < 80*50 {
            map[idx as usize] = TileType::Floor;
        }
    }
}

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

然后我们在地图生成函数中添加一个调用,apply_horizontal_tunnel(&mut map, 25, 40, 23);,瞧!我们有了两个房间之间的隧道!如果你运行(cargo run)项目,你可以在两个房间之间行走——而不是撞墙。所以我们的之前的代码仍然有效,但现在看起来更像一个 Roguelike 游戏。

制作一个简单的地牢

现在我们可以用它来制作一个随机的地牢。我们将修改我们的函数如下:

#![allow(unused)]
fn main() {
pub fn new_map_rooms_and_corridors() -> Vec<TileType> {
    let mut map = vec![TileType::Wall; 80*50];

    let mut rooms : Vec<Rect> = Vec::new();
    const MAX_ROOMS : i32 = 30;
    const MIN_SIZE : i32 = 6;
    const MAX_SIZE : i32 = 10;

    let mut rng = RandomNumberGenerator::new();

    for _ 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, 80 - w - 1) - 1;
        let y = rng.roll_dice(1, 50 - h - 1) - 1;
        let new_room = Rect::new(x, y, w, h);
        let mut ok = true;
        for other_room in rooms.iter() {
            if new_room.intersect(other_room) { ok = false }
        }
        if ok {
            apply_room_to_map(&new_room, &mut map);        
            rooms.push(new_room);            
        }
    }

    map
}
}

这里有很多变化:

  • 我们为最大房间数量、最小和最大房间尺寸添加了 const 常量。这是我们第一次遇到 const:它只是说“在开始时设置这个值,并且它永远不会改变”。这是在 Rust 中拥有全局变量的唯一简单方法;由于它们永远不会改变,它们通常甚至不存在,并且会被编译到使用它们的函数中。如果它们确实存在,因为它们不能改变,所以当多个线程访问它们时没有问题。通常设置一个命名的常量比使用“魔法数字”更清晰——也就是说,一个硬编码的值,没有真正的线索表明为什么选择这个值。
  • 我们从 RLTK 获取一个 RandomNumberGenerator(这要求我们在 map.rs 顶部的 use 语句中添加内容) * 我们随机构建宽度和高度。
  • 然后我们随机放置房间,使得 xy 大于 0 且小于最大地图尺寸减一。
  • 我们遍历现有房间,如果新房间与已放置的房间重叠,则拒绝它。
  • 如果可以,我们将其应用到房间。
  • 我们将房间保存在一个向量中,尽管我们还没有使用它。

在此阶段运行项目(cargo run)将为您提供一系列随机房间,房间之间没有走廊。

将房间连接在一起

我们现在需要将房间连接起来,用走廊。我们将把这个添加到地图生成器的if ok部分:

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

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

    rooms.push(new_room);
}
}
  1. 这是做什么的?它首先检查 rooms 列表是否为空。如果是,那么没有之前的房间可以加入 - 所以我们忽略它。
  2. 它获取房间的中心,并将其存储为 new_xnew_y
  3. 它获取向量中前一个房间的中心,并将其存储为 prev_xprev_y
  4. 它掷骰子,一半的时间它会先画一个水平的再画一个垂直的隧道 - 另一半时间,则相反。

现在试试 cargo run。它真的开始看起来像一个肉鸽游戏了!

放置玩家

目前,玩家总是从地图中心开始——使用新的生成器,这可能不是一个有效的起点!我们可以简单地将玩家移动到第一个房间的中心,但我们的生成器可能需要知道所有房间的位置——这样我们可以在里面放置物品——而不仅仅是玩家的位置。因此,我们将修改我们的new_map_rooms_and_corridors函数,使其也返回房间列表。所以我们更改方法签名:pub fn new_map_rooms_and_corridors() -> (Vec<Rect>, Vec<TileType>) {,并将返回语句改为(rooms, map)

我们的 main.rs 文件也需要调整,以接受新的格式。我们将 main.rs 中的 main 函数改为:

fn main() -> rltk::BError {
    use rltk::RltkBuilder;
    let context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build()?;
    let mut gs = State {
        ecs: World::new()
    };
    gs.ecs.register::<Position>();
    gs.ecs.register::<Renderable>();
    gs.ecs.register::<Player>();

    let (rooms, map) = new_map_rooms_and_corridors();
    gs.ecs.insert(map);
    let (player_x, player_y) = rooms[0].center();

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

    rltk::main_loop(context, gs)
}

这基本上是相同的,但我们从new_map_rooms_and_corridors接收到了房间列表和地图。然后我们将玩家放置在第一个房间的中心。

总结 - 并支持数字键盘和 Vi 键

现在你有一个看起来像 Rogue 类的地图,将玩家放在第一个房间,并让你使用光标键进行探索。并非每个键盘都有容易访问的光标键(有些笔记本电脑需要有趣的组合键)。许多玩家喜欢使用数字键盘进行操控,但并非每个键盘都有数字键盘——所以我们还支持文本编辑器 vi 的方向键。这使得硬核 UNIX 用户和普通玩家都感到满意。

我们暂时不会担心对角线移动。在 player.rs 中,我们将 player_input 改为如下所示:

#![allow(unused)]
fn main() {
pub fn player_input(gs: &mut State, ctx: &mut Rltk) {
    // Player movement
    match ctx.key {
        None => {} // 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),

            _ => {}
        },
    }
}
}

当你运行你的项目时,你应该会得到类似这样的结果:

截图

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

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


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.