第三章 - 地图
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
本教程的其余部分将致力于制作一个 Roguelike 游戏。Rogue 于 1980 年出现,是一款文本模式的地牢探索游戏。它催生了一个完整的“roguelike”类型:程序生成的地图、在多个层次上猎取目标以及“永久死亡”(死亡后重新开始)。这个定义是许多在线争论的源头;我宁愿避免这种情况!
没有地图可探索的 Roguelike 有点无意义,所以在本章中,我们将组合一个基本地图,绘制它,并让你的玩家四处走动一下。我们从第 2 章的代码开始,但去掉了红色的笑脸(及其向左的倾向)。
定义地图瓦片
我们将从两种瓷砖类型开始:墙壁和地板。我们可以用一个enum
来表示(要了解更多关于枚举的信息,《Rust 编程语言》 有一大节关于它们的内容):
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] enum TileType { Wall, Floor } }
请注意,我们已经包含了一些派生特性(这次是内置于 Rust 本身的派生宏):Copy
和 Clone
。Clone
为类型添加了一个 .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 } }
这里有一些我们以前没有遇到过的语法,所以我们来分解一下:
fn new_map() -> Vec<TileType>
指定了一个名为new_map
的函数。它不接受任何参数,因此可以从任何地方调用。- 它返回一个
Vec
。Vec
是 Rust 中的向量(如果你熟悉 C++,它几乎与 C++ 的std::vector
完全相同)。向量类似于数组(参见 这个 Rust by Example 章节),可以让你将一堆数据放入列表中并访问每个元素。与数组不同,Vec
没有大小限制——并且大小可以在程序运行时改变。因此,你可以push
(添加)新项目,并随着进度remove
(移除)它们。Rust by Example 有一个关于向量的很棒的章节;了解它们是个好主意——它们被广泛使用。 let mut map = vec![TileType::Floor; 80*50];
是一个看起来令人困惑的语句!让我们分解一下:let mut map
表示“创建一个新变量”(let
),“让我可以更改它”(mut
)并将其命名为“map”。vec!
是一个宏,另一个内置于 Rust 标准库中的宏。 感叹号是 Rust 表示“这是一个过程宏”的方式(与之前看到的派生宏相反)。过程宏像函数一样运行——它们定义了一个过程,只是大大减少了你的打字量。vec!
宏在其参数中使用方括号。- 第一个参数是新向量中每个元素的值。在这种情况下,我们将创建的每个条目设置为
Floor
(来自TileType
枚举)。 - 第二个参数是我们应该创建多少个瓦片。它们都将设置为我们上面设置的值。在这种情况下,我们的地图是 80x50 个瓦片(4,000 个瓦片——但我们会让编译器为我们做数学运算!)。所以我们需要创建 4,000 个瓦片。
- 你可以将
vec!
调用替换为for _i in 0..4000 { map.push(TileType::Floor); }
。事实上,宏基本上就是这样为你做的——但让宏来做肯定打字量更少!
for x in 0..80 {
是一个for 循环
(见这里),就像我们在前面的例子中使用的那样。在这种情况下,我们正在迭代x
从 0 到 79。 ```map[xy_idx(x, 0)] = TileType::Wall;
首先调用我们上面定义的xy_idx
函数来获取x, 0
的向量索引。然后它索引向量,告诉它将该位置的向量条目设置为墙。我们再次对x,49
进行同样的操作。- 我们做同样的事情,但循环
y
从 0..49 - 并在我们的地图上设置垂直墙。 let mut rng = rltk::RandomNumberGenerator::new();
调用RLTK
中的RandomNumberGenerator
类型的new
函数,并将其分配给一个名为rng
的变量。我们要求 RLTK 给我们一个新的骰子滚筒。for _i in 0..400 {
与其他for
循环相同,但注意_
在i
之前。我们实际上并不关心i
的值 - 我们只是希望循环运行 400 次。如果你有一个未使用的变量,Rust 会给你一个警告;添加下划线前缀告诉 Rust 没关系,我们故意这样做的。let x = rng.roll_dice(1, 79);
调用我们在第 7 步中获取的rng
,并要求它提供一个从 1 到 79 的随机数。RLTK 不使用独占范围,因为它试图镜像旧的 D&D 骰子惯例,如1d20
或类似。 在这种情况下,我们应该庆幸计算机不在乎发明一个 79 面的骰子的几何难度!我们还得到了一个介于 1 到 49 之间的y
值。我们已经掷了想象的骰子,并在地图上找到了一个随机位置。- 我们将变量
idx
(“index”的缩写)设置为我们掷出的坐标的向量索引(通过我们之前定义的xy_idx
)。 if idx != xy_idx(40, 25) {
检查idx
是否不是正中间(我们将从那里开始,所以我们不想在墙内开始!)。- 如果不是中间,我们将随机掷出的位置设置为墙。
很简单:它在地图的外边缘放置墙壁,然后在不是玩家起点的任何地方添加 400 个随机墙壁。
让地图对世界可见
Specs 包含“资源”的概念 —— 整个 ECS 可以使用的共享数据。因此,在我们的main
函数中,我们将一个随机生成的地图添加到世界中:
#![allow(unused)] fn main() { gs.ecs.insert(new_map()); }
地图现在可以从 ECS 可见的任何地方获取!现在在你的代码中,你可以使用相当繁琐的let map = self.ecs.get_mut::<Vec<TileType>>();
来访问地图;它以更简单的方式提供给系统。实际上有几种方法可以获取地图的值,包括ecs.get
、ecs.fetch
。get_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.