关于本教程

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

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

Hands-On Rust


门和角落,那是他们抓住你的地方。 如果我们要让米勒(来自《太空无垠》- 可能是目前我最喜欢的科幻小说系列)的警告成真 - 在游戏中门将是个好主意。 门是地下城探索的必备元素! 我们等待了这么久才实现它们,是为了确保我们有放置它们的合适位置。

门也是实体

我们将从简单的装饰性门开始,这些门根本不任何事情。 这将使我们能够适当地放置它们,然后我们可以实现一些与门相关的功能。 距离我们上次添加实体类型已经有一段时间了; 幸运的是,我们现有的 components 中拥有装饰性门所需的一切。 打开 spawner.rs,并重新熟悉它! 然后我们将添加一个门生成器函数:

#![allow(unused)]
fn main() {
fn door(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('+'),
            fg: RGB::named(rltk::CHOCOLATE),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Door".to_string() })
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
}

所以我们仅用于装饰的门非常简单:它有一个字形(glyph)(在许多 roguelike 游戏中,+ 是传统的),是棕色的,并且具有 NamePosition。 这就是让它们出现在地图上所需的全部! 我们还将修改 spawn_entity 以了解在给定要生成的 Door 时该怎么做:

#![allow(unused)]
fn main() {
match spawn.1.as_ref() {
    "Goblin" => goblin(ecs, x, y),
    "Orc" => orc(ecs, x, y),
    "Health Potion" => health_potion(ecs, x, y),
    "Fireball Scroll" => fireball_scroll(ecs, x, y),
    "Confusion Scroll" => confusion_scroll(ecs, x, y),
    "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
    "Dagger" => dagger(ecs, x, y),
    "Shield" => shield(ecs, x, y),
    "Longsword" => longsword(ecs, x, y),
    "Tower Shield" => tower_shield(ecs, x, y),
    "Rations" => rations(ecs, x, y),
    "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
    "Bear Trap" => bear_trap(ecs, x, y),
    "Door" => door(ecs, x, y),
    _ => {}
}
}

我们不会将门添加到生成表(spawn tables)中; 让它们随机出现在房间里是没有意义的!

放置门

我们将创建一个新的 builder(毕竟我们仍然在地图部分!)它可以放置门。 因此,在 map_builders 中,创建一个新文件:door_placement.rs

#![allow(unused)]
fn main() {
use super::{MetaMapBuilder, BuilderMap };
use rltk::RandomNumberGenerator;

pub struct DoorPlacement {}

impl MetaMapBuilder for DoorPlacement {
    #[allow(dead_code)]
    fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
        self.doors(rng, build_data);
    }
}

impl DoorPlacement {
    #[allow(dead_code)]
    pub fn new() -> Box<DoorPlacement> {
        Box::new(DoorPlacement{ })
    }

    fn doors(&mut self, _rng : &mut RandomNumberGenerator, _build_data : &mut BuilderMap) {
    }
}
}

这是一个元 builder 的空骨架。 让我们首先处理最简单的情况:当我们有走廊数据时,它提供了一个门可能适合的位置的蓝图。 我们将从一个新函数 door_possible 开始:

#![allow(unused)]
fn main() {
fn door_possible(&self, build_data : &mut BuilderMap, idx : usize) -> bool {
    let x = idx % build_data.map.width as usize;
    let y = idx / build_data.map.width as usize;

    // 检查东西方向门的可能性
    if build_data.map.tiles[idx] == TileType::Floor &&
        (x > 1 && build_data.map.tiles[idx-1] == TileType::Floor) &&
        (x < build_data.map.width-2 && build_data.map.tiles[idx+1] == TileType::Floor) &&
        (y > 1 && build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Wall) &&
        (y < build_data.map.height-2 && build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Wall)
    {
        return true;
    }

    // 检查南北方向门的可能性
    if build_data.map.tiles[idx] == TileType::Floor &&
        (x > 1 && build_data.map.tiles[idx-1] == TileType::Wall) &&
        (x < build_data.map.width-2 && build_data.map.tiles[idx+1] == TileType::Wall) &&
        (y > 1 && build_data.map.tiles[idx - build_data.map.width as usize] == TileType::Floor) &&
        (y < build_data.map.height-2 && build_data.map.tiles[idx + build_data.map.width as usize] == TileType::Floor)
    {
        return true;
    }

    false
}
}

实际上只有两种门有意义的位置:东西方向开放且南北方向被阻挡,反之亦然。 我们不希望门出现在开放区域。 因此,此函数检查这些条件,如果门是可能的,则返回 true,否则返回 false。 现在我们扩展 doors 函数以扫描走廊并在其开头放置门:

#![allow(unused)]
fn main() {
fn doors(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
    if let Some(halls_original) = &build_data.corridors {
        let halls = halls_original.clone(); // 为了避免嵌套借用
        for hall in halls.iter() {
            if hall.len() > 2 { // 我们对微小的走廊不感兴趣
                if self.door_possible(build_data, hall[0]) {
                    build_data.spawn_list.push((hall[0], "Door".to_string()));
                }
            }
        }
    }
}
}

我们首先检查是否走廊信息可以使用。 如果有,我们复制一份(为了让借用检查器高兴 - 否则我们将对 halls 进行两次借用)并迭代它。 每个条目都是一个走廊 - 构成该走廊的瓦片(tile)向量。 我们只对长度超过 2 个条目的走廊感兴趣 - 以避免连接门的非常短的走廊。 因此,如果它足够长 - 我们检查在走廊索引 0 处放置门是否合理; 如果合理,我们将其添加到生成列表(spawn list)中。

我们将快速再次修改 random_builder 以创建一个可能生成门的情况:

#![allow(unused)]
fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(SimpleMapBuilder::new());
builder.with(RoomDrawer::new());
builder.with(RoomSorter::new(RoomSort::LEFTMOST));
builder.with(StraightLineCorridors::new());
builder.with(RoomBasedSpawner::new());
builder.with(CorridorSpawner::new());
builder.with(RoomBasedStairs::new());
builder.with(RoomBasedStartingPosition::new());
builder.with(DoorPlacement::new());
builder
}

我们 cargo run 运行项目,瞧 - 门出现了:

Screenshot.

其他设计怎么样?

当然可以逐瓦片扫描其他地图,看看是否有门出现的可能性。 让我们这样做:

#![allow(unused)]
fn main() {
if let Some(halls_original) = &build_data.corridors {
        let halls = halls_original.clone(); // 为了避免嵌套借用
        for hall in halls.iter() {
            if hall.len() > 2 { // 我们对微小的走廊不感兴趣
                if self.door_possible(build_data, hall[0]) {
                    build_data.spawn_list.push((hall[0], "Door".to_string()));
                }
            }
        }
    } else {
        // 没有走廊 - 扫描可能的位置
        let tiles = build_data.map.tiles.clone();
        for (i, tile) in tiles.iter().enumerate() {
            if *tile == TileType::Floor && self.door_possible(build_data, i) {
                build_data.spawn_list.push((i, "Door".to_string()));
            }
        }
    }
}
}

修改你的 random_builder 以使用没有走廊的地图:

#![allow(unused)]
fn main() {
let mut builder = BuilderChain::new(new_depth);
builder.start_with(BspInteriorBuilder::new());
builder.with(DoorPlacement::new());
builder.with(RoomBasedSpawner::new());
builder.with(RoomBasedStairs::new());
builder.with(RoomBasedStartingPosition::new());
builder
}

你可以 cargo run 运行项目并看到门:

Screenshot.

效果相当好!

恢复我们的随机函数

我们将 random_builder 恢复到原来的样子,但有一个更改:我们将添加一个门生成器作为最后一步:

#![allow(unused)]
fn main() {
pub fn random_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator) -> BuilderChain {
    let mut builder = BuilderChain::new(new_depth);
    let type_roll = rng.roll_dice(1, 2);
    match type_roll {
        1 => random_room_builder(rng, &mut builder),
        _ => random_shape_builder(rng, &mut builder)
    }

    if rng.roll_dice(1, 3)==1 {
        builder.with(WaveformCollapseBuilder::new());
    }

    if rng.roll_dice(1, 20)==1 {
        builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FORT));
    }

    builder.with(DoorPlacement::new());
    builder.with(PrefabBuilder::vaults());

    builder
}
}

请注意,我们在添加 vault 之前 添加了它; 这是故意的 - vault 有机会生成并删除任何会干扰它的门。

让门发挥作用

门有几个属性:当关闭时,它们会阻挡移动和视野。 它们可以被打开(可以选择性地需要解锁,但我们现在不打算这样做),此时你可以很好地看穿它们。

让我们从“勾勒出”(suggesting!)一些新的组件开始。 在 spawner.rs 中:

