因为本篇涉及较大量代码,又因为机核文章没有代码块,因此阅读体验不佳。
若你有阅读代码的兴趣,可以到 itch.io 阅读本日志:点击跳转
编辑器 @宏楼 Koiro
现在编辑器可以进行方块编辑、选区移动、加载 & 保存存档的功能。
写 gizmos 事遇到一些 bevy Gizmos 的一些问题:
https://github.com/bevyengine/bevy/issues/13027
https://github.com/bevyengine/bevy/issues/13028
更好的方块注册系统和序列化系统 @宏楼 Koiro
方块注册系统
系统本身并不复杂,核心实现大概是这样,我们确保每个方块有一个唯一的 registry name,利用 registry name 和对应的 Block Builder 的行为生成方块:
lazy_static! { pub static ref BLOCK_REGISTRY: Mutex<BlockRegistry> = Mutex::new(BlockRegistry::default()); } pub trait BlockBuilder { fn spawn(&self, world: &mut World, spawn_cmd: &SpawnBlockCmd) -> Result<()>; } #[derive(Default)] pub struct BlockRegistry { map: HashMap<String, Box<dyn BlockBuilder + Send>>, } impl BlockRegistry { pub fn register_block<T: BlockBuilder + Send + 'static>(&mut self, reg_name: &str, builder: T) { //... } pub fn get_block_builder(&self, reg_name: &str) -> Option<&Box<dyn BlockBuilder + Send>> { //... } //... }
因为生成方块时 world 所有权冲突问题,所以方块注册表并没有使用 Bevy 的 Resource 和 Non-Send Resource,而是使用 lazy_static.
序列化系统
序列化和反序列化行为用 DeserializeManager 和 SerializeManager 管理。分别在保存和加载时调用。序列化时,场景会被序列化成 toml 中的 Table. 根据需要保存在磁盘或内存中。
#[derive(Default, Resource, Clone)] pub struct CatDeserializerManager { pub map: HashMap<Stage, HashMap<String, SystemId<DeserializeContext, Result<()>>>>, } #[derive(Default, Resource, Clone)] pub struct CatSerializerManager { pub map: HashMap<String, SystemId<(), Result<Table>>>, }
同时为加载做了阶段,每个阶段会间隔几帧执行。
#[derive(Default, Clone, PartialEq, Eq, Reflect, Hash)] pub enum Stage { Start, Pre, #[default] Normal, Post, End, }
Action 系统 @Rasp
@Koiro 补充:命令模式之所以叫 Action 是因为 Command 被 bevy 用了
为了让玩家的交互和地图上发生的事情可以被撤销,我们要先把命令模式做完。
用几个栈的组合可以实现动作和反动作(也就是撤销)操作的存储和执行,这个比较简单。比较有趣的问题是,这些栈里面要存什么。
由于我们在使用 Rust,我决定用 Trait 对象实现所有 Action 通用的功能。
pub trait Action: Send + Sync { fn r#do(&self, world: &mut World); fn undo(&self, world: &mut World); fn name(&self) -> String; }
可以注意到,两个核心方法的签名都是对自身的不可变借用。这实际上是一个设计失误,不可变借用自身导致在动作和反动作之间保存状态变得不可能。这会让类似“移除一个方块并在撤销时还原这个方块”这类的操作无法实现,因为我们无法保存那个“被移除的方块”。但是如果使用了可变借用,又会遇到一些跟 Bevy 相关的所有权问题。
一个临时解决方案是使用 Mutex:
pub trait ActionMut: Send + Sync { fn r#do(&mut self, world: &mut World); fn undo(&mut self, world: &mut World); } impl<AM: ActionMut> Action for (String, Mutex<AM>) { fn r#do(&self, world: &mut World) { let mut inner = self.1.lock().unwrap(); inner.r#do(world); } fn undo(&self, world: &mut World) { let mut inner = self.1.lock().unwrap(); inner.undo(world); } fn name(&self) -> String { format!("{}", self.0) } }
这样,我们就可以为需要可变借用自身的 Action 实现对应的 Trait 并且不会破坏先前实现的代码。
方块的合并与反合并 @Rasp
之前在 Unity 里面实现这个功能的时候因为没有认真想过怎么做撤销,用了一个非常草率的解决方案。
这个草率的解决方案基于一个假设:方块集合不能从中间断开。意思是,如果两个方块粘在一起,那么游戏中没有正常的操作能够把它们分开。相信你已经看出问题了,撤回操作作为合并操作的反操作,会破坏这个假设,导致旧有的解决方案没法正常使用。
新的解决方案基于一个基本原理:我们要保存方块、保存它跟谁粘在一起,更重要的是:保存这个方块所有的邻居信息。这样,通过对比当前邻居信息和之前邻居的差异,可以分辨出到底发生了什么:连接还是断开,又或者是增加了新的方块。
#[derive(Component, Reflect, Default)]
pub struct BlockUnionSet {
// core
neighbors: HashMap<Entity, [Option<Neighbor>; 4]>,
// polymer
polymer_id: HashMap<Entity, u64>,
polymers: HashMap<u64, Vec<Entity>>,
empty_polyer_id: Vec<u64>,
// edge
#[reflect(ignore)]
edges: HashMap<String, BTreeSet<Edge>>,
}
比较邻居信息并且更新方块集合的代码看起来像这样:
for (neighbor_in_set, neighbor_on_map) in neighbors { match (neighbor_in_set, neighbor_on_map) { (None, None) => { /* nop */ } (None, Some(on_map)) => { // in this case, we have to check the new neighbor is the same or not if block.reg_name == blocks[&on_map].reg_name { // if they are, we merge self.merge_polymer(entity, on_map); } } (Some(in_set), None) => { // in this case, we have to check our old neighbor if in_set.0 { // if our old neighbor and entity are the same, it means we lost a neighbor // we will request a deep refresh for this entity and it's connected blocks deep_refresh_list.push(entity); } } (Some(in_set), Some(on_map)) => { // in this case, we have to consider our old neighbor is the same with us or not if in_set.0 { // if they were and it's not now if blocks[&on_map].reg_name != block.reg_name { // we lost a neighbor deep_refresh_list.push(entity); deep_refresh_list.push(on_map); } } else { // if they were not but they are now if blocks[&on_map].reg_name == block.reg_name { // we have a new neighbor deep_refresh_list.push(entity); deep_refresh_list.push(on_map); } } } } }
更令人欣喜的是,存储了邻居相关的信息之后,后面方块集合边缘的寻找和判断也变得简单了。
玩家移动 @Rasp
在方块合并和反合并工作的基础上,我们可以把玩家移动的部分做完。
由于 Tilemap 部分完成了很多工作,留给我的部分不多。
首先我们要为单个方块编写移动相关的 Action,实现过程也很简单。在有了这个功能之后,我们通过先判断玩家的移动方向是否有障碍,如果有就判断障碍物是否可以移动。最后把所有需要移动的方块对应的移动 Action 都推进栈中等待执行即可。
边界改变交互 @宏楼 Koiro
边界改变的逻辑由 Asepirin 完成了,我仅需要做边界改变的交互与视觉设计。我们使用了虚拟光标。目前的效果是临时的,借此机会熟悉了些 bevy 中使用 shader.