第三章 - 地图


关于本教程

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

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

实践中的 Rust


本教程的其余部分将致力于制作一个 Roguelike 游戏。Rogue 于 1980 年出现,是一款文本模式的地牢探索游戏。它催生了一个完整的“roguelike”类型:程序生成的地图、在多个层次上猎取目标以及“永久死亡”(死亡后重新开始)。这个定义是许多在线争论的源头;我宁愿避免这种情况!

没有地图可探索的 Roguelike 有点无意义,所以在本章中,我们将组合一个基本地图,绘制它,并让你的玩家四处走动一下。我们从第 2 章的代码开始,但去掉了红色的笑脸(及其向左的倾向)。

定义地图瓦片

我们将从两种瓷砖类型开始:墙壁和地板。我们可以用一个enum来表示(要了解更多关于枚举的信息,《Rust 编程语言》 有一大节关于它们的内容):

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

请注意,我们已经包含了一些派生特性(这次是内置于 Rust 本身的派生宏):CopyCloneClone 为类型添加了一个 .clone() 方法,允许以编程方式进行复制。Copy 将默认行为从赋值时移动对象改为复制对象——因此 tile1 = tile2 会使两个值都有效,而不是处于“移动后”状态。

PartialEq 允许我们使用 == 来检查两个瓦片类型是否匹配。如果我们没有派生这些特性,if tile_type == TileType::Wall 将无法编译!

构建一个简单的地图

现在我们将创建一个返回瓦片(tile)向量的函数,表示一个简单的地图。我们将使用一个大小为整个地图的向量,这意味着我们需要一种方法来确定给定 x/y 位置的数组索引。因此,首先,我们创建一个新函数xy_idx

#![allow(unused)]
fn main() {
pub fn xy_idx(x: i32, y: i32) -> usize {
    (y as usize * 80) + x as usize
}
}

这很简单:它将 y 位置乘以地图宽度(80),并加上 x。这保证了每个位置一个瓦片,并且高效地在内存中映射以供从左到右阅读。

我们在这里使用了一个 Rust 函数的简写形式。注意,该函数返回一个usize(相当于 C/C++中的size_t——无论平台使用的基本大小类型是什么)——并且函数体末尾缺少一个;?任何以缺少分号的语句结尾的函数都将该行视为return语句。所以它与输入return (y as usize * 80) + x as usize相同。这来自于 Rust 作者的另一个最喜欢的语言ML——它使用了相同的简写形式。这种风格被认为是“Rustacean”(规范的 Rust;我总是想象一个带有可爱小爪子和壳的 Rust 怪物),所以我们为教程采用了这种风格。

然后我们编写一个构造函数来创建地图:

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

    // Make the boundaries walls
    // 将边界设置为墙壁
    for x in 0..80 {
        map[xy_idx(x, 0)] = TileType::Wall;
        map[xy_idx(x, 49)] = TileType::Wall;
    }
    for y in 0..50 {
        map[xy_idx(0, y)] = TileType::Wall;
        map[xy_idx(79, y)] = TileType::Wall;
    }

    // Now we'll randomly splat a bunch of walls. It won't be pretty, but it's a decent illustration.
    // First, obtain the thread-local RNG:
    // 现在我们将随机散布一堆墙壁。它不会很漂亮,但这是一个不错的演示。
    // 首先,获取线程本地的 RNG:
    let mut rng = rltk::RandomNumberGenerator::new();

    for _i in 0..400 {
        let x = rng.roll_dice(1, 79);
        let y = rng.roll_dice(1, 49);
        let idx = xy_idx(x, y);
        if idx != xy_idx(40, 25) {
            map[idx] = TileType::Wall;
        }
    }

    map
}
}

