深入地下城
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
我们现在拥有了地下城探索游戏的所有基础要素,但只有一个关卡是一个很大的限制! 本章将介绍深度,每个更深的层级都会生成一个新的地下城。 我们将跟踪玩家的深度,并鼓励更深入的探索。 玩家可能会遇到什么问题呢?
指示和存储深度
我们将首先将当前深度添加到地图中。 在 map.rs
中,我们调整 Map
结构以包含深度的整数:
#![allow(unused)] fn main() { #[derive(Default, Serialize, Deserialize, Clone)] pub struct Map { pub tiles : Vec<TileType>, pub rooms : Vec<Rect>, pub width : i32, pub height : i32, pub revealed_tiles : Vec<bool>, pub visible_tiles : Vec<bool>, pub blocked : Vec<bool>, pub depth : i32, #[serde(skip_serializing)] #[serde(skip_deserializing)] pub tile_content : Vec<Vec<Entity>> } }
i32
是一种基本类型,由序列化库 Serde
自动处理。 因此,在此处添加它会自动将其添加到我们的游戏保存/加载机制中。 我们的地图创建代码还需要指示我们位于地图的第 1 层。 我们希望能够将地图生成器用于其他层级,因此我们还添加一个参数。 更新后的函数如下所示:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map { let mut map = Map{ tiles : vec![TileType::Wall; MAPCOUNT], rooms : Vec::new(), 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 }; ... }
我们从 main.rs
中的设置代码调用此函数,因此我们也需要修改对地下城构建器的调用:
#![allow(unused)] fn main() { let map : Map = Map::new_map_rooms_and_corridors(1); }
就这样! 我们的地图现在知道了深度。 你需要删除你拥有的任何 savegame.json
文件,因为我们已经更改了格式 - 加载将会失败。
向玩家显示地图深度
我们将修改玩家的抬头显示器 (heads-up-display) 以指示当前的地图深度。 在 gui.rs
中,在 draw_ui
函数内部,我们添加以下内容:
#![allow(unused)] fn main() { let map = ecs.fetch::<Map>(); let depth = format!("Depth: {}", map.depth); ctx.print_color(2, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &depth); }
如果你现在 cargo run
项目,你将看到我们正在向你显示当前的深度:
添加下楼楼梯
在 map.rs
中,我们有一个枚举 - TileType
- 列出了可用的地块类型。 我们想要添加一个新的:下楼楼梯。 像这样修改枚举:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs } }
我们还希望能够渲染楼梯。 map.rs
包含 draw_map
,添加地块类型是一项相对简单的任务:
#![allow(unused)] fn main() { match tile { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::Wall => { glyph = rltk::to_cp437('#'); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } } }
最后,我们应该放置下楼楼梯。 我们将上楼楼梯放置在地图生成的第一个房间的中心 - 因此我们将楼梯放置在最后一个房间的中心! 回到 map.rs
中的 new_map_rooms_and_corridors
,我们像这样修改它:
#![allow(unused)] fn main() { pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map { let mut map = Map{ tiles : vec![TileType::Wall; MAPCOUNT], rooms : Vec::new(), 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 }; const MAX_ROOMS : i32 = 30; const MIN_SIZE : i32 = 6; const MAX_SIZE : i32 = 10; let mut rng = RandomNumberGenerator::new(); for i 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, map.width - w - 1) - 1; let y = rng.roll_dice(1, map.height - h - 1) - 1; let new_room = Rect::new(x, y, w, h); let mut ok = true; for other_room in map.rooms.iter() { if new_room.intersect(other_room) { ok = false } } if ok { map.apply_room_to_map(&new_room); if !map.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center(); if rng.range(0,2) == 1 { map.apply_horizontal_tunnel(prev_x, new_x, prev_y); map.apply_vertical_tunnel(prev_y, new_y, new_x); } else { map.apply_vertical_tunnel(prev_y, new_y, prev_x); map.apply_horizontal_tunnel(prev_x, new_x, new_y); } } map.rooms.push(new_room); } } let stairs_position = map.rooms[map.rooms.len()-1].center(); let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1); map.tiles[stairs_idx] = TileType::DownStairs; map } }
如果你现在 cargo run
项目,并四处走动一下 - 你可以找到一组下楼楼梯! 它们目前没有任何作用,但它们在地图上。
实际进入下一层
在 player.rs
中,我们有一个大的 match
语句来处理用户输入。 让我们将进入下一层绑定到句点键 (在美式键盘上,那是 >
,不带 shift 键)。 将此添加到 match
:
#![allow(unused)] fn main() { // 层级变更 VirtualKeyCode::Period => { if try_next_level(&mut gs.ecs) { return RunState::NextLevel; } } }
当然,现在我们需要实现 try_next_level
:
#![allow(unused)] fn main() { pub fn try_next_level(ecs: &mut World) -> bool { let player_pos = ecs.fetch::<Point>(); let map = ecs.fetch::<Map>(); let player_idx = map.xy_idx(player_pos.x, player_pos.y); if map.tiles[player_idx] == TileType::DownStairs { true } else { let mut gamelog = ecs.fetch_mut::<GameLog>(); gamelog.entries.push("这里没有下去的路。".to_string()); false } } }
眼尖的程序员会注意到我们返回了一个新的 RunState
- NextLevel
。 由于它还不存在,我们将打开 main.rs
并实现它:
#![allow(unused)] fn main() { #[derive(PartialEq, Copy, Clone)] pub enum RunState { AwaitingInput, PreRun, PlayerTurn, MonsterTurn, ShowInventory, ShowDropItem, ShowTargeting { range : i32, item : Entity}, MainMenu { menu_selection : gui::MainMenuSelection }, SaveGame, NextLevel } }
你的 IDE 现在会抱怨我们实际上没有实现新的 RunState
! 因此,我们进入 main.rs
中不断增长的状态处理程序并添加:
#![allow(unused)] fn main() { RunState::NextLevel => { self.goto_next_level(); newrunstate = RunState::PreRun; } }
我们将为 State
添加一个新的 impl
部分,以便我们可以将方法附加到它。 我们首先要创建一个辅助方法:
#![allow(unused)] fn main() { impl State { fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> { let entities = self.ecs.entities(); let player = self.ecs.read_storage::<Player>(); let backpack = self.ecs.read_storage::<InBackpack>(); let player_entity = self.ecs.fetch::<Entity>(); let mut to_delete : Vec<Entity> = Vec::new(); for entity in entities.join() { let mut should_delete = true; // 不要删除玩家 let p = player.get(entity); if let Some(_p) = p { should_delete = false; } // 不要删除玩家的装备 let bp = backpack.get(entity); if let Some(bp) = bp { if bp.owner == *player_entity { should_delete = false; } } if should_delete { to_delete.push(entity); } } to_delete } } }
当我们进入下一层时,我们想要删除所有实体 - 除了玩家和玩家拥有的任何装备。 这个辅助函数查询 ECS 以获取要删除的实体列表。 它有点冗长,但相对简单:我们创建一个向量,然后迭代所有实体。 如果实体是玩家,我们将其标记为 should_delete=false
。 如果它在背包中(具有 InBackpack
组件),我们检查所有者是否是玩家 - 如果是,我们就不删除它。
有了这个,我们开始创建 goto_next_level
函数,也在 State
实现内部:
#![allow(unused)] fn main() { fn goto_next_level(&mut self) { // 删除不是玩家或其装备的实体 let to_delete = self.entities_to_remove_on_level_change(); for target in to_delete { self.ecs.delete_entity(target).expect("无法删除实体"); } // 构建新地图并放置玩家 let worldmap; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); let current_depth = worldmap_resource.depth; *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1); worldmap = worldmap_resource.clone(); } // 生成坏人 for room in worldmap.rooms.iter().skip(1) { spawner::spawn_room(&mut self.ecs, room); } // 放置玩家并更新资源 let (player_x, player_y) = worldmap.rooms[0].center(); let mut player_position = self.ecs.write_resource::<Point>(); *player_position = Point::new(player_x, player_y); let mut position_components = self.ecs.write_storage::<Position>(); let player_entity = self.ecs.fetch::<Entity>(); let player_pos_comp = position_components.get_mut(*player_entity); if let Some(player_pos_comp) = player_pos_comp { player_pos_comp.x = player_x; player_pos_comp.y = player_y; } // 将玩家的视野标记为脏 (dirty) let mut viewshed_components = self.ecs.write_storage::<Viewshed>(); let vs = viewshed_components.get_mut(*player_entity); if let Some(vs) = vs { vs.dirty = true; } // 通知玩家并给他们一些治疗 let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("你下到下一层,并花点时间治疗。".to_string()); let mut player_health_store = self.ecs.write_storage::<CombatStats>(); let player_health = player_health_store.get_mut(*player_entity); if let Some(player_health) = player_health { player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2); } } }
这是一个很长的函数,但它完成了我们需要的一切。 让我们逐步分解它:
- 我们使用刚刚编写的辅助函数来获取要删除的实体列表,并要求 ECS 处理它们。
- 我们创建一个
worldmap
变量,并进入一个新的作用域。 否则,我们会遇到 ECS 的不可变与可变借用的问题。 - 在此作用域中,我们获得对当前
Map
资源的写引用。 我们获取当前层级,并将地图替换为新地图 - 新深度为current_depth + 1
。 然后,我们将它的 clone 存储在外部变量中并退出作用域(避免任何借用/生命周期问题)。 - 现在我们使用与初始设置中相同的代码,在每个房间中生成坏人和物品。
- 现在我们获取第一个房间的位置,并更新玩家的资源,将其位置设置为第一个房间的中心。 我们还获取玩家的
Position
组件并更新它。 - 我们获取玩家的
Viewshed
组件,因为它现在已经过时了,因为整个地图都围绕他/她发生了变化! 我们将其标记为 dirty - 并让各种系统处理其余部分。 - 我们给玩家一个日志条目,表明他们已下到下一层。
- 我们获取玩家的生命值组件,如果他们的生命值低于 50% - 则将其提升到一半。
如果你现在 cargo run
项目,你可以四处奔跑并下降层级。 你的深度指示器会增加 - 告诉你你做对了!
总结
本章比前几章稍微容易一些! 你现在可以下降到一个实际上无限的地下城(它实际上受 32 位整数大小的限制,但祝你好运通过那么多层级)。 我们已经了解了 ECS 如何提供帮助,以及我们的序列化工作如何轻松扩展以包含像这样的新功能,随着我们添加到项目中。
本章的源代码可以在这里找到
在你的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson.