BSP 室内设计


关于本教程

本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您能享受本教程,并制作出色的游戏!

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

Hands-On Rust


在上一章中,我们使用二叉空间分割 (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 };
}
}

让我们看看这段代码做了什么:

  1. 我们创建一个新的随机数生成器。
  2. 我们清空 rects 列表,并添加一个覆盖我们打算使用的整个地图的矩形。
  3. 我们在这个矩形上调用一个神奇的函数 add_subrects。稍后会详细介绍。
  4. 我们复制房间列表,以避免借用问题。
  5. 对于每个房间,我们将其添加到房间列表 - 并从地图上雕刻出来。我们还拍摄快照。
  6. 我们将玩家的起始位置设置在第一个房间。

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

让我们看一下这个函数的作用:

  1. 如果 rects 列表不为空,我们从列表中删除最后一个项目。这具有删除我们添加的最后一个矩形的效果 - 因此当我们开始时,我们正在删除覆盖整个地图的矩形。稍后,我们删除一个矩形是因为我们要分割它。这样,我们就不会有重叠。
  2. 我们计算矩形的宽度和高度,以及宽度和高度的一半。
  3. 我们掷骰子。水平或垂直分割的概率为 50%。
  4. 如果我们进行水平分割:
    1. 我们创建 h1 - 一个新的矩形。它覆盖了父矩形的左半部分。
    2. 我们将 h1 添加到 rects 列表。
    3. 如果 half_width 大于 MIN_ROOM_SIZE,我们再次递归调用 add_subrects,并将 h1 作为目标矩形。
    4. 我们创建 h2 - 一个新的矩形,覆盖父矩形的右侧。
    5. 我们将 h2 添加到 rects 列表。
    6. 如果 half_width 大于 MIN_ROOM_SIZE,我们再次递归调用 add_subrects,并将 h2 作为目标矩形。
  5. 如果我们进行垂直分割,则与 (4) 相同 - 但使用顶部和底部矩形。

从概念上讲,这从一个矩形开始:

#################################
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#                               #
#################################

水平分割会产生以下结果:

#################################
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#              #                #
#################################

下一个分割可能是垂直的:

#################################
#              #                #
#              #                #
#              #                #
#              #                #
################                #
#              #                #
#              #                #
#              #                #
#              #                #
#################################

这将重复进行,直到我们有很多小房间为止。

您可以立即 cargo run 运行代码,以查看房间的出现。

Screenshot

添加一些门道

拥有房间固然很好,但是如果没有连接它们的门,那就不会是一个非常有趣的体验!幸运的是,上一章中的完全相同的代码 也适用于此处。

#![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 运行,您将看到类似这样的内容:

Screenshot.

再次恢复随机性

最后,我们回到 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。