关于本教程

本教程是免费且开源的,所有代码均使用 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。