BSP 室内设计
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您能享受本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
在上一章中,我们使用二叉空间分割 (BSP) 构建了一个带有房间的地牢。BSP 非常灵活,可以帮助您解决许多问题;在本例中,我们将修改 BSP 来设计一个室内地牢 - 完全在一个矩形结构内部(例如,一座城堡),除了内部墙壁外,没有浪费任何空间。
本章的代码是从 One Knight in the Dungeon 的监狱关卡转换而来的。
脚手架
我们将从创建一个新文件 map_builders/bsp_interior.rs
开始,并放入我们在上一章中使用的相同初始样板代码:
#![allow(unused)] fn main() { use super::{MapBuilder, Map, Rect, apply_room_to_map, TileType, Position, spawner, SHOW_MAPGEN_VISUALIZER}; use rltk::RandomNumberGenerator; use specs::prelude::*; pub struct BspInteriorBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect>, history: Vec<Map>, rects: Vec<Rect> } impl MapBuilder for BspInteriorBuilder { fn get_map(&self) -> Map { self.map.clone() } fn get_starting_position(&self) -> Position { self.starting_position.clone() } fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } fn build_map(&mut self) { // 我们应该在这里做些什么 } fn spawn_entities(&mut self, ecs : &mut World) { for room in self.rooms.iter().skip(1) { spawner::spawn_room(ecs, room, self.depth); } } fn take_snapshot(&mut self) { if SHOW_MAPGEN_VISUALIZER { let mut snapshot = self.map.clone(); for v in snapshot.revealed_tiles.iter_mut() { *v = true; } self.history.push(snapshot); } } } impl BspInteriorBuilder { pub fn new(new_depth : i32) -> BspInteriorBuilder { BspInteriorBuilder{ map : Map::new(new_depth), starting_position : Position{ x: 0, y : 0 }, depth : new_depth, rooms: Vec::new(), history: Vec::new(), rects: Vec::new() } } } }
我们还将更改 map_builders/mod.rs
中的随机生成器函数,再次“欺骗”用户,始终“随机”选择新的算法:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { /*let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 2); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) }*/ Box::new(BspInteriorBuilder::new(new_depth)) } }
细分为房间
由于舍入问题,我们无法实现完美的细分,但我们可以非常接近。当然,对于游戏来说已经足够好了!我们组合了一个 build
函数,它与上一章中的函数非常相似:
#![allow(unused)] fn main() { fn build(&mut self) { let mut rng = RandomNumberGenerator::new(); self.rects.clear(); self.rects.push( Rect::new(1, 1, self.map.width-2, self.map.height-2) ); // 从一个地图大小的矩形开始 let first_room = self.rects[0]; self.add_subrects(first_room, &mut rng); // 分割第一个房间 let rooms = self.rects.clone(); for r in rooms.iter() { let room = *r; //room.x2 -= 1; //room.y2 -= 1; self.rooms.push(room); for y in room.y1 .. room.y2 { for x in room.x1 .. room.x2 { let idx = self.map.xy_idx(x, y); if idx > 0 && idx < ((self.map.width * self.map.height)-1) as usize { self.map.tiles[idx] = TileType::Floor; } } } self.take_snapshot(); } let start = self.rooms[0].center(); self.starting_position = Position{ x: start.0, y: start.1 }; } }
让我们看看这段代码做了什么:
- 我们创建一个新的随机数生成器。
- 我们清空
rects
列表,并添加一个覆盖我们打算使用的整个地图的矩形。 - 我们在这个矩形上调用一个神奇的函数
add_subrects
。稍后会详细介绍。 - 我们复制房间列表,以避免借用问题。
- 对于每个房间,我们将其添加到房间列表 - 并从地图上雕刻出来。我们还拍摄快照。
- 我们将玩家的起始位置设置在第一个房间。
add_subrects
函数在这种情况下完成了所有繁重的工作:
#![allow(unused)] fn main() { fn add_subrects(&mut self, rect : Rect, rng : &mut RandomNumberGenerator) { // 从列表中移除最后一个矩形 if !self.rects.is_empty() { self.rects.remove(self.rects.len() - 1); } // 计算边界 let width = rect.x2 - rect.x1; let height = rect.y2 - rect.y1; let half_width = width / 2; let half_height = height / 2; let split = rng.roll_dice(1, 4); if split <= 2 { // 水平分割 let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height ); self.rects.push( h1 ); if half_width > MIN_ROOM_SIZE { self.add_subrects(h1, rng); } let h2 = Rect::new( rect.x1 + half_width, rect.y1, half_width, height ); self.rects.push( h2 ); if half_width > MIN_ROOM_SIZE { self.add_subrects(h2, rng); } } else { // 垂直分割 let v1 = Rect::new( rect.x1, rect.y1, width, half_height-1 ); self.rects.push(v1); if half_height > MIN_ROOM_SIZE { self.add_subrects(v1, rng); } let v2 = Rect::new( rect.x1, rect.y1 + half_height, width, half_height ); self.rects.push(v2); if half_height > MIN_ROOM_SIZE { self.add_subrects(v2, rng); } } } }
让我们看一下这个函数的作用:
- 如果
rects
列表不为空,我们从列表中删除最后一个项目。这具有删除我们添加的最后一个矩形的效果 - 因此当我们开始时,我们正在删除覆盖整个地图的矩形。稍后,我们删除一个矩形是因为我们要分割它。这样,我们就不会有重叠。 - 我们计算矩形的宽度和高度,以及宽度和高度的一半。
- 我们掷骰子。水平或垂直分割的概率为 50%。
- 如果我们进行水平分割:
- 我们创建
h1
- 一个新的矩形。它覆盖了父矩形的左半部分。 - 我们将
h1
添加到rects
列表。 - 如果
half_width
大于MIN_ROOM_SIZE
,我们再次递归调用add_subrects
,并将h1
作为目标矩形。 - 我们创建
h2
- 一个新的矩形,覆盖父矩形的右侧。 - 我们将
h2
添加到rects
列表。 - 如果
half_width
大于MIN_ROOM_SIZE
,我们再次递归调用add_subrects
,并将h2
作为目标矩形。
- 我们创建
- 如果我们进行垂直分割,则与 (4) 相同 - 但使用顶部和底部矩形。
从概念上讲,这从一个矩形开始:
#################################
# #
# #
# #
# #
# #
# #
# #
# #
# #
#################################
水平分割会产生以下结果:
#################################
# # #
# # #
# # #
# # #
# # #
# # #
# # #
# # #
# # #
#################################
下一个分割可能是垂直的:
#################################
# # #
# # #
# # #
# # #
################ #
# # #
# # #
# # #
# # #
#################################
这将重复进行,直到我们有很多小房间为止。
您可以立即 cargo run
运行代码,以查看房间的出现。
添加一些门道
拥有房间固然很好,但是如果没有连接它们的门,那就不会是一个非常有趣的体验!幸运的是,上一章中的完全相同的代码 也适用于此处。
#![allow(unused)] fn main() { // 现在我们需要走廊 for i in 0..self.rooms.len()-1 { let room = self.rooms[i]; let next_room = self.rooms[i+1]; let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1); let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1); let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1); let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1); self.draw_corridor(start_x, start_y, end_x, end_y); self.take_snapshot(); } }
这反过来又调用了未更改的 draw_corridor
函数:
#![allow(unused)] fn main() { fn draw_corridor(&mut self, x1:i32, y1:i32, x2:i32, y2:i32) { let mut x = x1; let mut y = y1; while x != x2 || y != y2 { if x < x2 { x += 1; } else if x > x2 { x -= 1; } else if y < y2 { y += 1; } else if y > y2 { y -= 1; } let idx = self.map.xy_idx(x, y); self.map.tiles[idx] = TileType::Floor; } } }
别忘了楼梯(我差点又忘了!)
最后,我们需要完成并创建出口:
#![allow(unused)] fn main() { // 别忘了楼梯 let stairs = self.rooms[self.rooms.len()-1].center(); let stairs_idx = self.map.xy_idx(stairs.0, stairs.1); self.map.tiles[stairs_idx] = TileType::DownStairs; }
我们将出口放置在最后一个房间中,以确保可怜的玩家有路可走。
如果您现在 cargo run
运行,您将看到类似这样的内容:
.
再次恢复随机性
最后,我们回到 map_builders/mod.rs
并编辑我们的 random_builder
,以便再次为每个关卡提供一个随机地牢:
#![allow(unused)] fn main() { pub fn random_builder(new_depth: i32) -> Box<dyn MapBuilder> { let mut rng = rltk::RandomNumberGenerator::new(); let builder = rng.roll_dice(1, 3); match builder { 1 => Box::new(BspDungeonBuilder::new(new_depth)), 2 => Box::new(BspInteriorBuilder::new(new_depth)), _ => Box::new(SimpleMapBuilder::new(new_depth)) } } }
总结
这种类型的地牢可以代表室内,可能是宇宙飞船、城堡甚至房屋的内部。您可以根据自己的需要调整尺寸、门的位置和分割的偏向 - 但您将获得一张地图,该地图使游戏可以使用大部分可用空间。可能值得节约使用这些关卡(或将它们融入到其他关卡中) - 即使它们是随机的,它们也可能缺乏多样性。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。