因為本篇涉及較大量代碼,又因為機核文章沒有代碼塊,因此閱讀體驗不佳。
若你有閱讀代碼的興趣,可以到 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.