第四章 - 一个更有趣的地图
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
在本章中,我们将制作一个更有趣的地图。它将是基于房间的,看起来有点像早期的许多 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) } } }
这里没有什么真正新的东西,但让我们分解一下:
- 我们定义了一个名为
Rect
的struct
。我们添加了pub
标签以使其 公开 - 它可以在该模块外部使用(通过将其放入新文件中,我们自动创建了一个代码模块;这是 Rust 内置的代码分隔方式)。在main.rs
中,我们可以添加pub mod Rect
来说明“我们使用Rect
,并且因为我们前面加了pub
,任何人都可以从我们这里获取Rect
作为super::rect::Rect
。这样输入不太方便,所以第二行use rect::Rect
将其简化为super::Rect
。 - 我们创建了一个新的 构造函数,名为
new
。它使用返回简写并根据传入的x
、y
、width
和height
返回一个矩形。 - 我们定义了一个 成员 方法,
intersect
。它有一个&self
,这意味着它可以查看附加到的Rect
- 但不能修改它(它是一个“纯”函数)。它返回一个布尔值:如果两个矩形重叠则返回true
,否则返回false
。 - 我们定义了
center
,也是一个纯成员方法。它简单地返回矩形中心的坐标,作为一个x
和y
的 元组,在val.0
和val.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};
。min
和 max
如其名:它们返回两个值中的最小值或最大值。你可以使用 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
语句中添加内容) * 我们随机构建宽度和高度。 - 然后我们随机放置房间,使得
x
和y
大于 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); } }
- 这是做什么的?它首先检查
rooms
列表是否为空。如果是,那么没有之前的房间可以加入 - 所以我们忽略它。 - 它获取房间的中心,并将其存储为
new_x
和new_y
。 - 它获取向量中前一个房间的中心,并将其存储为
prev_x
和prev_y
。 - 它掷骰子,一半的时间它会先画一个水平的再画一个垂直的隧道 - 另一半时间,则相反。
现在试试 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.