这里有一些我们以前没有遇到过的语法,所以我们来分解一下:

  1. fn new_map() -> Vec<TileType> 指定了一个名为 new_map 的函数。它不接受任何参数,因此可以从任何地方调用。
  2. 返回一个 VecVec 是 Rust 中的向量(如果你熟悉 C++,它几乎与 C++ 的 std::vector 完全相同)。向量类似于数组(参见 这个 Rust by Example 章节),可以让你将一堆数据放入列表中并访问每个元素。与数组不同,Vec 没有大小限制——并且大小可以在程序运行时改变。因此,你可以 push(添加)新项目,并随着进度 remove(移除)它们。Rust by Example 有一个关于向量的很棒的章节;了解它们是个好主意——它们被广泛使用
  3. let mut map = vec![TileType::Floor; 80*50]; 是一个看起来令人困惑的语句!让我们分解一下:
    1. let mut map 表示“创建一个新变量”(let),“让我可以更改它”(mut)并将其命名为“map”。
    2. vec! 是一个,另一个内置于 Rust 标准库中的宏。 感叹号是 Rust 表示“这是一个过程宏”的方式(与之前看到的派生宏相反)。过程宏像函数一样运行——它们定义了一个过程,只是大大减少了你的打字量。
    3. vec! 宏在其参数中使用方括号。
    4. 第一个参数是新向量中每个元素的。在这种情况下,我们将创建的每个条目设置为 Floor(来自 TileType 枚举)。
    5. 第二个参数是我们应该创建多少个瓦片。它们都将设置为我们上面设置的值。在这种情况下,我们的地图是 80x50 个瓦片(4,000 个瓦片——但我们会让编译器为我们做数学运算!)。所以我们需要创建 4,000 个瓦片。
    6. 你可以将 vec! 调用替换为 for _i in 0..4000 { map.push(TileType::Floor); }。事实上,宏基本上就是这样为你做的——但让宏来做肯定打字量更少!
  4. for x in 0..80 { 是一个 for 循环见这里),就像我们在前面的例子中使用的那样。在这种情况下,我们正在迭代 x 从 0 到 79。 ```
  5. map[xy_idx(x, 0)] = TileType::Wall; 首先调用我们上面定义的 xy_idx 函数来获取 x, 0 的向量索引。然后它索引向量,告诉它将该位置的向量条目设置为墙。我们再次对 x,49 进行同样的操作。
  6. 我们做同样的事情,但循环 y 从 0..49 - 并在我们的地图上设置垂直墙。
  7. let mut rng = rltk::RandomNumberGenerator::new(); 调用 RLTK 中的 RandomNumberGenerator 类型的 new 函数,并将其分配给一个名为 rng 的变量。我们要求 RLTK 给我们一个新的骰子滚筒。
  8. for _i in 0..400 { 与其他 for 循环相同,但注意 _i 之前。我们实际上并不关心 i 的值 - 我们只是希望循环运行 400 次。如果你有一个未使用的变量,Rust 会给你一个警告;添加下划线前缀告诉 Rust 没关系,我们故意这样做的。
  9. let x = rng.roll_dice(1, 79); 调用我们在第 7 步中获取的 rng,并要求它提供一个从 1 到 79 的随机数。RLTK 使用独占范围,因为它试图镜像旧的 D&D 骰子惯例,如 1d20 或类似。 在这种情况下,我们应该庆幸计算机不在乎发明一个 79 面的骰子的几何难度!我们还得到了一个介于 1 到 49 之间的y值。我们已经掷了想象的骰子,并在地图上找到了一个随机位置。
  10. 我们将变量idx(“index”的缩写)设置为我们掷出的坐标的向量索引(通过我们之前定义的xy_idx)。
  11. if idx != xy_idx(40, 25) {检查idx是否不是正中间(我们将从那里开始,所以我们不想在墙内开始!)。
  12. 如果不是中间,我们将随机掷出的位置设置为墙。

很简单:它在地图的外边缘放置墙壁,然后在不是玩家起点的任何地方添加 400 个随机墙壁。

让地图对世界可见

Specs 包含“资源”的概念 —— 整个 ECS 可以使用的共享数据。因此,在我们的main函数中,我们将一个随机生成的地图添加到世界中:

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

地图现在可以从 ECS 可见的任何地方获取!现在在你的代码中,你可以使用相当繁琐的let map = self.ecs.get_mut::<Vec<TileType>>();来访问地图;它以更简单的方式提供给系统。实际上有几种方法可以获取地图的值,包括ecs.getecs.fetchget_mut获取地图的“可变”(你可以更改它)引用——包装在一个可选的(以防地图不存在)。fetch跳过Option类型,直接给你一个地图。你可以在 Specs 书中了解更多信息。

绘制地图

现在我们有了一个可用的地图,我们应该把它显示在屏幕上!新的draw_map函数的完整代码如下:

#![allow(unused)]
fn main() {
fn draw_map(map: &[TileType], ctx : &mut Rltk) {
    let mut y = 0;
    let mut x = 0;
    for tile in map.iter() {
        // Render a tile depending upon the tile type
        // 根据瓦片类型渲染瓦片
        match tile {
            TileType::Floor => {
                ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
            }
            TileType::Wall => {
                ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
            }
        }

        // Move the coordinates
        // 移动坐标
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}
}

大部分是直接的,并且使用了我们已经涉及的概念。在声明中,我们将地图作为&[TileType]而不是&Vec<TileType>传递;这允许我们选择传递地图的“切片”(部分)。我们暂时不会这样做,但这可能在以后有用。这也是一种更“地道”(即:惯用的 Rust)的做法,而且代码检查工具(clippy)会对此发出警告。如果你对切片感兴趣,《Rust 编程语言》可以教你

否则,它利用我们存储地图的方式——行在一起,一个接一个。因此,它遍历整个地图结构,每块瓷砖的x位置加 1。如果达到地图宽度,它将x归零并将y加 1。这样我们不会重复读取整个数组——这可能会变慢。实际渲染非常简单:我们匹配瓷砖类型,并为墙壁/地板绘制一个点或一个井号。 我们还应该调用该函数!在我们的 tick 函数中,添加:

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

fetch 调用是新的(我们在上面提到过)。fetch 要求你保证你知道你请求的资源确实存在——如果不存在,它会崩溃。它并不完全返回一个引用——它是一个 shred 类型,大多数情况下表现得像一个引用,但偶尔需要一点强制才能成为一个引用。我们会在需要的时候担心这个问题,但请你自己注意!

使墙壁变实

所以现在如果你运行程序(cargo run),你会看到一个绿色和灰色的地图,上面有一个黄色的@可以四处移动。不幸的是,你会很快发现玩家可以穿过墙壁!幸运的是,这很容易纠正。 为了实现这一点,我们修改了 try_move_player 以读取地图并检查目的地是否开放:

#![allow(unused)]
fn main() {
fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();
    let map = ecs.fetch::<Vec<TileType>>();

    for (_player, pos) in (&mut players, &mut positions).join() {
        let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y);
        if map[destination_idx] != TileType::Wall {
            pos.x = min(79 , max(0, pos.x + delta_x));
            pos.y = min(49, max(0, pos.y + delta_y));
        }
    }
}
}

let map = ... 是新的部分,它与主循环使用fetch的方式相同(这是将其存储在 ECS 中的优势 - 你可以在任何地方访问它,而不需要试图强制 Rust 允许你使用全局变量!)。我们用let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y); 计算玩家目的地的单元格索引 - 如果不是墙,我们正常移动。

现在运行程序(cargo run),你会在地图上有一个玩家,并且可以移动,被墙壁正确阻挡。

截图

完整的程序现在看起来像这样:

use rltk::{GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use std::cmp::{max, min};
use specs_derive::*;



#[derive(Component)]
struct Position {
    x: i32,
    y: i32,
}

#[derive(Component)]
struct Renderable {
    glyph: rltk::FontCharType,
    fg: RGB,
    bg: RGB,
}
 
#[derive(Component, Debug)]
struct Player {}

#[derive(PartialEq, Copy, Clone)]
enum TileType {
    Wall, Floor
}

struct State {
    ecs: World
}

pub fn xy_idx(x: i32, y: i32) -> usize {
    (y as usize * 80) + x as usize
}

fn new_map() -> Vec<TileType> {
    let mut map = vec![TileType::Floor; 80*50];

    // Make the boundaries walls
    // 将边界设置为墙壁
    for x in 0..80 {
        map[xy_idx(x, 0)] = TileType::Wall;
        map[xy_idx(x, 49)] = TileType::Wall;
    }
    for y in 0..50 {
        map[xy_idx(0, y)] = TileType::Wall;
        map[xy_idx(79, y)] = TileType::Wall;
    }

    // Now we'll randomly splat a bunch of walls. It won't be pretty, but it's a decent illustration.
    // First, obtain the thread-local RNG:
    // 现在我们将随机散布一堆墙壁。它不会很漂亮,但这是一个不错的演示。
    // 首先,获取线程本地的 RNG:
    let mut rng = rltk::RandomNumberGenerator::new();

    for _i in 0..400 {
        let x = rng.roll_dice(1, 79);
        let y = rng.roll_dice(1, 49);
        let idx = xy_idx(x, y);
        if idx != xy_idx(40, 25) {
            map[idx] = TileType::Wall;
        }
    }

    map
}

fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();
    let map = ecs.fetch::<Vec<TileType>>();

    for (_player, pos) in (&mut players, &mut positions).join() {
        let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y);
        if map[destination_idx] != TileType::Wall {
            pos.x = min(79 , max(0, pos.x + delta_x));
            pos.y = min(49, max(0, pos.y + delta_y));
        }
    }
}

fn player_input(gs: &mut State, ctx: &mut Rltk) {
    // Player movement
    // 玩家移动
    match ctx.key {
        None => {} // Nothing happened
                    // 没有事件发生
        Some(key) => match key {
            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
            _ => {}
        },
    }
}

fn draw_map(map: &[TileType], ctx : &mut Rltk) {
    let mut y = 0;
    let mut x = 0;
    for tile in map.iter() {
        // Render a tile depending upon the tile type
        // 根据瓦片类型渲染瓦片
        match tile {
            TileType::Floor => {
                ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
            }
            TileType::Wall => {
                ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
            }
        }

        // Move the coordinates
        // 移动坐标
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}

impl GameState for State {
    fn tick(&mut self, ctx : &mut Rltk) {
        ctx.cls();

        player_input(self, ctx);
        self.run_systems();

        let map = self.ecs.fetch::<Vec<TileType>>();
        draw_map(&map, ctx);

        let positions = self.ecs.read_storage::<Position>();
        let renderables = self.ecs.read_storage::<Renderable>();

        for (pos, render) in (&positions, &renderables).join() {
            ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
        }
    }
}

impl State {
    fn run_systems(&mut self) {
        self.ecs.maintain();
    }
}

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

    gs.ecs.insert(new_map());

    gs.ecs
        .create_entity()
        .with(Position { x: 40, y: 25 })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
        })
        .with(Player{})
        .build();

    rltk::main_loop(context, gs)
}

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

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


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.