地图构建测试工具
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。我希望您会喜欢本教程,并制作出伟大的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持 我的 Patreon。
当我们深入研究生成新的和有趣的地图时,提供一种查看算法正在做什么的方法将很有帮助。本章将构建一个测试工具来实现此目的,并扩展上一章中的 SimpleMapBuilder
以支持它。这将是一项相对较大的任务,我们将在过程中学习一些新技术!
清理地图创建 - 不要重复自己
在 main.rs
中,我们基本上有三次相同的代码。当程序启动时,我们将一个地图插入到世界中。当我们更改关卡或完成游戏时 - 我们也做同样的事情。最后两个具有不同的语义(因为我们正在更新世界而不是第一次插入) - 但它基本上是冗余的重复。
我们将首先更改第一个,以插入占位符值,而不是我们打算使用的实际值。这样,World
就有了数据的槽位 - 只是目前还不是很有用。这是一个带有注释掉的旧代码的版本:
#![allow(unused)] fn main() { gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new()); gs.ecs.insert(Map::new(1)); gs.ecs.insert(Point::new(0, 0)); gs.ecs.insert(rltk::RandomNumberGenerator::new()); /*let mut builder = map_builders::random_builder(1); builder.build_map(); let player_start = builder.get_starting_position(); let map = builder.get_map(); let (player_x, player_y) = (player_start.x, player_start.y); builder.spawn_entities(&mut gs.ecs); gs.ecs.insert(map); gs.ecs.insert(Point::new(player_x, player_y));*/ // 注释掉的旧代码 let player_entity = spawner::player(&mut gs.ecs, 0, 0); gs.ecs.insert(player_entity); }
因此,我们没有构建地图,而是将一个占位符放入 World
资源中。这显然对于实际开始游戏不是很有用,所以我们还需要一个函数来执行实际的构建并更新资源。并非完全巧合,该函数与我们当前更新地图的其他两个地方相同!换句话说,我们也可以将它们合并到这个函数中。因此,在 State
的实现中,我们添加:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { let mut builder = map_builders::random_builder(new_depth); builder.build_map(); let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); *worldmap_resource = builder.get_map(); player_start = builder.get_starting_position(); } // 生成坏人 builder.spawn_entities(&mut self.ecs); // 放置玩家并更新资源 let (player_x, player_y) = (player_start.x, player_start.y); 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; } // 标记玩家的视野为脏 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; } } }
现在我们可以删除注释掉的代码,并大大简化我们的第一次调用:
#![allow(unused)] fn main() { gs.ecs.insert(Map::new(1)); gs.ecs.insert(Point::new(0, 0)); gs.ecs.insert(rltk::RandomNumberGenerator::new()); let player_entity = spawner::player(&mut gs.ecs, 0, 0); gs.ecs.insert(player_entity); gs.ecs.insert(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }); gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] }); gs.ecs.insert(particle_system::ParticleBuilder::new()); gs.ecs.insert(rex_assets::RexAssets::new()); gs.generate_world_map(1); }
我们还可以转到代码中调用我们刚刚添加到 generate_world_map
的相同代码的各个部分,并通过使用新函数来大大简化它们。我们可以用以下代码替换 goto_next_level
:
#![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("Unable to delete entity"); } // 构建新地图并放置玩家 let current_depth; { let worldmap_resource = self.ecs.fetch::<Map>(); current_depth = worldmap_resource.depth; } self.generate_world_map(current_depth + 1); // 通知玩家并给予他们一些生命值 let player_entity = self.ecs.fetch::<Entity>(); let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>(); gamelog.entries.push("You descend to the next level, and take a moment to heal.".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); } } }
同样,我们可以清理 game_over_cleanup
:
#![allow(unused)] fn main() { fn game_over_cleanup(&mut self) { // 删除所有内容 let mut to_delete = Vec::new(); for e in self.ecs.entities().join() { to_delete.push(e); } for del in to_delete.iter() { self.ecs.delete_entity(*del).expect("Deletion failed"); } // 生成一个新的玩家 { let player_entity = spawner::player(&mut self.ecs, 0, 0); let mut player_entity_writer = self.ecs.write_resource::<Entity>(); *player_entity_writer = player_entity; } // 构建新地图并放置玩家 self.generate_world_map(1); } }
这样就可以了 - cargo run
给出了我们已经玩了一段时间的相同游戏,并且我们删减了很多代码。使事情更小的重构真是太棒了!
制作生成器
有时,组合两种范例出奇地困难:
- RLTK(以及底层 GUI 环境)的图形“tick”特性鼓励您快速地一蹴而就地完成所有操作。
- 在生成地图时实际可视化进度鼓励您以许多阶段作为“状态机”运行,并在过程中产生地图结果。
我的第一个想法是使用协程,特别是 Generators。它们确实非常适合这种类型的事情:您可以编写在函数中同步(按顺序)运行的代码,并在计算继续时“yield”值。我甚至深入到获得了可工作的实现 - 但它需要 nightly 支持(不稳定的,未完成的 Rust)并且与 web assembly 不能很好地配合。所以我放弃了它。这里有一个教训:有时工具并不完全为你真正想要的东西做好准备!
相反,我决定采用更传统的方式。地图可以在生成时拍摄“快照”,并且可以在可视化工具中逐帧播放大量的快照。这不如协程那么好,但它有效且稳定。这些是理想的特性!
首先,我们应该确保可视化地图生成是完全可选的。当您将游戏发布给玩家时,您可能不希望在他们开始游戏时向他们展示整个地图 - 但当您处理地图算法时,这非常有价值。所以在 main.rs
的顶部,我们添加一个常量:
#![allow(unused)] fn main() { const SHOW_MAPGEN_VISUALIZER : bool = true; }
常量就是这样:一旦程序启动就无法更改的变量。Rust 使只读常量非常容易,并且编译器通常会完全优化掉它们,因为值是预先知道的。在这种情况下,我们声明一个名为 SHOW_MAPGEN_VISUALIZER
的 bool
值为 true
。我们的想法是,当我们不想显示地图生成进度时,可以将其设置为 false
。
有了这个前提,是时候为我们的地图构建器接口添加快照支持了。在 map_builders/mod.rs
中,我们稍微扩展了接口:
#![allow(unused)] fn main() { pub trait MapBuilder { fn build_map(&mut self); fn spawn_entities(&mut self, ecs : &mut World); fn get_map(&self) -> Map; fn get_starting_position(&self) -> Position; fn get_snapshot_history(&self) -> Vec<Map>; fn take_snapshot(&mut self); } }
请注意新条目:get_snapshot_history
和 take_snapshot
。前者将用于向生成器请求其地图帧的历史记录;后者告诉生成器支持拍摄快照(并由他们决定如何操作)。
现在是提及 Rust 和 C++(以及其他提供面向对象编程支持的语言)之间一个主要区别的好时机。Rust 特征不支持向特征签名添加变量。因此,即使 history : Vec<Map>
恰好是您在所有实现中用于存储快照的内容,您也不能将其包含在特征中。我真的不知道为什么会这样,但这可以解决 - 只是与 OOP 规范略有不同。
在 simple_map.rs
内部,我们需要为我们的 SimpleMapBuilder
实现这些方法。我们首先向我们的 struct
添加支持变量:
#![allow(unused)] fn main() { pub struct SimpleMapBuilder { map : Map, starting_position : Position, depth: i32, rooms: Vec<Rect>, history: Vec<Map> } }
请注意,我们已将 history: Vec<Map>
添加到结构中。它的意思正如其字面意思:Map
结构的向量(可调整大小的数组)。我们的想法是,我们将为地图生成的每个“帧”不断地将地图的副本添加到其中。
接下来是特征实现:
#![allow(unused)] fn main() { fn get_snapshot_history(&self) -> Vec<Map> { self.history.clone() } }
这非常简单:我们将历史向量的副本返回给调用者。我们还需要:
#![allow(unused)] fn main() { 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); } } }
我们首先检查是否正在使用快照功能(如果没有使用,则没有必要浪费内存!)。如果正在使用,我们获取当前地图的副本,迭代每个 revealed_tiles
单元格并将其设置为 true
(以便地图渲染将显示所有内容,包括无法访问的墙壁),并将其添加到历史记录列表中。
我们现在可以在地图生成的任何时候调用 self.take_snapshot()
,它会被添加为地图生成器的帧。在 simple_map.rs
中,我们在添加房间或走廊后添加几个调用:
#![allow(unused)] fn main() { ... if ok { apply_room_to_map(&mut self.map, &new_room); self.take_snapshot(); if !self.rooms.is_empty() { let (new_x, new_y) = new_room.center(); let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center(); if rng.range(0,2) == 1 { apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y); apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x); } else { apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x); apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y); } } self.rooms.push(new_room); self.take_snapshot(); } ... }
渲染可视化工具
可视化地图开发是另一种游戏状态,所以我们将其添加到 main.rs
中的 RunState
枚举中:
#![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, ShowRemoveItem, GameOver, MagicMapReveal { row : i32 }, MapGeneration } }
可视化实际上需要几个变量,但我遇到了一个问题:其中一个变量真的应该是我们在可视化之后要转换到的下一个状态。我们可能正在从三个来源之一(新游戏、游戏结束、下一关)构建新地图 - 并且它们在生成后具有不同的状态。不幸的是,你不能将第二个 RunState
放入第一个 RunState
中 - Rust 会给你循环错误,并且它不会编译。您可以使用 Box<RunState>
- 但这不适用于从 Copy
派生的 RunState
!我为此奋斗了一段时间,并决定添加到 State
中:
#![allow(unused)] fn main() { pub struct State { pub ecs: World, mapgen_next_state : Option<RunState>, mapgen_history : Vec<Map>, mapgen_index : usize, mapgen_timer : f32 } }
我们添加了:
mapgen_next_state
- 游戏接下来应该去哪里。mapgen_history
- 要播放的地图历史帧的副本。mapgen_index
- 我们在回放过程中历史记录的进度。mapgen_timer
- 用于回放期间的帧定时。
由于我们修改了 State
,我们还必须修改 State
对象的创建:
#![allow(unused)] fn main() { let mut gs = State { ecs: World::new(), mapgen_next_state : Some(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }), mapgen_index : 0, mapgen_history: Vec::new(), mapgen_timer: 0.0 }; }
我们使下一个状态与我们一直在使用的起始状态相同:因此游戏将渲染地图创建,然后转到菜单。我们可以将我们的初始状态更改为 MapGeneration
:
#![allow(unused)] fn main() { gs.ecs.insert(RunState::MapGeneration{} ); }
现在我们需要实现渲染器。在我们的 tick
函数中,我们添加以下状态:
#![allow(unused)] fn main() { match newrunstate { RunState::MapGeneration => { if !SHOW_MAPGEN_VISUALIZER { newrunstate = self.mapgen_next_state.unwrap(); } ctx.cls(); draw_map(&self.mapgen_history[self.mapgen_index], ctx); self.mapgen_timer += ctx.frame_time_ms; if self.mapgen_timer > 300.0 { self.mapgen_timer = 0.0; self.mapgen_index += 1; if self.mapgen_index >= self.mapgen_history.len() { newrunstate = self.mapgen_next_state.unwrap(); } } } ... }
这相对简单:
- 如果未启用可视化工具,则立即转换到下一个状态。
- 清空屏幕。
- 调用
draw_map
,使用我们状态中的地图历史记录 - 在当前帧。 - 将帧持续时间添加到
mapgen_timer
,如果它大于 300ms:- 将计时器设置回 0。
- 增加帧计数器。
- 如果帧计数器已到达历史记录的末尾,则转换到下一个游戏状态。
眼尖的读者会注意到这里有一个细微的变化。draw_map
以前不接受 map
- 它会从 ECS 中获取!在 map.rs
中,draw_map
的开头更改为:
#![allow(unused)] fn main() { pub fn draw_map(map : &Map, ctx : &mut Rltk) { }
我们在 tick
中的常规 draw_map
调用也更改为:
#![allow(unused)] fn main() { draw_map(&self.ecs.fetch::<Map>(), ctx); }
这是一个微小的更改,它允许我们渲染我们需要的任何 Map
结构!
最后,我们需要实际为可视化工具提供一些数据来渲染。我们调整 generate_world_map
以重置各种 mapgen_
变量,清除历史记录,并在运行后检索快照历史记录:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let mut builder = map_builders::random_builder(new_depth); builder.build_map(); self.mapgen_history = builder.get_snapshot_history(); let player_start; { let mut worldmap_resource = self.ecs.write_resource::<Map>(); *worldmap_resource = builder.get_map(); player_start = builder.get_starting_position(); } }
如果您现在 cargo run
该项目,您可以在开始游戏之前观看简单的地图生成器构建您的关卡。
总结
这完成了构建测试工具 - 您可以观看地图生成,这应该使生成地图(接下来几章的主题)更加直观。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.