#![allow(unused)]
fn main() {
fn door(ecs: &mut World, x: i32, y: i32) {
    ecs.create_entity()
        .with(Position{ x, y })
        .with(Renderable{
            glyph: rltk::to_cp437('+'),
            fg: RGB::named(rltk::CHOCOLATE),
            bg: RGB::named(rltk::BLACK),
            render_order: 2
        })
        .with(Name{ name : "Door".to_string() })
        .with(BlocksTile{})
        .with(BlocksVisibility{})
        .with(Door{open: false})
        .marked::<SimpleMarker<SerializeMe>>()
        .build();
}
}

这里有两种新的组件类型!

  • BlocksVisibility 将实现其名称所示的功能 - 阻止你(和怪物)看穿它。 将其作为组件而不是特殊情况处理是很好的,因为现在你可以使任何东西阻挡视野。 一个非常大的宝箱,一个巨人,甚至是一堵移动的墙 - 能够阻止看穿它们是有意义的。
  • Door - 表示它是一扇门,并且需要自己的处理方式。

打开 components.rs,我们将创建这些新组件:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct BlocksVisibility {}

#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Door {
    pub open: bool
}
}

与所有组件一样,不要忘记在 mainsaveload_system.rs 中注册它们

扩展视野系统以处理阻挡您视线的实体

由于视野(field of view)由 RLTK 处理,而 RLTK 依赖于 Map trait - 我们需要扩展我们的地图类以处理这个概念。 添加一个新字段:

#![allow(unused)]
fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub depth : i32,
    pub bloodstains : HashSet<usize>,
    pub view_blocked : HashSet<usize>,

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
}

并更新构造函数,以免忘记:

#![allow(unused)]
fn main() {
pub fn new(new_depth : i32) -> Map {
    Map{
        tiles : vec![TileType::Wall; MAPCOUNT],
        width : MAPWIDTH as i32,
        height: MAPHEIGHT as i32,
        revealed_tiles : vec![false; MAPCOUNT],
        visible_tiles : vec![false; MAPCOUNT],
        blocked : vec![false; MAPCOUNT],
        tile_content : vec![Vec::new(); MAPCOUNT],
        depth: new_depth,
        bloodstains: HashSet::new(),
        view_blocked : HashSet::new()
    }
}
}

现在我们将更新 is_opaque 函数(视野(field-of-view)使用),以包含对其的检查:

#![allow(unused)]
fn main() {
fn is_opaque(&self, idx:i32) -> bool {
    let idx_u = idx as usize;
    self.tiles[idx_u] == TileType::Wall || self.view_blocked.contains(&idx_u)
}
}

我们还需要访问 visibility_system.rs 以填充此数据。 我们需要扩展系统的数据以检索更多内容:

#![allow(unused)]
fn main() {
type SystemData = ( WriteExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, Viewshed>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, Player>,
                        WriteStorage<'a, Hidden>,
                        WriteExpect<'a, rltk::RandomNumberGenerator>,
                        WriteExpect<'a, GameLog>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, BlocksVisibility>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, entities, mut viewshed, pos, player,
            mut hidden, mut rng, mut log, names, blocks_visibility) = data;
        ...
}

紧随其后,我们将循环遍历所有阻挡视野的实体,并在 view_blocked HashSet 中设置它们的索引:

#![allow(unused)]
fn main() {
map.view_blocked.clear();
for (block_pos, _block) in (&pos, &blocks_visibility).join() {
    let idx = map.xy_idx(block_pos.x, block_pos.y);
    map.view_blocked.insert(idx);
}
}

如果你现在 cargo run 运行项目,你会看到门现在阻挡了视线:

Screenshot.

处理门

撞到一扇关着的门应该打开它,然后你就可以自由通过(我们可以添加 openclose 命令 - 也许我们稍后会这样做 - 但现在让我们保持简单)。 打开 player.rs,我们将向 try_move_player 添加功能:

#![allow(unused)]
fn main() {
...
let mut doors = ecs.write_storage::<Door>();
let mut blocks_visibility = ecs.write_storage::<BlocksVisibility>();
let mut blocks_movement = ecs.write_storage::<BlocksTile>();
let mut renderables = ecs.write_storage::<Renderable>();

for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() {
    if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; }
    let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);

    for potential_target in map.tile_content[destination_idx].iter() {
        let target = combat_stats.get(*potential_target);
        if let Some(_target) = target {
            wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed");
            return;
        }
        let door = doors.get_mut(*potential_target);
        if let Some(door) = door {
            door.open = true;
            blocks_visibility.remove(*potential_target);
            blocks_movement.remove(*potential_target);
            let glyph = renderables.get_mut(*potential_target).unwrap();
            glyph.glyph = rltk::to_cp437('/');
            viewshed.dirty = true;
        }
    }
    ...
}

让我们逐步了解它:

  1. 我们获得对 DoorBlocksVisibilityBlocksTileRenderable 存储的写入访问权限。
  2. 我们迭代移动瓦片中的潜在目标,像以前一样处理近战。
  3. 我们还检查潜在目标是否是一扇门。 如果是,则:
    1. 将门的 open 变量设置为 true
    2. 删除 BlocksVisibility 条目 - 现在你可以看穿它了(怪物也可以!)。
    3. 删除 BlocksTile 条目 - 现在你可以穿过它了(所有人都可以!)。
    4. 更新字形(glyph)以显示打开的门口。
    5. 我们将视野(viewshed)标记为脏的(dirty),以便现在显示你可以通过门看到的东西。

如果你现在 cargo run 运行项目,你将获得所需的功能:

Screenshot.

门太多了!

在非走廊地图上,在测试门放置时存在一个小问题:到处都是门。 让我们降低门放置的频率。 我们只需添加一点随机性:

#![allow(unused)]
fn main() {
fn doors(&mut self, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
    if let Some(halls_original) = &build_data.corridors {
        let halls = halls_original.clone(); // 为了避免嵌套借用
        for hall in halls.iter() {
            if hall.len() > 2 { // 我们对微小的走廊不感兴趣
                if self.door_possible(build_data, hall[0]) {
                    build_data.spawn_list.push((hall[0], "Door".to_string()));
                }
            }
        }
    } else {
        // 没有走廊 - 扫描可能的位置
        let tiles = build_data.map.tiles.clone();
        for (i, tile) in tiles.iter().enumerate() {
            if *tile == TileType::Floor && self.door_possible(build_data, i) && rng.roll_dice(1,3)==1 {
                build_data.spawn_list.push((i, "Door".to_string()));
            }
        }
    }
}
}

这给出了任何可能的门放置产生门的 1/3 的机会。 从玩游戏的角度来看,这感觉差不多是对的。 它可能不适合你 - 所以你可以更改它! 你甚至可能想把它做成一个参数。

门在其他实体之上

有时,门会生成在另一个实体之上。 这很少见,但可能会发生。 让我们防止这个问题发生。 我们可以通过快速扫描 door_possible 中的生成列表(spawn list)来解决这个问题:

#![allow(unused)]
fn main() {
fn door_possible(&self, build_data : &mut BuilderMap, idx : usize) -> bool {
    let mut blocked = false;
    for spawn in build_data.spawn_list.iter() {
        if spawn.0 == idx { blocked = true; }
    }
    if blocked { return false; }
    ...
}

如果速度成为一个问题,这将很容易加速(创建一个已占用瓦片的快速 HashSet,并查询它而不是整个列表) - 但我们实际上没有任何性能问题,并且地图构建在主循环之外运行(所以它是每个级别一次,而不是每帧) - 所以你很可能不需要它。

附录:修复 WFC

在我们的 random_builder 中,我们犯了一个错误! 波函数坍缩(Wave Function Collapse)改变了地图的性质,应该调整生成点(spawn)、入口点和出口点。 这是正确的代码:

#![allow(unused)]
fn main() {
if rng.roll_dice(1, 3)==1 {
        builder.with(WaveformCollapseBuilder::new());

        // 现在将起点设置为随机起始区域
        let (start_x, start_y) = random_start_position(rng);
        builder.with(AreaStartingPosition::new(start_x, start_y));

        // 设置出口并生成怪物
        builder.with(VoronoiSpawning::new());
        builder.with(DistantExit::new());
    }
}

总结

门的教程就到这里了! 未来肯定还有改进的空间 - 但该功能已经可以工作了。 你可以靠近一扇门,它会阻挡移动和视线(因此房间的居住者不会打扰你)。 打开它,你就可以看穿 - 居住者也可以看到你。 现在它打开了,你可以通过它。 这非常接近门的定义了!

...

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

在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)

版权所有 (C) 2019, Herbert Wolverson。