diff --git a/Assets/# Prototype/World/JGraph.asset b/Assets/# Prototype/World/JGraph.asset new file mode 100644 index 0000000..07194de --- /dev/null +++ b/Assets/# Prototype/World/JGraph.asset @@ -0,0 +1,613 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fcb661d795ed4088af4ae73e4253f98b, type: 3} + m_Name: JGraph + m_EditorClassIdentifier: Assembly-CSharp::Intrepid.Gameplay.Journey.JGraphSO + sourceWorldLayoutId: 858671c3949e4543b0d68818c9b2e41b + publishedAt: 2026-06-20T07:40:11.0297990Z + schemaVersion: 1 + contentHash: 0c6d1b68e944d050c332427770faa770e77fac44b9fc92372e5b1487ff21331c + stages: + - id: stage_9202fe485eaa408d86e379b3fdafa49b + displayName: Stage 1 + worldLayer: 0 + occupiedTiles: [] + tags: [] + - id: stage_25e8797ce5d9420b97aaf3e06f760b1d + displayName: Stage 2 + worldLayer: 0 + occupiedTiles: [] + tags: [] + - id: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + displayName: Stage 3 + worldLayer: 0 + occupiedTiles: + - x: 8 + y: 8 + - x: 9 + y: 8 + - x: 10 + y: 8 + - x: 11 + y: 8 + - x: 12 + y: 8 + - x: 8 + y: 9 + - x: 9 + y: 9 + - x: 10 + y: 9 + - x: 11 + y: 9 + - x: 12 + y: 9 + - x: 8 + y: 10 + - x: 9 + y: 10 + - x: 10 + y: 10 + - x: 11 + y: 10 + - x: 12 + y: 10 + - x: 8 + y: 11 + - x: 9 + y: 11 + - x: 10 + y: 11 + - x: 11 + y: 11 + - x: 12 + y: 11 + - x: 8 + y: 12 + - x: 9 + y: 12 + - x: 10 + y: 12 + - x: 11 + y: 12 + - x: 12 + y: 12 + - x: 13 + y: 9 + - x: 13 + y: 10 + - x: 13 + y: 11 + - x: 13 + y: 12 + - x: 8 + y: 7 + - x: 9 + y: 7 + - x: 10 + y: 7 + - x: 11 + y: 7 + - x: 7 + y: 7 + - x: 7 + y: 8 + - x: 7 + y: 9 + - x: 7 + y: 10 + tags: [] + - id: stage_92bb328658e04aceb915822ac7ff20ae + displayName: Stage 4 + worldLayer: 0 + occupiedTiles: [] + tags: [] + - id: stage_aae8c48d41d84a679c9c52acd567ff30 + displayName: Stage 5 + worldLayer: 0 + occupiedTiles: + - x: 8 + y: 13 + - x: 9 + y: 13 + - x: 10 + y: 13 + - x: 11 + y: 13 + - x: 12 + y: 13 + - x: 8 + y: 14 + - x: 9 + y: 14 + - x: 10 + y: 14 + - x: 11 + y: 14 + - x: 12 + y: 14 + - x: 8 + y: 15 + - x: 9 + y: 15 + - x: 10 + y: 15 + - x: 11 + y: 15 + - x: 12 + y: 15 + - x: 8 + y: 16 + - x: 9 + y: 16 + - x: 10 + y: 16 + - x: 11 + y: 16 + - x: 12 + y: 16 + - x: 13 + y: 14 + - x: 13 + y: 15 + - x: 13 + y: 16 + - x: 11 + y: 17 + - x: 12 + y: 17 + - x: 13 + y: 17 + tags: [] + - id: stage_526b633d818b4e36b96ad92f540b9415 + displayName: Stage 6 + worldLayer: 0 + occupiedTiles: + - x: 17 + y: 7 + - x: 18 + y: 7 + - x: 19 + y: 7 + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + tags: [] + - id: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + displayName: Stage 7 + worldLayer: 0 + occupiedTiles: + - x: 13 + y: 13 + - x: 14 + y: 13 + - x: 16 + y: 13 + - x: 17 + y: 13 + - x: 15 + y: 13 + - x: 14 + y: 9 + - x: 15 + y: 9 + - x: 16 + y: 9 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 14 + y: 10 + - x: 15 + y: 10 + - x: 16 + y: 10 + - x: 17 + y: 10 + - x: 18 + y: 10 + - x: 19 + y: 10 + - x: 14 + y: 11 + - x: 15 + y: 11 + - x: 16 + y: 11 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - x: 14 + y: 12 + - x: 15 + y: 12 + - x: 16 + y: 12 + - x: 17 + y: 12 + - x: 18 + y: 12 + - x: 19 + y: 12 + - x: 18 + y: 13 + - x: 19 + y: 13 + - x: 14 + y: 14 + - x: 15 + y: 14 + - x: 16 + y: 14 + - x: 17 + y: 14 + - x: 18 + y: 14 + - x: 19 + y: 14 + - x: 14 + y: 15 + - x: 15 + y: 15 + - x: 16 + y: 15 + - x: 17 + y: 15 + - x: 18 + y: 15 + - x: 19 + y: 15 + - x: 14 + y: 16 + - x: 15 + y: 16 + - x: 16 + y: 16 + - x: 17 + y: 16 + - x: 18 + y: 16 + - x: 19 + y: 16 + - x: 14 + y: 17 + - x: 15 + y: 17 + - x: 16 + y: 17 + - x: 17 + y: 17 + - x: 18 + y: 17 + - x: 19 + y: 17 + tags: [] + - id: stage_1a60b23d8b4c4622905831237c716081 + displayName: Stage 8 + worldLayer: 1 + occupiedTiles: + - x: 16 + y: 8 + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - x: 20 + y: 8 + - x: 16 + y: 9 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 20 + y: 9 + - x: 16 + y: 10 + - x: 17 + y: 10 + - x: 18 + y: 10 + - x: 19 + y: 10 + - x: 20 + y: 10 + - x: 16 + y: 11 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - x: 20 + y: 11 + - x: 16 + y: 12 + - x: 17 + y: 12 + - x: 18 + y: 12 + - x: 19 + y: 12 + - x: 20 + y: 12 + tags: [] + - id: stage_a980c91d39bc470296289d3ef72636c0 + displayName: Stage 9 + worldLayer: -1 + occupiedTiles: + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 17 + y: 10 + - x: 18 + y: 10 + - x: 19 + y: 10 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + tags: [] + - id: stage_78ca4be5151f4c60818dce81558ab162 + displayName: Stage 10 + worldLayer: -2 + occupiedTiles: + - x: 18 + y: 10 + - x: 16 + y: 8 + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - x: 20 + y: 8 + - x: 16 + y: 9 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 20 + y: 9 + - x: 16 + y: 10 + - x: 17 + y: 10 + - x: 19 + y: 10 + - x: 20 + y: 10 + - x: 16 + y: 11 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - x: 20 + y: 11 + - x: 16 + y: 12 + - x: 17 + y: 12 + - x: 18 + y: 12 + - x: 19 + y: 12 + - x: 20 + y: 12 + tags: [] + - id: stage_bfa43a65aced42ceb03f56b11dce0998 + displayName: Stage 15 + worldLayer: 0 + occupiedTiles: [] + tags: [] + - id: stage_b24d4774f45444138b8cfda33ed5034e + displayName: Stage 16 + worldLayer: 0 + occupiedTiles: [] + tags: [] + endpoints: + - id: endpoint_adc34044a48447d0be41389ba398d5f5 + stageId: stage_aae8c48d41d84a679c9c52acd567ff30 + worldTile: + x: 10 + y: 13 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_181357103f5742008cfa73a544a56363 + stageId: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + worldTile: + x: 10 + y: 12 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_71bd3851ad2b4d20a90cf3b3f5ade5fb + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 13 + y: 13 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_d9121bcc789443548c898106dd5f4c43 + stageId: stage_aae8c48d41d84a679c9c52acd567ff30 + worldTile: + x: 13 + y: 14 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_dc223745e7994fbd880f1e93f146d8cf + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 13 + y: 13 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_65e01fe22fe44222919c322ea8580a69 + stageId: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + worldTile: + x: 13 + y: 12 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_27a37615b51f40cdb39acfc9fbffce22 + stageId: stage_526b633d818b4e36b96ad92f540b9415 + worldTile: + x: 18 + y: 8 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_7cf95989e98a4de49edb7e2080e1c5e6 + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 18 + y: 9 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_34e27be82570488cbcd45531e56f128c + stageId: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + worldTile: + x: 10 + y: 10 + kind: 1 + required: 1 + socketFace: 0 + - id: endpoint_e6e40c3366564b96825ecf652721a46d + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 17 + y: 13 + kind: 1 + required: 1 + socketFace: 0 + - id: endpoint_9c245891f5734102bc24edb0dcc2c9fd + stageId: stage_aae8c48d41d84a679c9c52acd567ff30 + worldTile: + x: 11 + y: 15 + kind: 1 + required: 1 + socketFace: 0 + - id: endpoint_0e3b0c016a474adb8fe8b05bdfd79f14 + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + - id: endpoint_6f17b532985e4e908e70b3802eccf8cc + stageId: stage_78ca4be5151f4c60818dce81558ab162 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + - id: endpoint_ae66297a1c1a4c259e26ea8b51d69851 + stageId: stage_1a60b23d8b4c4622905831237c716081 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + - id: endpoint_263ea59d79614b9b85ed2bb4fe25c9ad + stageId: stage_a980c91d39bc470296289d3ef72636c0 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + connections: + - id: connection_cdf62cf06a20426b88d3987ea8917967 + endpointAId: endpoint_adc34044a48447d0be41389ba398d5f5 + endpointBId: endpoint_181357103f5742008cfa73a544a56363 + directionality: 0 + - id: connection_3dabe3efe429439bbb49104bed37bb20 + endpointAId: endpoint_71bd3851ad2b4d20a90cf3b3f5ade5fb + endpointBId: endpoint_d9121bcc789443548c898106dd5f4c43 + directionality: 0 + - id: connection_ef34c049437c491b80364a034880300d + endpointAId: endpoint_dc223745e7994fbd880f1e93f146d8cf + endpointBId: endpoint_65e01fe22fe44222919c322ea8580a69 + directionality: 0 + - id: connection_2b7aa20f26bd48acb636a37ed1d20ad0 + endpointAId: endpoint_27a37615b51f40cdb39acfc9fbffce22 + endpointBId: endpoint_7cf95989e98a4de49edb7e2080e1c5e6 + directionality: 0 + - id: connection_d8da15ab563d4bb9ba6f0294cec60341 + endpointAId: endpoint_e6e40c3366564b96825ecf652721a46d + endpointBId: endpoint_34e27be82570488cbcd45531e56f128c + directionality: 0 + - id: connection_335b1c8a1f964b11b72ddf2ef053bd71 + endpointAId: endpoint_9c245891f5734102bc24edb0dcc2c9fd + endpointBId: endpoint_e6e40c3366564b96825ecf652721a46d + directionality: 0 + - id: connection_d0e5d7611be64e31a77bc6d24650a031 + endpointAId: endpoint_9c245891f5734102bc24edb0dcc2c9fd + endpointBId: endpoint_34e27be82570488cbcd45531e56f128c + directionality: 0 + - id: connection_12db74114bf54c8eb5b7038efcae4172 + endpointAId: endpoint_ae66297a1c1a4c259e26ea8b51d69851 + endpointBId: endpoint_0e3b0c016a474adb8fe8b05bdfd79f14 + directionality: 0 + - id: connection_274434096af24703a063627df527d688 + endpointAId: endpoint_263ea59d79614b9b85ed2bb4fe25c9ad + endpointBId: endpoint_0e3b0c016a474adb8fe8b05bdfd79f14 + directionality: 0 + - id: connection_22f10261be3b4a24ada8debf789f9e33 + endpointAId: endpoint_263ea59d79614b9b85ed2bb4fe25c9ad + endpointBId: endpoint_6f17b532985e4e908e70b3802eccf8cc + directionality: 0 diff --git a/Assets/# Prototype/World/JGraph.asset.meta b/Assets/# Prototype/World/JGraph.asset.meta new file mode 100644 index 0000000..c4838ea --- /dev/null +++ b/Assets/# Prototype/World/JGraph.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0e8c3fc507c9b42e8b1f2df5009b5937 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/# Prototype/World/WorldLayout.asset b/Assets/# Prototype/World/WorldLayout.asset index 62a443e..118cf2e 100644 --- a/Assets/# Prototype/World/WorldLayout.asset +++ b/Assets/# Prototype/World/WorldLayout.asset @@ -12,9 +12,627 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 8cbbff7dfcc24d2b9030f41f9f2ef718, type: 3} m_Name: WorldLayout m_EditorClassIdentifier: Assembly-CSharp-Editor::Editor.GON.WorldEditor.WorldLayoutSO - id: 5d22dc1f93b74fd1aeeacd2abe5fcb9c + id: 858671c3949e4543b0d68818c9b2e41b worldTileSize: 24 worldLayerHeight: 24 - stages: [] - endpoints: [] - connections: [] + minWorldLayer: -2 + maxWorldLayer: 1 + stages: + - id: stage_9202fe485eaa408d86e379b3fdafa49b + displayName: Stage 1 + stageKind: 0 + worldLayer: 0 + color: {r: 0.43435237, g: 0.72672, b: 0.73008716, a: 0.820845} + stageDefinition: {fileID: 0} + occupiedTiles: [] + - id: stage_25e8797ce5d9420b97aaf3e06f760b1d + displayName: Stage 2 + stageKind: 0 + worldLayer: 0 + color: {r: 0.71619093, g: 0.6632371, b: 0.3653053, a: 0.7631802} + stageDefinition: {fileID: 0} + occupiedTiles: [] + - id: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + displayName: Stage 3 + stageKind: 0 + worldLayer: 0 + color: {r: 0.26295125, g: 0.36055228, b: 0.6102875, a: 0.76864} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 8 + y: 8 + - x: 9 + y: 8 + - x: 10 + y: 8 + - x: 11 + y: 8 + - x: 12 + y: 8 + - x: 8 + y: 9 + - x: 9 + y: 9 + - x: 10 + y: 9 + - x: 11 + y: 9 + - x: 12 + y: 9 + - x: 8 + y: 10 + - x: 9 + y: 10 + - x: 10 + y: 10 + - x: 11 + y: 10 + - x: 12 + y: 10 + - x: 8 + y: 11 + - x: 9 + y: 11 + - x: 10 + y: 11 + - x: 11 + y: 11 + - x: 12 + y: 11 + - x: 8 + y: 12 + - x: 9 + y: 12 + - x: 10 + y: 12 + - x: 11 + y: 12 + - x: 12 + y: 12 + - x: 13 + y: 9 + - x: 13 + y: 10 + - x: 13 + y: 11 + - x: 13 + y: 12 + - x: 8 + y: 7 + - x: 9 + y: 7 + - x: 10 + y: 7 + - x: 11 + y: 7 + - x: 7 + y: 7 + - x: 7 + y: 8 + - x: 7 + y: 9 + - x: 7 + y: 10 + - id: stage_92bb328658e04aceb915822ac7ff20ae + displayName: Stage 4 + stageKind: 0 + worldLayer: 0 + color: {r: 0.7052704, g: 0.6072819, b: 0.39938506, a: 0.8043456} + stageDefinition: {fileID: 0} + occupiedTiles: [] + - id: stage_aae8c48d41d84a679c9c52acd567ff30 + displayName: Stage 5 + stageKind: 0 + worldLayer: 0 + color: {r: 0.73909104, g: 0.47607818, b: 0.6006857, a: 0.8447911} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 8 + y: 13 + - x: 9 + y: 13 + - x: 10 + y: 13 + - x: 11 + y: 13 + - x: 12 + y: 13 + - x: 8 + y: 14 + - x: 9 + y: 14 + - x: 10 + y: 14 + - x: 11 + y: 14 + - x: 12 + y: 14 + - x: 8 + y: 15 + - x: 9 + y: 15 + - x: 10 + y: 15 + - x: 11 + y: 15 + - x: 12 + y: 15 + - x: 8 + y: 16 + - x: 9 + y: 16 + - x: 10 + y: 16 + - x: 11 + y: 16 + - x: 12 + y: 16 + - x: 13 + y: 14 + - x: 13 + y: 15 + - x: 13 + y: 16 + - x: 11 + y: 17 + - x: 12 + y: 17 + - x: 13 + y: 17 + - id: stage_526b633d818b4e36b96ad92f540b9415 + displayName: Stage 6 + stageKind: 0 + worldLayer: 0 + color: {r: 0.59988964, g: 0.84526074, b: 0.5487183, a: 0.7954139} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 17 + y: 7 + - x: 18 + y: 7 + - x: 19 + y: 7 + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - id: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + displayName: Stage 7 + stageKind: 0 + worldLayer: 0 + color: {r: 0.7086307, g: 0.25315043, b: 0.84043574, a: 0.79866225} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 13 + y: 13 + - x: 14 + y: 13 + - x: 16 + y: 13 + - x: 17 + y: 13 + - x: 15 + y: 13 + - x: 14 + y: 9 + - x: 15 + y: 9 + - x: 16 + y: 9 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 14 + y: 10 + - x: 15 + y: 10 + - x: 16 + y: 10 + - x: 17 + y: 10 + - x: 18 + y: 10 + - x: 19 + y: 10 + - x: 14 + y: 11 + - x: 15 + y: 11 + - x: 16 + y: 11 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - x: 14 + y: 12 + - x: 15 + y: 12 + - x: 16 + y: 12 + - x: 17 + y: 12 + - x: 18 + y: 12 + - x: 19 + y: 12 + - x: 18 + y: 13 + - x: 19 + y: 13 + - x: 14 + y: 14 + - x: 15 + y: 14 + - x: 16 + y: 14 + - x: 17 + y: 14 + - x: 18 + y: 14 + - x: 19 + y: 14 + - x: 14 + y: 15 + - x: 15 + y: 15 + - x: 16 + y: 15 + - x: 17 + y: 15 + - x: 18 + y: 15 + - x: 19 + y: 15 + - x: 14 + y: 16 + - x: 15 + y: 16 + - x: 16 + y: 16 + - x: 17 + y: 16 + - x: 18 + y: 16 + - x: 19 + y: 16 + - x: 14 + y: 17 + - x: 15 + y: 17 + - x: 16 + y: 17 + - x: 17 + y: 17 + - x: 18 + y: 17 + - x: 19 + y: 17 + - id: stage_1a60b23d8b4c4622905831237c716081 + displayName: Stage 8 + stageKind: 0 + worldLayer: 1 + color: {r: 0.41402355, g: 0.6012687, b: 0.37944123, a: 0.8586461} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 16 + y: 8 + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - x: 20 + y: 8 + - x: 16 + y: 9 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 20 + y: 9 + - x: 16 + y: 10 + - x: 17 + y: 10 + - x: 18 + y: 10 + - x: 19 + y: 10 + - x: 20 + y: 10 + - x: 16 + y: 11 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - x: 20 + y: 11 + - x: 16 + y: 12 + - x: 17 + y: 12 + - x: 18 + y: 12 + - x: 19 + y: 12 + - x: 20 + y: 12 + - id: stage_a980c91d39bc470296289d3ef72636c0 + displayName: Stage 9 + stageKind: 0 + worldLayer: -1 + color: {r: 0.6987197, g: 0.5302855, b: 0.419144, a: 0.76745874} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 17 + y: 10 + - x: 18 + y: 10 + - x: 19 + y: 10 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - id: stage_78ca4be5151f4c60818dce81558ab162 + displayName: Stage 10 + stageKind: 0 + worldLayer: -2 + color: {r: 0.58324605, g: 0.27982995, b: 0.5501635, a: 0.85375965} + stageDefinition: {fileID: 0} + occupiedTiles: + - x: 18 + y: 10 + - x: 16 + y: 8 + - x: 17 + y: 8 + - x: 18 + y: 8 + - x: 19 + y: 8 + - x: 20 + y: 8 + - x: 16 + y: 9 + - x: 17 + y: 9 + - x: 18 + y: 9 + - x: 19 + y: 9 + - x: 20 + y: 9 + - x: 16 + y: 10 + - x: 17 + y: 10 + - x: 19 + y: 10 + - x: 20 + y: 10 + - x: 16 + y: 11 + - x: 17 + y: 11 + - x: 18 + y: 11 + - x: 19 + y: 11 + - x: 20 + y: 11 + - x: 16 + y: 12 + - x: 17 + y: 12 + - x: 18 + y: 12 + - x: 19 + y: 12 + - x: 20 + y: 12 + - id: stage_bfa43a65aced42ceb03f56b11dce0998 + displayName: Stage 15 + stageKind: 0 + worldLayer: 0 + color: {r: 0.82491213, g: 0.683962, b: 0.2155831, a: 0.8341453} + stageDefinition: {fileID: 0} + occupiedTiles: [] + - id: stage_b24d4774f45444138b8cfda33ed5034e + displayName: Stage 16 + stageKind: 0 + worldLayer: 0 + color: {r: 0.44949582, g: 0.6144462, b: 0.7657885, a: 0.82364887} + stageDefinition: {fileID: 0} + occupiedTiles: [] + endpoints: + - id: endpoint_adc34044a48447d0be41389ba398d5f5 + stageId: stage_aae8c48d41d84a679c9c52acd567ff30 + worldTile: + x: 10 + y: 13 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_181357103f5742008cfa73a544a56363 + stageId: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + worldTile: + x: 10 + y: 12 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_71bd3851ad2b4d20a90cf3b3f5ade5fb + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 13 + y: 13 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_d9121bcc789443548c898106dd5f4c43 + stageId: stage_aae8c48d41d84a679c9c52acd567ff30 + worldTile: + x: 13 + y: 14 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_dc223745e7994fbd880f1e93f146d8cf + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 13 + y: 13 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_65e01fe22fe44222919c322ea8580a69 + stageId: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + worldTile: + x: 13 + y: 12 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_27a37615b51f40cdb39acfc9fbffce22 + stageId: stage_526b633d818b4e36b96ad92f540b9415 + worldTile: + x: 18 + y: 8 + kind: 0 + required: 1 + socketFace: 1 + - id: endpoint_7cf95989e98a4de49edb7e2080e1c5e6 + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 18 + y: 9 + kind: 0 + required: 1 + socketFace: 0 + - id: endpoint_34e27be82570488cbcd45531e56f128c + stageId: stage_f5a0fa8cc3a14a94b8b6fa80c4e2413d + worldTile: + x: 10 + y: 10 + kind: 1 + required: 1 + socketFace: 0 + - id: endpoint_e6e40c3366564b96825ecf652721a46d + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 17 + y: 13 + kind: 1 + required: 1 + socketFace: 0 + - id: endpoint_9c245891f5734102bc24edb0dcc2c9fd + stageId: stage_aae8c48d41d84a679c9c52acd567ff30 + worldTile: + x: 11 + y: 15 + kind: 1 + required: 1 + socketFace: 0 + - id: endpoint_0e3b0c016a474adb8fe8b05bdfd79f14 + stageId: stage_3e76ac8acd0447d59b0fbf1a65a25fb3 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + - id: endpoint_6f17b532985e4e908e70b3802eccf8cc + stageId: stage_78ca4be5151f4c60818dce81558ab162 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + - id: endpoint_ae66297a1c1a4c259e26ea8b51d69851 + stageId: stage_1a60b23d8b4c4622905831237c716081 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + - id: endpoint_263ea59d79614b9b85ed2bb4fe25c9ad + stageId: stage_a980c91d39bc470296289d3ef72636c0 + worldTile: + x: 18 + y: 10 + kind: 2 + required: 1 + socketFace: 0 + connections: + - id: connection_cdf62cf06a20426b88d3987ea8917967 + endpointAId: endpoint_adc34044a48447d0be41389ba398d5f5 + endpointBId: endpoint_181357103f5742008cfa73a544a56363 + directionality: 0 + - id: connection_3dabe3efe429439bbb49104bed37bb20 + endpointAId: endpoint_71bd3851ad2b4d20a90cf3b3f5ade5fb + endpointBId: endpoint_d9121bcc789443548c898106dd5f4c43 + directionality: 0 + - id: connection_ef34c049437c491b80364a034880300d + endpointAId: endpoint_dc223745e7994fbd880f1e93f146d8cf + endpointBId: endpoint_65e01fe22fe44222919c322ea8580a69 + directionality: 0 + - id: connection_2b7aa20f26bd48acb636a37ed1d20ad0 + endpointAId: endpoint_27a37615b51f40cdb39acfc9fbffce22 + endpointBId: endpoint_7cf95989e98a4de49edb7e2080e1c5e6 + directionality: 0 + - id: connection_d8da15ab563d4bb9ba6f0294cec60341 + endpointAId: endpoint_e6e40c3366564b96825ecf652721a46d + endpointBId: endpoint_34e27be82570488cbcd45531e56f128c + directionality: 0 + - id: connection_335b1c8a1f964b11b72ddf2ef053bd71 + endpointAId: endpoint_9c245891f5734102bc24edb0dcc2c9fd + endpointBId: endpoint_e6e40c3366564b96825ecf652721a46d + directionality: 0 + - id: connection_d0e5d7611be64e31a77bc6d24650a031 + endpointAId: endpoint_9c245891f5734102bc24edb0dcc2c9fd + endpointBId: endpoint_34e27be82570488cbcd45531e56f128c + directionality: 0 + - id: connection_12db74114bf54c8eb5b7038efcae4172 + endpointAId: endpoint_ae66297a1c1a4c259e26ea8b51d69851 + endpointBId: endpoint_0e3b0c016a474adb8fe8b05bdfd79f14 + directionality: 0 + - id: connection_274434096af24703a063627df527d688 + endpointAId: endpoint_263ea59d79614b9b85ed2bb4fe25c9ad + endpointBId: endpoint_0e3b0c016a474adb8fe8b05bdfd79f14 + directionality: 0 + - id: connection_22f10261be3b4a24ada8debf789f9e33 + endpointAId: endpoint_263ea59d79614b9b85ed2bb4fe25c9ad + endpointBId: endpoint_6f17b532985e4e908e70b3802eccf8cc + directionality: 0 diff --git a/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs b/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs index cde55de..bd287ef 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs @@ -55,6 +55,11 @@ namespace Editor.GON.WorldEditor worldLayer = source.worldLayer, occupiedTiles = new List(source.occupiedTiles) }; + if (source.stageKind == WorldStageKind.SavePoint) + { + stage.tags.Add(WorldLayoutSO.SavePointTag); + } + stages.Add(stage); } @@ -116,6 +121,11 @@ namespace Editor.GON.WorldEditor { builder.Append("S|").Append(stage.id).Append('|').Append(stage.displayName).Append('|') .Append(stage.worldLayer).AppendLine(); + foreach (var tag in stage.tags) + { + builder.Append("G|").Append(tag).AppendLine(); + } + foreach (var tile in stage.occupiedTiles) { builder.Append("T|").Append(tile.x).Append('|').Append(tile.y).AppendLine(); diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs index 4b67450..39ca6ee 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs @@ -8,8 +8,12 @@ namespace Editor.GON.WorldEditor public static readonly Color Grid = new(1f, 1f, 1f, 0.08f); public static readonly Color GridAxis = new(1f, 1f, 1f, 0.18f); public static readonly Color Selected = new(1f, 0.86f, 0.32f, 1f); - public static readonly Color Socket = new(0.95f, 0.95f, 0.95f, 1f); + public static readonly Color EndpointSelected = new(1f, 0.1f, 0.95f, 1f); + public static readonly Color Socket = new(0.45f, 0.45f, 0.45f, 1f); + public static readonly Color SocketPaired = new(1f, 0.86f, 0.32f, 1f); + public static readonly Color SocketConnected = new(0.35f, 0.95f, 0.45f, 1f); public static readonly Color Anchor = new(0.35f, 0.9f, 0.75f, 1f); public static readonly Color Lift = new(0.95f, 0.45f, 0.35f, 1f); + public static readonly Color LiftGhost = new(0.95f, 0.45f, 0.35f, 0.35f); } } diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs index 6ef1a41..1e64f38 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs @@ -1,5 +1,8 @@ namespace Editor.GON.WorldEditor { + using System; + using Intrepid.Gameplay.Journey; + public enum WorldEditorTool { Select = 0, @@ -14,6 +17,12 @@ namespace Editor.GON.WorldEditor { public int activeLayer; public string selectedStageId; + public string selectedEndpointId; + public string selectedConnectionId; public WorldEditorTool activeTool = WorldEditorTool.PaintStage; + public JWorldTile? hoverTile; + public string pendingAnchorEndpointId; + public WorldStageKind? pendingStagePlacementKind; + public WorldEditorTool toolBeforeStagePlacement; } } diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs index 1aeed34..4e45ffc 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs @@ -15,9 +15,11 @@ namespace Editor.GON.WorldEditor private JGraphSO graph; private WorldEditorState state; private VisualElement topBar; + private VisualElement layerBar; + private VisualElement toolBar; private WorldMapCanvas canvas; + private WorldInspectorPanel inspector; private Label infoLabel; - private ScrollView validationView; [MenuItem("Tools/Gardens Of Nil/World Editor")] public static void Open() @@ -51,9 +53,38 @@ namespace Editor.GON.WorldEditor }; rootVisualElement.Add(topBar); + var main = new VisualElement + { + style = + { + flexGrow = 1f, + flexDirection = FlexDirection.Row + } + }; + rootVisualElement.Add(main); + + layerBar = new VisualElement + { + style = + { + width = 74, + flexShrink = 0f, + paddingTop = 6, + paddingLeft = 6, + paddingRight = 6, + borderRightWidth = 1, + borderRightColor = new Color(1f, 1f, 1f, 0.12f) + } + }; + main.Add(layerBar); + canvas = new WorldMapCanvas(this, state); canvas.SetLayout(layout); - rootVisualElement.Add(canvas); + main.Add(canvas); + + inspector = new WorldInspectorPanel(this, state); + inspector.SetLayout(layout); + main.Add(inspector); infoLabel = new Label { @@ -66,18 +97,25 @@ namespace Editor.GON.WorldEditor }; rootVisualElement.Add(infoLabel); - validationView = new ScrollView + toolBar = new VisualElement { style = { - maxHeight = 130, + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + paddingLeft = 8, + paddingRight = 8, + paddingTop = 6, + paddingBottom = 6, borderTopWidth = 1, borderTopColor = new Color(1f, 1f, 1f, 0.12f) } }; - rootVisualElement.Add(validationView); + rootVisualElement.Add(toolBar); RebuildTopBar(); + RebuildLayerBar(); + RebuildToolBar(); RebuildInfo(); } @@ -88,18 +126,126 @@ namespace Editor.GON.WorldEditor return; } + var previousStageId = state.selectedStageId; + var previousEndpointId = state.selectedEndpointId; + var previousConnectionId = state.selectedConnectionId; Undo.RecordObject(layout, undoName); mutation(); layout.EnsureDefaults(); + layout.PruneEmptyStagesAndDanglingConnections(); + ClearInvalidSelection(); EditorUtility.SetDirty(layout); + + if (previousStageId != state.selectedStageId || + previousEndpointId != state.selectedEndpointId || + previousConnectionId != state.selectedConnectionId) + { + SyncActiveLayerToSelection(true); + } + canvas?.RequestRepaint(); RebuildTopBar(); + RebuildLayerBar(); + RebuildToolBar(); + inspector?.Rebuild(); RebuildInfo(); Repaint(); } public WorldLayoutSO Layout => layout; + public void SetHoverTile(JWorldTile? tile) + { + state.hoverTile = tile; + RebuildInfo(); + } + + public void RequestRebuildInfo() + { + RebuildInfo(); + } + + public void RequestSelectionRebuild() + { + SyncActiveLayerToSelection(true); + canvas?.RequestRepaint(); + RebuildLayerBar(); + inspector?.Rebuild(); + RebuildInfo(); + Repaint(); + } + + public void BeginStagePlacement(WorldStageKind stageKind) + { + if (state.pendingStagePlacementKind is null) + { + state.toolBeforeStagePlacement = state.activeTool; + } + + state.pendingStagePlacementKind = stageKind; + state.pendingAnchorEndpointId = null; + canvas?.RequestRepaint(); + RebuildTopBar(); + RebuildToolBar(); + RebuildInfo(); + Repaint(); + } + + public void CancelStagePlacement() + { + if (state.pendingStagePlacementKind is null) + { + return; + } + + state.pendingStagePlacementKind = null; + state.activeTool = state.toolBeforeStagePlacement; + canvas?.RequestRepaint(); + RebuildTopBar(); + RebuildToolBar(); + RebuildInfo(); + Repaint(); + } + + public void SelectStage(string stageId) + { + state.selectedStageId = stageId; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + RequestSelectionRebuild(); + } + + public void SelectEndpoint(string endpointId) + { + var endpoint = layout?.FindEndpoint(endpointId); + if (endpoint is null) + { + return; + } + + state.selectedStageId = endpoint.stageId; + state.selectedEndpointId = endpoint.id; + state.selectedConnectionId = null; + RequestSelectionRebuild(); + } + + public void SelectConnection(string connectionId) + { + var connection = FindConnection(connectionId); + if (connection is null) + { + return; + } + + var endpointA = layout.FindEndpoint(connection.endpointAId); + var endpointB = layout.FindEndpoint(connection.endpointBId); + var endpoint = PickConnectionEndpointForCurrentLayer(endpointA, endpointB); + state.selectedStageId = endpoint?.stageId; + state.selectedEndpointId = null; + state.selectedConnectionId = connection.id; + RequestSelectionRebuild(); + } + private void RebuildTopBar() { if (topBar is null) @@ -134,45 +280,6 @@ namespace Editor.GON.WorldEditor layout.EnsureDefaults(); - var layerField = new IntegerField("Layer") - { - value = state.activeLayer, - style = - { - marginLeft = 10, - width = 90 - } - }; - layerField.RegisterValueChangedCallback(evt => - { - state.activeLayer = evt.newValue; - canvas?.RequestRepaint(); - RebuildInfo(); - }); - topBar.Add(layerField); - - var toolField = new EnumField("Tool", state.activeTool) - { - style = - { - marginLeft = 8, - minWidth = 170 - } - }; - toolField.RegisterValueChangedCallback(evt => - { - state.activeTool = (WorldEditorTool)evt.newValue; - canvas?.RequestRepaint(); - RebuildInfo(); - }); - topBar.Add(toolField); - - topBar.Add(new Button(CreateStage) - { - text = "+ Stage", - style = { marginLeft = 8 } - }); - var graphField = new ObjectField("J Graph") { objectType = typeof(JGraphSO), @@ -217,6 +324,298 @@ namespace Editor.GON.WorldEditor }); } + private void RebuildLayerBar() + { + if (layerBar is null) + { + return; + } + + layerBar.Clear(); + + if (layout is null) + { + layerBar.Add(new Label("Layers")); + return; + } + + layout.EnsureDefaults(); + state.activeLayer = layout.ClampLayer(state.activeLayer); + + var title = new Label("Layers") + { + style = + { + unityFontStyleAndWeight = FontStyle.Bold, + marginBottom = 4 + } + }; + layerBar.Add(title); + + var topControls = new VisualElement + { + style = + { + flexDirection = FlexDirection.Row, + marginBottom = 6 + } + }; + var addTopLayer = new Button(delegate + { + MutateLayout("Add upper world layer", delegate + { + layout.maxWorldLayer += 1; + state.activeLayer = layout.maxWorldLayer; + }); + }) + { + text = "+", + style = + { + flexGrow = 1f, + marginRight = 2, + height = 24 + } + }; + topControls.Add(addTopLayer); + + var removeTopLayer = new Button(delegate + { + MutateLayout("Remove upper world layer", delegate + { + if (layout.maxWorldLayer > layout.minWorldLayer && !LayerHasStages(layout.maxWorldLayer)) + { + layout.maxWorldLayer -= 1; + state.activeLayer = layout.ClampLayer(state.activeLayer); + } + }); + }) + { + text = "-", + style = + { + flexGrow = 1f, + marginLeft = 2, + height = 24 + } + }; + removeTopLayer.SetEnabled(layout.maxWorldLayer > layout.minWorldLayer && !LayerHasStages(layout.maxWorldLayer)); + topControls.Add(removeTopLayer); + layerBar.Add(topControls); + + for (var layer = layout.maxWorldLayer; layer >= layout.minWorldLayer; layer--) + { + var capturedLayer = layer; + var button = new Button(delegate + { + state.activeLayer = capturedLayer; + canvas?.RequestRepaint(); + RebuildLayerBar(); + inspector?.Rebuild(); + RebuildInfo(); + }) + { + text = FormatLayer(capturedLayer), + style = + { + marginBottom = 3, + height = 26 + } + }; + + if (capturedLayer == state.activeLayer) + { + button.style.backgroundColor = new Color(1f, 0.86f, 0.32f, 0.45f); + button.style.color = Color.white; + button.style.unityFontStyleAndWeight = FontStyle.Bold; + } + + layerBar.Add(button); + } + + var bottomControls = new VisualElement + { + style = + { + flexDirection = FlexDirection.Row, + marginTop = 6 + } + }; + var addBottomLayer = new Button(delegate + { + MutateLayout("Add lower world layer", delegate + { + layout.minWorldLayer -= 1; + state.activeLayer = layout.minWorldLayer; + }); + }) + { + text = "+", + style = + { + flexGrow = 1f, + marginRight = 2, + height = 24 + } + }; + bottomControls.Add(addBottomLayer); + + var removeBottomLayer = new Button(delegate + { + MutateLayout("Remove lower world layer", delegate + { + if (layout.minWorldLayer < layout.maxWorldLayer && !LayerHasStages(layout.minWorldLayer)) + { + layout.minWorldLayer += 1; + state.activeLayer = layout.ClampLayer(state.activeLayer); + } + }); + }) + { + text = "-", + style = + { + flexGrow = 1f, + marginLeft = 2, + height = 24 + } + }; + removeBottomLayer.SetEnabled(layout.minWorldLayer < layout.maxWorldLayer && !LayerHasStages(layout.minWorldLayer)); + bottomControls.Add(removeBottomLayer); + layerBar.Add(bottomControls); + } + + private bool LayerHasStages(int layer) + { + if (layout is null) + { + return false; + } + + foreach (var stage in layout.stages) + { + if (stage != null && stage.worldLayer == layer) + { + return true; + } + } + + return false; + } + + private void RebuildToolBar() + { + if (toolBar is null) + { + return; + } + + toolBar.Clear(); + toolBar.Add(new Label("Units") + { + style = + { + unityFontStyleAndWeight = FontStyle.Bold, + marginRight = 8 + } + }); + + AddUnitButton(WorldStageKind.Stage, "+ Stage", "Placing Stage"); + AddUnitButton(WorldStageKind.SavePoint, "+ Save Point", "Placing Save"); + + var separator = new VisualElement + { + style = + { + width = 1, + height = 24, + marginLeft = 8, + marginRight = 8, + backgroundColor = new Color(1f, 1f, 1f, 0.18f) + } + }; + toolBar.Add(separator); + + toolBar.Add(new Label("Tools") + { + style = + { + unityFontStyleAndWeight = FontStyle.Bold, + marginRight = 8 + } + }); + + AddToolButton(WorldEditorTool.Select, "Select"); + AddToolButton(WorldEditorTool.PaintStage, "Paint"); + AddToolButton(WorldEditorTool.EraseStage, "Erase"); + AddToolButton(WorldEditorTool.Socket, "Socket"); + AddToolButton(WorldEditorTool.Anchor, "Anchor"); + AddToolButton(WorldEditorTool.Lift, "Lift"); + } + + private void AddUnitButton(WorldStageKind stageKind, string label, string placingLabel) + { + var button = new Button(delegate { BeginStagePlacement(stageKind); }) + { + text = state.pendingStagePlacementKind == stageKind ? placingLabel : label, + style = + { + marginRight = 4, + minWidth = stageKind == WorldStageKind.SavePoint ? 110 : 82, + height = 28 + } + }; + + if (state.pendingStagePlacementKind == stageKind) + { + button.style.backgroundColor = new Color(1f, 0.1f, 0.95f, 0.35f); + button.style.color = Color.white; + button.style.unityFontStyleAndWeight = FontStyle.Bold; + } + + toolBar.Add(button); + } + + private void AddToolButton(WorldEditorTool tool, string label) + { + var button = new Button(delegate + { + state.activeTool = tool; + canvas?.RequestRepaint(); + RebuildToolBar(); + inspector?.Rebuild(); + RebuildInfo(); + }) + { + text = label, + style = + { + marginRight = 4, + minWidth = 76, + height = 28 + } + }; + + if (state.activeTool == tool) + { + button.style.backgroundColor = new Color(1f, 0.86f, 0.32f, 0.45f); + button.style.color = Color.white; + button.style.unityFontStyleAndWeight = FontStyle.Bold; + } + + toolBar.Add(button); + } + + private static string FormatLayer(int layer) + { + if (layer > 0) + { + return "+" + layer; + } + + return layer.ToString(); + } + private void RebuildInfo() { if (infoLabel is null) @@ -231,25 +630,182 @@ namespace Editor.GON.WorldEditor } var selected = layout.FindStage(state.selectedStageId); - infoLabel.text = selected is null - ? "No stage selected." - : "Selected: " + selected.displayName + " (" + selected.id + ")"; + var selectedEndpoint = layout.FindEndpoint(state.selectedEndpointId); + var selectedConnection = FindConnection(state.selectedConnectionId); + var hoverText = state.hoverTile.HasValue + ? " | Tile: (" + state.hoverTile.Value.x + ", " + state.hoverTile.Value.y + ")" + : " | Tile: -"; + var anchorText = string.IsNullOrEmpty(state.pendingAnchorEndpointId) + ? string.Empty + : " | Anchor link armed"; + var placementText = state.pendingStagePlacementKind.HasValue + ? " | Placing " + state.pendingStagePlacementKind.Value + " (click to deploy, Esc to cancel)" + : string.Empty; + var selectionText = "No selection."; + if (selectedConnection is not null) + { + selectionText = "Selected connection: " + selectedConnection.id; + } + else if (selectedEndpoint is not null) + { + selectionText = "Selected " + selectedEndpoint.kind + ": " + selectedEndpoint.id; + } + else if (selected is not null) + { + selectionText = "Selected: " + selected.displayName + " (" + selected.id + ")"; + } + + infoLabel.text = selectionText + hoverText + anchorText + placementText; } private void SetLayout(WorldLayoutSO newLayout) { layout = newLayout; layout?.EnsureDefaults(); + if (layout is not null) + { + state.activeLayer = layout.ClampLayer(state.activeLayer); + } + if (layout?.FindStage(state.selectedStageId) is null) { state.selectedStageId = null; } + if (layout?.FindEndpoint(state.selectedEndpointId) is null) + { + state.selectedEndpointId = null; + } + + if (FindConnection(state.selectedConnectionId) is null) + { + state.selectedConnectionId = null; + } + canvas?.SetLayout(layout); + inspector?.SetLayout(layout); RebuildTopBar(); + RebuildLayerBar(); + inspector?.Rebuild(); RebuildInfo(); } + private void ClearInvalidSelection() + { + if (layout is null) + { + state.selectedStageId = null; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + return; + } + + if (layout.FindStage(state.selectedStageId) is null) + { + state.selectedStageId = null; + } + + if (layout.FindEndpoint(state.selectedEndpointId) is null) + { + state.selectedEndpointId = null; + } + + if (FindConnection(state.selectedConnectionId) is null) + { + state.selectedConnectionId = null; + } + } + + private void SyncActiveLayerToSelection(bool frameWhenLayerChanges) + { + if (layout is null) + { + return; + } + + layout.EnsureDefaults(); + var previousLayer = state.activeLayer; + + var selectedEndpoint = layout.FindEndpoint(state.selectedEndpointId); + if (selectedEndpoint is not null) + { + var endpointStage = layout.FindStage(selectedEndpoint.stageId); + if (endpointStage is not null) + { + state.activeLayer = layout.ClampLayer(endpointStage.worldLayer); + FrameSelectionIfLayerChanged(previousLayer, frameWhenLayerChanges); + return; + } + } + + var selectedConnection = FindConnection(state.selectedConnectionId); + if (selectedConnection is not null) + { + var endpointA = layout.FindEndpoint(selectedConnection.endpointAId); + var endpointB = layout.FindEndpoint(selectedConnection.endpointBId); + var endpoint = PickConnectionEndpointForSelectedStage(endpointA, endpointB) ?? + PickConnectionEndpointForCurrentLayer(endpointA, endpointB); + var endpointStage = endpoint is null ? null : layout.FindStage(endpoint.stageId); + if (endpointStage is not null) + { + state.activeLayer = layout.ClampLayer(endpointStage.worldLayer); + FrameSelectionIfLayerChanged(previousLayer, frameWhenLayerChanges); + return; + } + } + + var selectedStage = layout.FindStage(state.selectedStageId); + if (selectedStage is not null) + { + state.activeLayer = layout.ClampLayer(selectedStage.worldLayer); + FrameSelectionIfLayerChanged(previousLayer, frameWhenLayerChanges); + } + } + + private void FrameSelectionIfLayerChanged(int previousLayer, bool frameWhenLayerChanges) + { + if (frameWhenLayerChanges && previousLayer != state.activeLayer) + { + canvas?.FrameSelection(); + } + } + + private WorldEndpointAuthoring PickConnectionEndpointForSelectedStage( + WorldEndpointAuthoring endpointA, + WorldEndpointAuthoring endpointB) + { + if (endpointA is not null && endpointA.stageId == state.selectedStageId) + { + return endpointA; + } + + if (endpointB is not null && endpointB.stageId == state.selectedStageId) + { + return endpointB; + } + + return null; + } + + private WorldEndpointAuthoring PickConnectionEndpointForCurrentLayer( + WorldEndpointAuthoring endpointA, + WorldEndpointAuthoring endpointB) + { + var stageA = endpointA is null ? null : layout.FindStage(endpointA.stageId); + if (stageA is not null && stageA.worldLayer == state.activeLayer) + { + return endpointA; + } + + var stageB = endpointB is null ? null : layout.FindStage(endpointB.stageId); + if (stageB is not null && stageB.worldLayer == state.activeLayer) + { + return endpointB; + } + + return endpointA ?? endpointB; + } + private void CreateNewLayoutAsset() { var path = EditorUtility.SaveFilePanelInProject( @@ -292,19 +848,31 @@ namespace Editor.GON.WorldEditor private void CreateStage() { - MutateLayout("Create world stage", delegate + BeginStagePlacement(WorldStageKind.Stage); + } + + private void CreateSavePoint() + { + BeginStagePlacement(WorldStageKind.SavePoint); + } + + private WorldConnectionAuthoring FindConnection(string connectionId) + { + if (layout is null || string.IsNullOrEmpty(connectionId)) { - var stage = new WorldStageAuthoring + return null; + } + + layout.EnsureDefaults(); + foreach (var connection in layout.connections) + { + if (connection != null && connection.id == connectionId) { - id = layout.CreateId("stage"), - displayName = "Stage " + (layout.stages.Count + 1), - worldLayer = state.activeLayer, - color = Random.ColorHSV(0f, 1f, 0.35f, 0.75f, 0.55f, 0.85f, 0.75f, 0.9f) - }; - stage.AddTile(new JWorldTile(0, 0)); - layout.stages.Add(stage); - state.selectedStageId = stage.id; - }); + return connection; + } + } + + return null; } private void SaveLayout() @@ -321,6 +889,10 @@ namespace Editor.GON.WorldEditor private void ValidateLayout() { + MutateLayout("Repair world layout before validation", delegate + { + layout.PruneEmptyStagesAndDanglingConnections(); + }); ShowValidationMessages(WorldLayoutValidator.Validate(layout)); } @@ -336,15 +908,9 @@ namespace Editor.GON.WorldEditor private void ShowValidationMessages(List messages) { - validationView?.Clear(); - if (validationView is null) - { - return; - } - if (messages is null || messages.Count == 0) { - validationView.Add(new Label("Validation passed.")); + Debug.Log("World Editor validation passed."); return; } @@ -355,21 +921,22 @@ namespace Editor.GON.WorldEditor continue; } - var label = new Label(message.severity + ": " + message.message + - (string.IsNullOrEmpty(message.objectId) ? string.Empty : " [" + message.objectId + "]")) - { - style = - { - color = message.severity == WorldValidationSeverity.Error - ? new Color(1f, 0.45f, 0.4f) - : new Color(1f, 0.82f, 0.35f), - paddingLeft = 8, - paddingTop = 2, - paddingBottom = 2 - } - }; - validationView.Add(label); + LogValidationMessage(message); } } + + private static void LogValidationMessage(WorldValidationMessage message) + { + var text = "World Editor " + message.severity + ": " + message.message + + (string.IsNullOrEmpty(message.objectId) ? string.Empty : " [" + message.objectId + "]"); + + if (message.severity == WorldValidationSeverity.Error) + { + Debug.LogError(text); + return; + } + + Debug.LogWarning(text); + } } } diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldInspectorPanel.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldInspectorPanel.cs new file mode 100644 index 0000000..181f612 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldInspectorPanel.cs @@ -0,0 +1,308 @@ +using System.Collections.Generic; +using Editor.GON.StageEditor; +using Intrepid.Gameplay.Journey; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Editor.GON.WorldEditor +{ + public sealed class WorldInspectorPanel : VisualElement + { + private readonly WorldEditorWindow owner; + private readonly WorldEditorState state; + private WorldLayoutSO worldLayout; + + public WorldInspectorPanel(WorldEditorWindow owner, WorldEditorState state) + { + this.owner = owner; + this.state = state; + + style.width = 300; + style.minWidth = 260; + style.maxWidth = 380; + style.paddingLeft = 8; + style.paddingRight = 8; + style.paddingTop = 8; + style.paddingBottom = 8; + style.borderLeftWidth = 1; + style.borderLeftColor = new Color(1f, 1f, 1f, 0.12f); + style.flexShrink = 0; + } + + public void SetLayout(WorldLayoutSO newLayout) + { + worldLayout = newLayout; + Rebuild(); + } + + public void Rebuild() + { + Clear(); + + Add(new Label("Inspector") + { + style = + { + unityFontStyleAndWeight = FontStyle.Bold, + marginBottom = 8 + } + }); + + if (worldLayout is null) + { + Add(new HelpBox("Select or create a WorldLayoutSO asset.", HelpBoxMessageType.Info)); + return; + } + + worldLayout.EnsureDefaults(); + + var selectedConnection = FindConnection(state.selectedConnectionId); + if (selectedConnection is not null) + { + BuildConnectionInspector(selectedConnection); + return; + } + + var selectedEndpoint = worldLayout.FindEndpoint(state.selectedEndpointId); + if (selectedEndpoint is not null) + { + BuildEndpointInspector(selectedEndpoint); + return; + } + + var selectedStage = worldLayout.FindStage(state.selectedStageId); + if (selectedStage is not null) + { + BuildStageInspector(selectedStage); + return; + } + + Add(new HelpBox("No world item selected.", HelpBoxMessageType.Info)); + } + + private void BuildStageInspector(WorldStageAuthoring stage) + { + AddSection("Stage"); + AddReadOnlyLine("Id", stage.id); + AddReadOnlyLine("Kind", stage.stageKind.ToString()); + AddReadOnlyLine("Layer", FormatLayer(stage.worldLayer)); + AddReadOnlyLine("Tiles", stage.occupiedTiles.Count.ToString()); + AddReadOnlyLine("Endpoints", CountStageEndpoints(stage.id).ToString()); + + var kind = new EnumField("Kind", stage.stageKind); + kind.RegisterValueChangedCallback(evt => owner.MutateLayout("Change world stage kind", delegate + { + stage.stageKind = (WorldStageKind)evt.newValue; + worldLayout.EnforceSavePointRules(stage); + })); + Add(kind); + + var name = new TextField("Name") + { + value = stage.displayName + }; + name.RegisterValueChangedCallback(evt => owner.MutateLayout("Rename world stage", delegate + { + stage.displayName = string.IsNullOrWhiteSpace(evt.newValue) ? "Stage" : evt.newValue; + })); + Add(name); + + var color = new ColorField("Color") + { + value = stage.color + }; + color.RegisterValueChangedCallback(evt => owner.MutateLayout("Change world stage color", delegate + { + stage.color = evt.newValue; + })); + Add(color); + + var definition = new ObjectField("Stage Definition") + { + objectType = typeof(StageDefinition), + value = stage.stageDefinition + }; + definition.RegisterValueChangedCallback(evt => owner.MutateLayout("Assign stage definition", delegate + { + stage.stageDefinition = evt.newValue as StageDefinition; + })); + Add(definition); + } + + private void BuildEndpointInspector(WorldEndpointAuthoring endpoint) + { + var stage = worldLayout.FindStage(endpoint.stageId); + + AddSection(endpoint.kind.ToString()); + AddReadOnlyLine("Id", endpoint.id); + AddReadOnlyLine("Stage", stage is null ? endpoint.stageId : stage.displayName); + AddReadOnlyLine("Layer", stage is null ? "-" : FormatLayer(stage.worldLayer)); + AddReadOnlyLine("Tile", "(" + endpoint.worldTile.x + ", " + endpoint.worldTile.y + ")"); + + if (endpoint.kind == JEndpointKind.Socket) + { + AddReadOnlyLine("Face", endpoint.socketFace.ToString()); + } + + var required = new Toggle("Required") + { + value = endpoint.required + }; + required.RegisterValueChangedCallback(evt => owner.MutateLayout("Toggle endpoint required", delegate + { + endpoint.required = evt.newValue; + })); + Add(required); + + var connections = FindConnectionsForEndpoint(endpoint.id); + AddReadOnlyLine("Connections", connections.Count.ToString()); + foreach (var connection in connections) + { + AddConnectionButton(connection); + } + } + + private void BuildConnectionInspector(WorldConnectionAuthoring connection) + { + AddSection("JConnection"); + AddReadOnlyLine("Id", connection.id); + + var endpointA = worldLayout.FindEndpoint(connection.endpointAId); + var endpointB = worldLayout.FindEndpoint(connection.endpointBId); + AddEndpointButton("Endpoint A", endpointA, connection.endpointAId); + AddEndpointButton("Endpoint B", endpointB, connection.endpointBId); + + var directionality = new EnumField("Direction", connection.directionality); + directionality.RegisterValueChangedCallback(evt => owner.MutateLayout("Change connection direction", delegate + { + connection.directionality = (JConnectionDirectionality)evt.newValue; + })); + Add(directionality); + } + + private void AddConnectionButton(WorldConnectionAuthoring connection) + { + var button = new Button(delegate + { + owner.SelectConnection(connection.id); + }) + { + text = "Connection: " + connection.id, + style = + { + marginTop = 4, + unityTextAlign = TextAnchor.MiddleLeft + } + }; + Add(button); + } + + private void AddEndpointButton(string label, WorldEndpointAuthoring endpoint, string fallbackId) + { + var text = endpoint is null + ? fallbackId + " (missing)" + : endpoint.kind + " " + endpoint.id; + var button = new Button(delegate + { + if (endpoint is null) + { + return; + } + + owner.SelectEndpoint(endpoint.id); + }) + { + text = label + ": " + text, + style = + { + marginTop = 4, + unityTextAlign = TextAnchor.MiddleLeft + } + }; + button.SetEnabled(endpoint is not null); + Add(button); + } + + private List FindConnectionsForEndpoint(string endpointId) + { + var result = new List(); + foreach (var connection in worldLayout.connections) + { + if (connection == null) + { + continue; + } + + if (connection.endpointAId == endpointId || connection.endpointBId == endpointId) + { + result.Add(connection); + } + } + + return result; + } + + private WorldConnectionAuthoring FindConnection(string connectionId) + { + if (string.IsNullOrEmpty(connectionId)) + { + return null; + } + + foreach (var connection in worldLayout.connections) + { + if (connection != null && connection.id == connectionId) + { + return connection; + } + } + + return null; + } + + private int CountStageEndpoints(string stageId) + { + var count = 0; + foreach (var endpoint in worldLayout.endpoints) + { + if (endpoint != null && endpoint.stageId == stageId) + { + count++; + } + } + + return count; + } + + private void AddSection(string text) + { + Add(new Label(text) + { + style = + { + unityFontStyleAndWeight = FontStyle.Bold, + marginTop = 4, + marginBottom = 6 + } + }); + } + + private void AddReadOnlyLine(string label, string value) + { + Add(new Label(label + ": " + value) + { + style = + { + marginBottom = 3, + opacity = 0.85f + } + }); + } + + private static string FormatLayer(int layer) + { + return layer > 0 ? "+" + layer : layer.ToString(); + } + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldInspectorPanel.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldInspectorPanel.cs.meta new file mode 100644 index 0000000..abfa7dc --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldInspectorPanel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2cf93c2a33314ae690dec73cb9b210e9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs index 0857a2b..9106c66 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs @@ -6,12 +6,21 @@ using UnityEngine; namespace Editor.GON.WorldEditor { + public enum WorldStageKind + { + Stage = 0, + SavePoint = 1 + } + [CreateAssetMenu(menuName = "GON/Editor/World Layout", fileName = "WorldLayout")] public sealed class WorldLayoutSO : ScriptableObject { public string id; public float worldTileSize = 24f; public float worldLayerHeight = 24f; + public const string SavePointTag = "SavePoint"; + public int minWorldLayer = -2; + public int maxWorldLayer = 2; public List stages = new(); public List endpoints = new(); public List connections = new(); @@ -33,6 +42,11 @@ namespace Editor.GON.WorldEditor worldLayerHeight = 24f; } + if (minWorldLayer > maxWorldLayer) + { + (minWorldLayer, maxWorldLayer) = (maxWorldLayer, minWorldLayer); + } + stages ??= new List(); endpoints ??= new List(); connections ??= new List(); @@ -43,6 +57,12 @@ namespace Editor.GON.WorldEditor } } + public int ClampLayer(int layer) + { + EnsureDefaults(); + return Mathf.Clamp(layer, minWorldLayer, maxWorldLayer); + } + public string CreateId(string prefix) { return prefix + "_" + Guid.NewGuid().ToString("N"); @@ -110,6 +130,214 @@ namespace Editor.GON.WorldEditor return null; } + + public WorldEndpointAuthoring FindSocketAt(string stageId, JWorldTile tile, JSocketFace face) + { + EnsureDefaults(); + foreach (var endpoint in endpoints) + { + if (endpoint != null && + endpoint.stageId == stageId && + endpoint.kind == JEndpointKind.Socket && + endpoint.socketFace == face && + endpoint.worldTile.x == tile.x && + endpoint.worldTile.y == tile.y) + { + return endpoint; + } + } + + return null; + } + + public WorldConnectionAuthoring FindConnectionBetween(string endpointAId, string endpointBId) + { + EnsureDefaults(); + foreach (var connection in connections) + { + if (connection == null) + { + continue; + } + + if ((connection.endpointAId == endpointAId && connection.endpointBId == endpointBId) || + (connection.endpointAId == endpointBId && connection.endpointBId == endpointAId)) + { + return connection; + } + } + + return null; + } + + public void RemoveEndpointAndConnections(string endpointId) + { + if (string.IsNullOrEmpty(endpointId)) + { + return; + } + + EnsureDefaults(); + for (var i = endpoints.Count - 1; i >= 0; i--) + { + if (endpoints[i] != null && endpoints[i].id == endpointId) + { + endpoints.RemoveAt(i); + } + } + + for (var i = connections.Count - 1; i >= 0; i--) + { + var connection = connections[i]; + if (connection == null || + connection.endpointAId == endpointId || + connection.endpointBId == endpointId) + { + connections.RemoveAt(i); + } + } + } + + public void RemoveStageAndOwnedData(string stageId) + { + if (string.IsNullOrEmpty(stageId)) + { + return; + } + + EnsureDefaults(); + var endpointIds = new List(); + foreach (var endpoint in endpoints) + { + if (endpoint != null && endpoint.stageId == stageId) + { + endpointIds.Add(endpoint.id); + } + } + + foreach (var endpointId in endpointIds) + { + RemoveEndpointAndConnections(endpointId); + } + + for (var i = stages.Count - 1; i >= 0; i--) + { + if (stages[i] != null && stages[i].id == stageId) + { + stages.RemoveAt(i); + } + } + } + + public void RemoveEndpointsAtTile(string stageId, JWorldTile tile) + { + if (string.IsNullOrEmpty(stageId)) + { + return; + } + + EnsureDefaults(); + var endpointIds = new List(); + foreach (var endpoint in endpoints) + { + if (endpoint != null && + endpoint.stageId == stageId && + endpoint.worldTile.x == tile.x && + endpoint.worldTile.y == tile.y) + { + endpointIds.Add(endpoint.id); + } + } + + foreach (var endpointId in endpointIds) + { + RemoveEndpointAndConnections(endpointId); + } + } + + public bool PruneEmptyStagesAndDanglingConnections() + { + EnsureDefaults(); + var changed = false; + for (var i = stages.Count - 1; i >= 0; i--) + { + var stage = stages[i]; + stage?.EnsureDefaults(); + if (stage == null || stage.occupiedTiles.Count > 0) + { + continue; + } + + RemoveStageAndOwnedData(stage.id); + changed = true; + } + + for (var i = endpoints.Count - 1; i >= 0; i--) + { + var endpoint = endpoints[i]; + var stage = endpoint == null ? null : FindStage(endpoint.stageId); + if (endpoint == null || stage == null || !stage.ContainsTile(endpoint.worldTile)) + { + if (endpoint != null) + { + RemoveEndpointAndConnections(endpoint.id); + } + else + { + endpoints.RemoveAt(i); + } + + changed = true; + } + } + + for (var i = connections.Count - 1; i >= 0; i--) + { + var connection = connections[i]; + if (connection == null || + FindEndpoint(connection.endpointAId) == null || + FindEndpoint(connection.endpointBId) == null) + { + connections.RemoveAt(i); + changed = true; + } + } + + return changed; + } + + public void EnforceSavePointRules(WorldStageAuthoring stage) + { + if (stage is null || stage.stageKind != WorldStageKind.SavePoint) + { + return; + } + + stage.EnsureDefaults(); + if (stage.occupiedTiles.Count == 0) + { + stage.occupiedTiles.Add(new JWorldTile(0, 0)); + } + + var keptTile = stage.occupiedTiles[0]; + stage.occupiedTiles.Clear(); + stage.occupiedTiles.Add(keptTile); + + for (var i = endpoints.Count - 1; i >= 0; i--) + { + var endpoint = endpoints[i]; + if (endpoint == null || endpoint.stageId != stage.id) + { + continue; + } + + var outsideSaveTile = endpoint.worldTile.x != keptTile.x || endpoint.worldTile.y != keptTile.y; + if (outsideSaveTile || endpoint.kind is JEndpointKind.Anchor or JEndpointKind.Lift) + { + RemoveEndpointAndConnections(endpoint.id); + } + } + } } [Serializable] @@ -117,6 +345,7 @@ namespace Editor.GON.WorldEditor { public string id; public string displayName; + public WorldStageKind stageKind; public int worldLayer; public Color color = new(0.25f, 0.62f, 0.95f, 0.7f); public StageDefinition stageDefinition; diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs index 674dcb5..10d8fba 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Intrepid.Gameplay.Journey; +using UnityEngine; namespace Editor.GON.WorldEditor { @@ -65,6 +66,12 @@ namespace Editor.GON.WorldEditor "Stage has no Journey endpoints.", stage.id)); } + if (stage.stageKind == WorldStageKind.SavePoint && stage.occupiedTiles.Count != 1) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "Save Point stages must occupy exactly one WorldTile.", stage.id)); + } + foreach (var tile in stage.occupiedTiles) { var key = TileKey(stage.worldLayer, tile); @@ -107,6 +114,13 @@ namespace Editor.GON.WorldEditor "JEndpoint is placed on a WorldTile not occupied by its Stage.", endpoint.id)); } + if (stage.stageKind == WorldStageKind.SavePoint && + endpoint.kind is JEndpointKind.Anchor or JEndpointKind.Lift) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "Save Point stages cannot contain JEndpointAnchor or JEndpointLift.", endpoint.id)); + } + if (endpoint.kind == JEndpointKind.Socket) { var socketKey = endpoint.stageId + ":" + endpoint.worldTile.x + ":" + endpoint.worldTile.y + ":" + @@ -192,9 +206,9 @@ namespace Editor.GON.WorldEditor messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, "JEndpointSocket has more than one JConnection.", endpoint.id)); break; - case JEndpointKind.Lift when count > 1: + case JEndpointKind.Lift when count > 2: messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, - "JEndpointLift has more than one JConnection.", endpoint.id)); + "JEndpointLift has more than two JConnections.", endpoint.id)); break; } @@ -239,10 +253,11 @@ namespace Editor.GON.WorldEditor connection.id)); } - if (stageA != null && stageB != null && stageA.worldLayer == stageB.worldLayer) + if (stageA != null && stageB != null && + Mathf.Abs(stageA.worldLayer - stageB.worldLayer) != 1) { messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, - "JEndpointLift connections must connect different WorldLayers.", connection.id)); + "JEndpointLift connections must connect adjacent WorldLayers.", connection.id)); } } } diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs index 0f58722..1fc37f7 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Intrepid.Gameplay.Journey; using UnityEngine; using UnityEngine.UIElements; @@ -16,8 +17,17 @@ namespace Editor.GON.WorldEditor private Vector2 pan = new(60f, 60f); private float cellPixels = 36f; private bool isPanning; + private bool isPaintingStage; + private bool isMovingStage; + private bool hasHoverMouse; + private Vector2 hoverMousePosition; private Vector2 panStartMouse; private Vector2 panStartValue; + private JWorldTile paintStartTile; + private JWorldTile paintCurrentTile; + private JWorldTile moveStartTile; + private JWorldTile moveCurrentTile; + private string movingStageId; public WorldMapCanvas(WorldEditorWindow owner, WorldEditorState state) { @@ -33,7 +43,9 @@ namespace Editor.GON.WorldEditor RegisterCallback(OnMouseDown); RegisterCallback(OnMouseMove); RegisterCallback(OnMouseUp); + RegisterCallback(OnMouseLeave); RegisterCallback(OnWheel); + RegisterCallback(OnKeyDown); } public void SetLayout(WorldLayoutSO newLayout) @@ -48,9 +60,51 @@ namespace Editor.GON.WorldEditor MarkDirtyRepaint(); } + public void FrameSelection() + { + if (worldLayout is null) + { + return; + } + + if (!string.IsNullOrEmpty(state.selectedEndpointId)) + { + var endpoint = worldLayout.FindEndpoint(state.selectedEndpointId); + if (endpoint is not null && TryGetEndpointGridCenter(endpoint, out var endpointCenter)) + { + FrameGridCenter(endpointCenter); + } + + return; + } + + if (!string.IsNullOrEmpty(state.selectedConnectionId)) + { + if (TryGetConnectionGridCenter(state.selectedConnectionId, out var connectionCenter)) + { + FrameGridCenter(connectionCenter); + } + + return; + } + + var stage = worldLayout.FindStage(state.selectedStageId); + if (stage is not null && TryGetStageGridCenter(stage, out var stageCenter)) + { + FrameGridCenter(stageCenter); + } + } + private void OnMouseDown(MouseDownEvent evt) { Focus(); + if (state.pendingStagePlacementKind.HasValue && (evt.button == 1 || evt.button == 2)) + { + owner.CancelStagePlacement(); + evt.StopPropagation(); + return; + } + if (evt.button == 1 || evt.button == 2) { isPanning = true; @@ -71,12 +125,214 @@ namespace Editor.GON.WorldEditor return; } + if (state.pendingStagePlacementKind.HasValue) + { + DeployPendingStage(tile); + evt.StopPropagation(); + return; + } + + if (state.activeTool == WorldEditorTool.Anchor && !string.IsNullOrEmpty(state.pendingAnchorEndpointId)) + { + ApplyTool(tile, evt.localMousePosition); + evt.StopPropagation(); + return; + } + + if (state.activeTool == WorldEditorTool.Select && TrySelectEndpointAtMouse(tile, evt.localMousePosition)) + { + evt.StopPropagation(); + return; + } + + if (SelectStageOnFirstClick(tile)) + { + evt.StopPropagation(); + return; + } + + if (state.activeTool is WorldEditorTool.PaintStage or WorldEditorTool.EraseStage) + { + BeginStagePaint(tile); + evt.StopPropagation(); + return; + } + + if (state.activeTool == WorldEditorTool.Select && TryBeginStageMove(tile)) + { + evt.StopPropagation(); + return; + } + ApplyTool(tile, evt.localMousePosition); evt.StopPropagation(); } + private void OnKeyDown(KeyDownEvent evt) + { + if (evt.keyCode != KeyCode.Escape || state.pendingStagePlacementKind is null) + { + return; + } + + owner.CancelStagePlacement(); + evt.StopPropagation(); + } + + private bool SelectStageOnFirstClick(JWorldTile tile) + { + var stageAtTile = worldLayout.FindStageAt(state.activeLayer, tile); + if (stageAtTile is null || state.selectedStageId == stageAtTile.id) + { + return false; + } + + state.selectedStageId = stageAtTile.id; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + owner.RequestSelectionRebuild(); + MarkDirtyRepaint(); + return true; + } + + private bool TrySelectEndpointAtMouse(JWorldTile tile, Vector2 mouse) + { + if (TryFindEndpointAtTile(tile, JEndpointKind.Anchor, out var anchor) && + Vector2.Distance(mouse, TileRect(tile).center) <= Mathf.Max(8f, cellPixels * 0.24f)) + { + SelectEndpoint(anchor); + return true; + } + + if (TryFindEndpointAtTile(tile, JEndpointKind.Lift, out var lift) && + Vector2.Distance(mouse, TileRect(tile).center) <= Mathf.Max(10f, cellPixels * 0.32f)) + { + SelectEndpoint(lift); + return true; + } + + var stageAtTile = worldLayout.FindStageAt(state.activeLayer, tile); + if (stageAtTile is null) + { + return false; + } + + var face = GetNearestFace(mouse, tile); + if (DistanceToFace(mouse, TileRect(tile), face) > Mathf.Max(8f, cellPixels * 0.25f)) + { + return false; + } + + var socket = worldLayout.FindSocketAt(stageAtTile.id, tile, face); + if (socket is null) + { + return false; + } + + SelectEndpoint(socket); + return true; + } + + private void SelectEndpoint(WorldEndpointAuthoring endpoint) + { + owner.SelectEndpoint(endpoint.id); + } + + private void DeployPendingStage(JWorldTile tile) + { + if (worldLayout is null || state.pendingStagePlacementKind is null) + { + return; + } + + var stageKind = state.pendingStagePlacementKind.Value; + var occupyingStage = worldLayout.FindStageAt(state.activeLayer, tile); + if (stageKind == WorldStageKind.Stage && occupyingStage is not null) + { + MarkDirtyRepaint(); + return; + } + + owner.MutateLayout(stageKind == WorldStageKind.SavePoint ? "Place save point" : "Place world stage", delegate + { + if (stageKind == WorldStageKind.SavePoint) + { + RemoveOccupiedTileForSavePoint(tile); + } + + var isSavePoint = stageKind == WorldStageKind.SavePoint; + var stage = new WorldStageAuthoring + { + id = worldLayout.CreateId("stage"), + displayName = isSavePoint + ? "Save Point " + (worldLayout.stages.Count + 1) + : "Stage " + (worldLayout.stages.Count + 1), + stageKind = stageKind, + worldLayer = state.activeLayer, + color = isSavePoint + ? new Color(0.95f, 0.25f, 0.85f, 0.82f) + : Random.ColorHSV(0f, 1f, 0.35f, 0.75f, 0.55f, 0.85f, 0.75f, 0.9f) + }; + stage.AddTile(tile); + worldLayout.stages.Add(stage); + worldLayout.EnforceSavePointRules(stage); + + state.selectedStageId = stage.id; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + state.pendingStagePlacementKind = null; + state.activeTool = state.toolBeforeStagePlacement; + }); + + MarkDirtyRepaint(); + } + + private void RemoveOccupiedTileForSavePoint(JWorldTile tile) + { + var occupyingStage = worldLayout.FindStageAt(state.activeLayer, tile); + if (occupyingStage is null) + { + return; + } + + worldLayout.RemoveEndpointsAtTile(occupyingStage.id, tile); + occupyingStage.RemoveTile(tile); + if (occupyingStage.occupiedTiles.Count == 0) + { + worldLayout.RemoveStageAndOwnedData(occupyingStage.id); + } + } + private void OnMouseMove(MouseMoveEvent evt) { + hasHoverMouse = true; + hoverMousePosition = evt.localMousePosition; + UpdateHoverTile(evt.localMousePosition); + + if (isPaintingStage) + { + if (TryScreenToTile(evt.localMousePosition, out var tile)) + { + paintCurrentTile = tile; + MarkDirtyRepaint(); + } + + evt.StopPropagation(); + return; + } + + if (isMovingStage) + { + if (TryScreenToTile(evt.localMousePosition, out var tile)) + { + moveCurrentTile = tile; + MarkDirtyRepaint(); + } + + evt.StopPropagation(); + return; + } + if (!isPanning) { return; @@ -87,24 +343,54 @@ namespace Editor.GON.WorldEditor evt.StopPropagation(); } + private void OnMouseLeave(MouseLeaveEvent evt) + { + hasHoverMouse = false; + owner.SetHoverTile(null); + MarkDirtyRepaint(); + } + private void OnMouseUp(MouseUpEvent evt) { + if (isPaintingStage) + { + if (TryScreenToTile(evt.localMousePosition, out var tile)) + { + paintCurrentTile = tile; + } + + ApplyStagePaint(); + EndPointerCapture(); + evt.StopPropagation(); + return; + } + + if (isMovingStage) + { + if (TryScreenToTile(evt.localMousePosition, out var tile)) + { + moveCurrentTile = tile; + } + + ApplyStageMove(); + EndPointerCapture(); + evt.StopPropagation(); + return; + } + if (!isPanning) { return; } isPanning = false; - if (this.HasMouseCapture()) - { - this.ReleaseMouse(); - } - + EndPointerCapture(); evt.StopPropagation(); } private void OnWheel(WheelEvent evt) { + UpdateHoverTile(evt.localMousePosition); var mouse = evt.localMousePosition; var before = ScreenToGridFloat(mouse); var delta = -evt.delta.y * 0.08f; @@ -115,13 +401,64 @@ namespace Editor.GON.WorldEditor MarkDirtyRepaint(); } + private bool TryBeginStageMove(JWorldTile tile) + { + var stageAtTile = worldLayout.FindStageAt(state.activeLayer, tile); + if (stageAtTile is null) + { + state.selectedStageId = null; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + owner.RequestSelectionRebuild(); + return false; + } + + if (state.selectedStageId != stageAtTile.id) + { + state.selectedStageId = stageAtTile.id; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + owner.RequestSelectionRebuild(); + MarkDirtyRepaint(); + return false; + } + + state.selectedStageId = stageAtTile.id; + movingStageId = stageAtTile.id; + moveStartTile = tile; + moveCurrentTile = tile; + isMovingStage = true; + this.CaptureMouse(); + state.selectedEndpointId = null; + state.selectedConnectionId = null; + owner.RequestSelectionRebuild(); + MarkDirtyRepaint(); + return true; + } + + private void UpdateHoverTile(Vector2 mouse) + { + if (TryScreenToTile(mouse, out var tile)) + { + owner.SetHoverTile(tile); + } + } + private void ApplyTool(JWorldTile tile, Vector2 mouse) { var stageAtTile = worldLayout.FindStageAt(state.activeLayer, tile); if (state.activeTool == WorldEditorTool.Select) { state.selectedStageId = stageAtTile?.id; - owner.MutateLayout("Select world stage", delegate { }); + state.selectedEndpointId = null; + state.selectedConnectionId = null; + owner.RequestSelectionRebuild(); + return; + } + + if (state.activeTool == WorldEditorTool.Anchor && !string.IsNullOrEmpty(state.pendingAnchorEndpointId)) + { + ConnectPendingAnchorAtTile(tile); return; } @@ -148,17 +485,630 @@ namespace Editor.GON.WorldEditor owner.MutateLayout("Erase world tile", delegate { selectedStage.RemoveTile(tile); }); break; case WorldEditorTool.Anchor: - AddEndpoint(selectedStage, tile, JEndpointKind.Anchor, JSocketFace.North); + AddOrConnectAnchor(selectedStage, tile); break; case WorldEditorTool.Lift: - AddEndpoint(selectedStage, tile, JEndpointKind.Lift, JSocketFace.North); + AddOrConnectLift(selectedStage, tile); break; case WorldEditorTool.Socket: - AddEndpoint(selectedStage, tile, JEndpointKind.Socket, GetNearestFace(mouse, tile)); + AddOrConnectSocket(tile, GetNearestFace(mouse, tile)); break; } } + private void AddOrConnectLift(WorldStageAuthoring stage, JWorldTile tile) + { + if (stage.stageKind == WorldStageKind.SavePoint) + { + return; + } + + if (!stage.ContainsTile(tile)) + { + return; + } + + if (worldLayout.FindEndpointAt(stage.id, tile, JEndpointKind.Anchor) != null) + { + return; + } + + owner.MutateLayout("Add journey lift", delegate + { + var lift = worldLayout.FindEndpointAt(stage.id, tile, JEndpointKind.Lift); + if (lift is null) + { + lift = new WorldEndpointAuthoring + { + id = worldLayout.CreateId("endpoint"), + stageId = stage.id, + worldTile = tile, + kind = JEndpointKind.Lift, + required = true + }; + worldLayout.endpoints.Add(lift); + } + + state.selectedStageId = stage.id; + state.selectedEndpointId = lift.id; + state.selectedConnectionId = null; + TryConnectLift(lift); + }); + } + + private void TryConnectLift(WorldEndpointAuthoring lift) + { + var liftStage = worldLayout.FindStage(lift.stageId); + if (liftStage is null) + { + return; + } + + foreach (var candidate in worldLayout.endpoints) + { + if (candidate is null || + candidate.id == lift.id || + candidate.kind != JEndpointKind.Lift || + candidate.worldTile.x != lift.worldTile.x || + candidate.worldTile.y != lift.worldTile.y || + worldLayout.FindConnectionBetween(lift.id, candidate.id) is not null) + { + continue; + } + + var candidateStage = worldLayout.FindStage(candidate.stageId); + if (candidateStage is null || Mathf.Abs(candidateStage.worldLayer - liftStage.worldLayer) != 1) + { + continue; + } + + worldLayout.connections.Add(new WorldConnectionAuthoring + { + id = worldLayout.CreateId("connection"), + endpointAId = lift.id, + endpointBId = candidate.id, + directionality = JConnectionDirectionality.Bidirectional + }); + } + } + + private void ConnectPendingAnchorAtTile(JWorldTile tile) + { + if (!TryFindAnchorAtTile(tile, out var anchor)) + { + return; + } + + if (state.pendingAnchorEndpointId == anchor.id) + { + state.pendingAnchorEndpointId = null; + SelectEndpoint(anchor); + MarkDirtyRepaint(); + return; + } + + var firstAnchor = worldLayout.FindEndpoint(state.pendingAnchorEndpointId); + if (firstAnchor is null || firstAnchor.kind != JEndpointKind.Anchor) + { + state.pendingAnchorEndpointId = anchor.id; + SelectEndpoint(anchor); + MarkDirtyRepaint(); + return; + } + + owner.MutateLayout("Connect journey anchors", delegate + { + var connection = worldLayout.FindConnectionBetween(firstAnchor.id, anchor.id); + if (connection is null) + { + connection = new WorldConnectionAuthoring + { + id = worldLayout.CreateId("connection"), + endpointAId = firstAnchor.id, + endpointBId = anchor.id, + directionality = JConnectionDirectionality.Bidirectional + }; + worldLayout.connections.Add(connection); + } + + state.pendingAnchorEndpointId = null; + state.selectedStageId = anchor.stageId; + state.selectedEndpointId = null; + state.selectedConnectionId = connection.id; + }); + } + + private void AddOrConnectAnchor(WorldStageAuthoring stage, JWorldTile tile) + { + if (stage.stageKind == WorldStageKind.SavePoint) + { + return; + } + + if (!stage.ContainsTile(tile)) + { + return; + } + + var anchor = worldLayout.FindEndpointAt(stage.id, tile, JEndpointKind.Anchor); + if (anchor is null) + { + if (!string.IsNullOrEmpty(state.pendingAnchorEndpointId)) + { + return; + } + + if (worldLayout.FindEndpointAt(stage.id, tile, JEndpointKind.Lift) != null) + { + return; + } + + owner.MutateLayout("Add journey anchor", delegate + { + anchor = new WorldEndpointAuthoring + { + id = worldLayout.CreateId("endpoint"), + stageId = stage.id, + worldTile = tile, + kind = JEndpointKind.Anchor, + required = true + }; + worldLayout.endpoints.Add(anchor); + state.selectedStageId = stage.id; + state.selectedEndpointId = anchor.id; + state.selectedConnectionId = null; + state.pendingAnchorEndpointId = anchor.id; + }); + return; + } + + if (string.IsNullOrEmpty(state.pendingAnchorEndpointId)) + { + state.pendingAnchorEndpointId = anchor.id; + SelectEndpoint(anchor); + MarkDirtyRepaint(); + return; + } + + if (state.pendingAnchorEndpointId == anchor.id) + { + state.pendingAnchorEndpointId = null; + SelectEndpoint(anchor); + MarkDirtyRepaint(); + return; + } + + var firstAnchor = worldLayout.FindEndpoint(state.pendingAnchorEndpointId); + if (firstAnchor is null || firstAnchor.kind != JEndpointKind.Anchor) + { + state.pendingAnchorEndpointId = anchor.id; + SelectEndpoint(anchor); + MarkDirtyRepaint(); + return; + } + + owner.MutateLayout("Connect journey anchors", delegate + { + var connection = worldLayout.FindConnectionBetween(firstAnchor.id, anchor.id); + if (connection is null) + { + connection = new WorldConnectionAuthoring + { + id = worldLayout.CreateId("connection"), + endpointAId = firstAnchor.id, + endpointBId = anchor.id, + directionality = JConnectionDirectionality.Bidirectional + }; + worldLayout.connections.Add(connection); + } + + state.pendingAnchorEndpointId = null; + state.selectedStageId = anchor.stageId; + state.selectedEndpointId = null; + state.selectedConnectionId = connection.id; + }); + } + + private void AddOrConnectSocket(JWorldTile tile, JSocketFace face) + { + var stage = worldLayout.FindStageAt(state.activeLayer, tile); + if (stage is null) + { + return; + } + + var adjacentTile = Adjacent(tile, face); + var adjacentStage = worldLayout.FindStageAt(state.activeLayer, adjacentTile); + if (adjacentStage != null && adjacentStage.id == stage.id) + { + return; + } + + state.selectedStageId = stage.id; + var oppositeFace = Opposite(face); + + owner.MutateLayout("Create journey socket connection", delegate + { + var socket = GetOrCreateSocket(stage, tile, face); + state.selectedStageId = stage.id; + state.selectedEndpointId = socket.id; + state.selectedConnectionId = null; + if (adjacentStage is null) + { + return; + } + + var adjacentSocket = GetOrCreateSocket(adjacentStage, adjacentTile, oppositeFace); + if (worldLayout.FindConnectionBetween(socket.id, adjacentSocket.id) is null) + { + worldLayout.connections.Add(new WorldConnectionAuthoring + { + id = worldLayout.CreateId("connection"), + endpointAId = socket.id, + endpointBId = adjacentSocket.id, + directionality = JConnectionDirectionality.Bidirectional + }); + } + }); + } + + private WorldEndpointAuthoring GetOrCreateSocket(WorldStageAuthoring stage, JWorldTile tile, JSocketFace face) + { + var socket = worldLayout.FindSocketAt(stage.id, tile, face); + if (socket != null) + { + return socket; + } + + socket = new WorldEndpointAuthoring + { + id = worldLayout.CreateId("endpoint"), + stageId = stage.id, + worldTile = tile, + kind = JEndpointKind.Socket, + socketFace = face, + required = true + }; + worldLayout.endpoints.Add(socket); + return socket; + } + + private void BeginStagePaint(JWorldTile tile) + { + paintStartTile = tile; + paintCurrentTile = tile; + isPaintingStage = true; + this.CaptureMouse(); + MarkDirtyRepaint(); + } + + private void ApplyStagePaint() + { + if (worldLayout is null) + { + isPaintingStage = false; + return; + } + + var selectedStage = worldLayout.FindStage(state.selectedStageId); + if (selectedStage is null && state.activeTool == WorldEditorTool.PaintStage) + { + selectedStage = CreateStageForPaint(); + } + + if (selectedStage is null || selectedStage.worldLayer != state.activeLayer) + { + isPaintingStage = false; + MarkDirtyRepaint(); + return; + } + + var rect = GetTileRect(paintStartTile, paintCurrentTile); + if (state.activeTool == WorldEditorTool.PaintStage) + { + if (selectedStage.stageKind == WorldStageKind.SavePoint) + { + isPaintingStage = false; + MarkDirtyRepaint(); + return; + } + + owner.MutateLayout("Paint world stage footprint", delegate + { + for (var y = rect.yMin; y < rect.yMax; y++) + { + for (var x = rect.xMin; x < rect.xMax; x++) + { + var tile = new JWorldTile(x, y); + if (!worldLayout.IsTileOccupiedByOther(selectedStage.id, state.activeLayer, tile)) + { + selectedStage.AddTile(tile); + } + } + } + }); + } + else if (state.activeTool == WorldEditorTool.EraseStage) + { + owner.MutateLayout("Erase world stage footprint", delegate + { + if (selectedStage.stageKind == WorldStageKind.SavePoint) + { + RemoveSavePointStage(selectedStage, rect); + return; + } + + var singleTile = rect.width == 1 && rect.height == 1; + if (singleTile && RemoveEndpointsInRect(selectedStage, rect)) + { + return; + } + + for (var y = rect.yMin; y < rect.yMax; y++) + { + for (var x = rect.xMin; x < rect.xMax; x++) + { + selectedStage.RemoveTile(new JWorldTile(x, y)); + } + } + + RemoveEndpointsInRect(selectedStage, rect); + }); + } + + isPaintingStage = false; + MarkDirtyRepaint(); + } + + private WorldStageAuthoring CreateStageForPaint() + { + var stage = new WorldStageAuthoring + { + id = worldLayout.CreateId("stage"), + displayName = "Stage " + (worldLayout.stages.Count + 1), + stageKind = WorldStageKind.Stage, + worldLayer = state.activeLayer, + color = Random.ColorHSV(0f, 1f, 0.35f, 0.75f, 0.55f, 0.85f, 0.75f, 0.9f) + }; + worldLayout.stages.Add(stage); + state.selectedStageId = stage.id; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + return stage; + } + + private void ApplyStageMove() + { + if (worldLayout is null) + { + isMovingStage = false; + return; + } + + var stage = worldLayout.FindStage(movingStageId); + if (stage is null || stage.worldLayer != state.activeLayer) + { + isMovingStage = false; + MarkDirtyRepaint(); + return; + } + + var dx = moveCurrentTile.x - moveStartTile.x; + var dy = moveCurrentTile.y - moveStartTile.y; + if (dx == 0 && dy == 0) + { + isMovingStage = false; + MarkDirtyRepaint(); + return; + } + + if (!CanMoveStage(stage, dx, dy)) + { + isMovingStage = false; + MarkDirtyRepaint(); + return; + } + + owner.MutateLayout("Move world stage", delegate + { + var movedTiles = new List(); + foreach (var tile in stage.occupiedTiles) + { + movedTiles.Add(new JWorldTile(tile.x + dx, tile.y + dy)); + } + + stage.occupiedTiles = movedTiles; + + foreach (var endpoint in worldLayout.endpoints) + { + if (endpoint != null && endpoint.stageId == stage.id) + { + endpoint.worldTile = new JWorldTile(endpoint.worldTile.x + dx, endpoint.worldTile.y + dy); + } + } + + RemoveInvalidConnections(); + }); + + isMovingStage = false; + MarkDirtyRepaint(); + } + + private bool CanMoveStage(WorldStageAuthoring stage, int dx, int dy) + { + foreach (var tile in stage.occupiedTiles) + { + var movedTile = new JWorldTile(tile.x + dx, tile.y + dy); + if (worldLayout.IsTileOccupiedByOther(stage.id, stage.worldLayer, movedTile)) + { + return false; + } + } + + return true; + } + + private void RemoveInvalidConnections() + { + for (var i = worldLayout.connections.Count - 1; i >= 0; i--) + { + var connection = worldLayout.connections[i]; + if (connection is null) + { + worldLayout.connections.RemoveAt(i); + continue; + } + + var endpointA = worldLayout.FindEndpoint(connection.endpointAId); + var endpointB = worldLayout.FindEndpoint(connection.endpointBId); + if (endpointA is null || endpointB is null) + { + ClearSelectedConnectionIf(connection.id); + worldLayout.connections.RemoveAt(i); + continue; + } + + if (endpointA.kind != endpointB.kind) + { + continue; + } + + if (endpointA.kind == JEndpointKind.Socket && + !AreValidSocketConnectionEndpoints(endpointA, endpointB)) + { + ClearSelectedConnectionIf(connection.id); + worldLayout.connections.RemoveAt(i); + } + + if (endpointA.kind == JEndpointKind.Lift && + !AreValidLiftConnectionEndpoints(endpointA, endpointB)) + { + ClearSelectedConnectionIf(connection.id); + worldLayout.connections.RemoveAt(i); + } + } + } + + private bool AreValidSocketConnectionEndpoints(WorldEndpointAuthoring endpointA, WorldEndpointAuthoring endpointB) + { + var stageA = worldLayout.FindStage(endpointA.stageId); + var stageB = worldLayout.FindStage(endpointB.stageId); + if (stageA is null || stageB is null || stageA.worldLayer != stageB.worldLayer || stageA.id == stageB.id) + { + return false; + } + + return Opposite(endpointA.socketFace) == endpointB.socketFace && + SameTile(Adjacent(endpointA.worldTile, endpointA.socketFace), endpointB.worldTile); + } + + private bool AreValidLiftConnectionEndpoints(WorldEndpointAuthoring endpointA, WorldEndpointAuthoring endpointB) + { + var stageA = worldLayout.FindStage(endpointA.stageId); + var stageB = worldLayout.FindStage(endpointB.stageId); + if (stageA is null || stageB is null || stageA.id == stageB.id) + { + return false; + } + + return SameTile(endpointA.worldTile, endpointB.worldTile) && + Mathf.Abs(stageA.worldLayer - stageB.worldLayer) == 1; + } + + private static bool SameTile(JWorldTile a, JWorldTile b) + { + return a.x == b.x && a.y == b.y; + } + + private void RemoveSavePointStage(WorldStageAuthoring stage, RectInt rect) + { + if (stage is null || stage.stageKind != WorldStageKind.SavePoint) + { + return; + } + + var touched = false; + foreach (var tile in stage.occupiedTiles) + { + if (rect.Contains(new Vector2Int(tile.x, tile.y))) + { + touched = true; + break; + } + } + + if (!touched) + { + return; + } + + worldLayout.RemoveStageAndOwnedData(stage.id); + + if (state.selectedStageId == stage.id) + { + state.selectedStageId = null; + state.selectedEndpointId = null; + state.selectedConnectionId = null; + } + } + + private bool RemoveEndpointsInRect(WorldStageAuthoring stage, RectInt rect) + { + var removedEndpointIds = new HashSet(); + for (var i = worldLayout.endpoints.Count - 1; i >= 0; i--) + { + var endpoint = worldLayout.endpoints[i]; + if (endpoint == null || endpoint.stageId != stage.id) + { + continue; + } + + if (!rect.Contains(new Vector2Int(endpoint.worldTile.x, endpoint.worldTile.y))) + { + continue; + } + + removedEndpointIds.Add(endpoint.id); + if (state.selectedEndpointId == endpoint.id) + { + state.selectedEndpointId = null; + } + + worldLayout.endpoints.RemoveAt(i); + } + + if (removedEndpointIds.Count == 0) + { + return false; + } + + for (var i = worldLayout.connections.Count - 1; i >= 0; i--) + { + var connection = worldLayout.connections[i]; + if (connection == null || + removedEndpointIds.Contains(connection.endpointAId) || + removedEndpointIds.Contains(connection.endpointBId)) + { + if (connection != null) + { + ClearSelectedConnectionIf(connection.id); + } + + worldLayout.connections.RemoveAt(i); + } + } + + return true; + } + + private void EndPointerCapture() + { + if (this.HasMouseCapture()) + { + this.ReleaseMouse(); + } + } + private void AddEndpoint(WorldStageAuthoring stage, JWorldTile tile, JEndpointKind kind, JSocketFace face) { if (!stage.ContainsTile(tile)) @@ -172,6 +1122,12 @@ namespace Editor.GON.WorldEditor return; } + if (stage.stageKind == WorldStageKind.SavePoint && + kind is JEndpointKind.Anchor or JEndpointKind.Lift) + { + return; + } + if (worldLayout.FindEndpointAt(stage.id, tile, kind) != null) { return; @@ -196,7 +1152,14 @@ namespace Editor.GON.WorldEditor var p = mgc.painter2D; DrawGrid(p); DrawStages(p); + DrawPendingStagePlacement(p); + DrawStagePaintPreview(p); + DrawStageMovePreview(p); + DrawAnchorConnections(p); + DrawOtherLayerLiftIndicators(p); + DrawPendingAnchorConnection(p); DrawEndpoints(p); + DrawSelectedEndpointIndicator(p); } private void DrawGrid(Painter2D p) @@ -241,11 +1204,109 @@ namespace Editor.GON.WorldEditor { var rect = TileRect(tile); FillRect(p, rect, stage.color); - StrokeRect(p, rect, stage.id == state.selectedStageId ? WorldEditorColors.Selected : Color.black, 2f); + if (stage.stageKind == WorldStageKind.SavePoint) + { + DrawSavePointSymbol(p, rect); + } + } + + foreach (var tile in stage.occupiedTiles) + { + DrawStageTileFreeFaces(p, stage, tile, + stage.id == state.selectedStageId ? WorldEditorColors.Selected : Color.black, 2f); } } } + private void DrawStageTileFreeFaces( + Painter2D p, + WorldStageAuthoring stage, + JWorldTile tile, + Color color, + float width) + { + var rect = TileRect(tile); + + if (!stage.ContainsTile(new JWorldTile(tile.x, tile.y - 1))) + { + DrawLine(p, new Vector2(rect.xMin, rect.yMin), new Vector2(rect.xMax, rect.yMin), color, width); + } + + if (!stage.ContainsTile(new JWorldTile(tile.x, tile.y + 1))) + { + DrawLine(p, new Vector2(rect.xMin, rect.yMax), new Vector2(rect.xMax, rect.yMax), color, width); + } + + if (!stage.ContainsTile(new JWorldTile(tile.x + 1, tile.y))) + { + DrawLine(p, new Vector2(rect.xMax, rect.yMin), new Vector2(rect.xMax, rect.yMax), color, width); + } + + if (!stage.ContainsTile(new JWorldTile(tile.x - 1, tile.y))) + { + DrawLine(p, new Vector2(rect.xMin, rect.yMin), new Vector2(rect.xMin, rect.yMax), color, width); + } + } + + private void DrawSavePointSymbol(Painter2D p, Rect rect) + { + var a = new Vector2(rect.xMin + rect.width * 0.32f, rect.yMin + rect.height * 0.25f); + var b = new Vector2(rect.xMin + rect.width * 0.68f, rect.yMin + rect.height * 0.25f); + var c = new Vector2(rect.xMin + rect.width * 0.32f, rect.yMin + rect.height * 0.50f); + var d = new Vector2(rect.xMin + rect.width * 0.68f, rect.yMin + rect.height * 0.50f); + var e = new Vector2(rect.xMin + rect.width * 0.32f, rect.yMin + rect.height * 0.75f); + var f = new Vector2(rect.xMin + rect.width * 0.68f, rect.yMin + rect.height * 0.75f); + var width = Mathf.Max(2.5f, cellPixels * 0.08f); + + DrawSegmentedSavePointSymbol(p, a, b, c, d, e, f, new Color(0f, 0f, 0f, 0.65f), width + 2f); + DrawSegmentedSavePointSymbol(p, a, b, c, d, e, f, Color.white, width); + } + + private void DrawSegmentedSavePointSymbol( + Painter2D p, + Vector2 a, + Vector2 b, + Vector2 c, + Vector2 d, + Vector2 e, + Vector2 f, + Color color, + float width) + { + DrawLine(p, a, b, color, width); + DrawLine(p, a, c, color, width); + DrawLine(p, c, d, color, width); + DrawLine(p, d, f, color, width); + DrawLine(p, e, f, color, width); + } + + private void DrawPendingStagePlacement(Painter2D p) + { + if (worldLayout is null || state.pendingStagePlacementKind is null || !state.hoverTile.HasValue) + { + return; + } + + var tile = state.hoverTile.Value; + var rect = TileRect(tile); + var stageKind = state.pendingStagePlacementKind.Value; + var occupied = worldLayout.FindStageAt(state.activeLayer, tile) is not null; + var blocked = stageKind == WorldStageKind.Stage && occupied; + var fill = blocked + ? new Color(1f, 0.15f, 0.12f, 0.25f) + : stageKind == WorldStageKind.SavePoint + ? new Color(0.95f, 0.25f, 0.85f, 0.28f) + : new Color(1f, 0.86f, 0.32f, 0.22f); + var stroke = blocked + ? new Color(1f, 0.15f, 0.12f, 1f) + : stageKind == WorldStageKind.SavePoint + ? WorldEditorColors.EndpointSelected + : WorldEditorColors.Selected; + + FillRect(p, rect, fill); + StrokeRect(p, rect, stroke, 2f); + } + private void DrawEndpoints(Painter2D p) { if (worldLayout is null) @@ -266,7 +1327,7 @@ namespace Editor.GON.WorldEditor switch (endpoint.kind) { case JEndpointKind.Anchor: - FillMarker(p, center, Mathf.Max(8f, cellPixels * 0.28f), WorldEditorColors.Anchor); + FillCircle(p, center, Mathf.Max(5f, cellPixels * 0.16f), WorldEditorColors.Anchor); break; case JEndpointKind.Lift: FillMarker(p, center, Mathf.Max(8f, cellPixels * 0.28f), WorldEditorColors.Lift); @@ -279,19 +1340,294 @@ namespace Editor.GON.WorldEditor } } + private void DrawSelectedEndpointIndicator(Painter2D p) + { + if (worldLayout is null || string.IsNullOrEmpty(state.selectedEndpointId)) + { + return; + } + + var endpoint = worldLayout.FindEndpoint(state.selectedEndpointId); + if (endpoint is null || !TryGetEndpointScreenPosition(endpoint, out var center)) + { + return; + } + + DrawDottedCircle(p, center, cellPixels * 0.5f, WorldEditorColors.EndpointSelected, 2f); + } + + private void DrawOtherLayerLiftIndicators(Painter2D p) + { + if (worldLayout is null) + { + return; + } + + foreach (var endpoint in worldLayout.endpoints) + { + if (endpoint is null || endpoint.kind != JEndpointKind.Lift) + { + continue; + } + + var stage = worldLayout.FindStage(endpoint.stageId); + if (stage is null || stage.worldLayer == state.activeLayer) + { + continue; + } + + var rect = TileRect(endpoint.worldTile); + var center = rect.center; + FillMarker(p, center, Mathf.Max(8f, cellPixels * 0.28f), WorldEditorColors.LiftGhost); + DrawLine(p, center + Vector2.down * 6f, center + Vector2.up * 6f, WorldEditorColors.Lift, 1f); + } + } + + private void DrawAnchorConnections(Painter2D p) + { + if (worldLayout is null) + { + return; + } + + foreach (var connection in worldLayout.connections) + { + if (connection is null) + { + continue; + } + + var endpointA = worldLayout.FindEndpoint(connection.endpointAId); + var endpointB = worldLayout.FindEndpoint(connection.endpointBId); + if (endpointA is null || endpointB is null || + endpointA.kind != JEndpointKind.Anchor || + endpointB.kind != JEndpointKind.Anchor) + { + continue; + } + + if (!TryGetEndpointCenter(endpointA, out var a) || !TryGetEndpointCenter(endpointB, out var b)) + { + continue; + } + + DrawLine(p, a, b, WorldEditorColors.Anchor, 2f); + } + } + + private void DrawPendingAnchorConnection(Painter2D p) + { + if (string.IsNullOrEmpty(state.pendingAnchorEndpointId) || !hasHoverMouse) + { + return; + } + + var anchor = worldLayout?.FindEndpoint(state.pendingAnchorEndpointId); + if (anchor is null || anchor.kind != JEndpointKind.Anchor || + !TryGetEndpointCenter(anchor, out var start)) + { + return; + } + + var color = IsHoveringConnectableAnchor(anchor) + ? WorldEditorColors.Anchor + : new Color(0.55f, 0.55f, 0.55f, 1f); + DrawLine(p, start, hoverMousePosition, color, 2f); + } + + private bool IsHoveringConnectableAnchor(WorldEndpointAuthoring pendingAnchor) + { + if (!state.hoverTile.HasValue) + { + return false; + } + + return TryFindAnchorAtTile(state.hoverTile.Value, out var hoverAnchor) && + hoverAnchor.id != pendingAnchor.id; + } + + private bool TryFindAnchorAtTile(JWorldTile tile, out WorldEndpointAuthoring anchor) + { + return TryFindEndpointAtTile(tile, JEndpointKind.Anchor, out anchor); + } + + private bool TryFindEndpointAtTile( + JWorldTile tile, + JEndpointKind kind, + out WorldEndpointAuthoring foundEndpoint) + { + foundEndpoint = null; + if (worldLayout is null) + { + return false; + } + + foreach (var endpoint in worldLayout.endpoints) + { + if (endpoint is null || + endpoint.kind != kind || + endpoint.worldTile.x != tile.x || + endpoint.worldTile.y != tile.y) + { + continue; + } + + var stage = worldLayout.FindStage(endpoint.stageId); + if (stage is null || stage.worldLayer != state.activeLayer) + { + continue; + } + + foundEndpoint = endpoint; + return true; + } + + return false; + } + + private bool TryGetEndpointCenter(WorldEndpointAuthoring endpoint, out Vector2 center) + { + center = default; + var stage = worldLayout?.FindStage(endpoint.stageId); + if (stage is null || stage.worldLayer != state.activeLayer) + { + return false; + } + + center = TileRect(endpoint.worldTile).center; + return true; + } + + private void DrawStagePaintPreview(Painter2D p) + { + if (!isPaintingStage || state.activeTool is not (WorldEditorTool.PaintStage or WorldEditorTool.EraseStage)) + { + return; + } + + var rect = GetTileRect(paintStartTile, paintCurrentTile); + var screenMin = GridToScreen(new Vector2(rect.xMin, rect.yMin)); + var screenMax = GridToScreen(new Vector2(rect.xMax, rect.yMax)); + var screenRect = new Rect( + screenMin.x, + screenMin.y, + screenMax.x - screenMin.x, + screenMax.y - screenMin.y); + + var fill = state.activeTool == WorldEditorTool.PaintStage + ? new Color(1f, 0.86f, 0.32f, 0.18f) + : new Color(1f, 0.25f, 0.2f, 0.18f); + var stroke = state.activeTool == WorldEditorTool.PaintStage + ? WorldEditorColors.Selected + : new Color(1f, 0.25f, 0.2f, 1f); + + FillRect(p, screenRect, fill); + StrokeRect(p, screenRect, stroke, 2f); + } + + private void DrawStageMovePreview(Painter2D p) + { + if (!isMovingStage) + { + return; + } + + var stage = worldLayout?.FindStage(movingStageId); + if (stage is null) + { + return; + } + + var dx = moveCurrentTile.x - moveStartTile.x; + var dy = moveCurrentTile.y - moveStartTile.y; + if (dx == 0 && dy == 0) + { + return; + } + + var canMove = CanMoveStage(stage, dx, dy); + var fill = canMove + ? new Color(1f, 0.86f, 0.32f, 0.18f) + : new Color(1f, 0.25f, 0.2f, 0.18f); + var stroke = canMove ? WorldEditorColors.Selected : new Color(1f, 0.25f, 0.2f, 1f); + + foreach (var tile in stage.occupiedTiles) + { + var rect = TileRect(new JWorldTile(tile.x + dx, tile.y + dy)); + FillRect(p, rect, fill); + StrokeRect(p, rect, stroke, 2f); + } + } + private void DrawSocket(Painter2D p, Rect rect, JSocketFace face) { var center = rect.center; var half = cellPixels * 0.5f; var size = Mathf.Max(5f, cellPixels * 0.16f); - var point = face switch + var point = SocketPoint(rect, face); + FillMarker(p, point, size * 2f, GetSocketColor(rect, face)); + } + + private bool TryGetEndpointScreenPosition(WorldEndpointAuthoring endpoint, out Vector2 position) + { + position = default; + var stage = worldLayout.FindStage(endpoint.stageId); + if (stage is null || stage.worldLayer != state.activeLayer) + { + return false; + } + + var rect = TileRect(endpoint.worldTile); + position = endpoint.kind == JEndpointKind.Socket + ? SocketPoint(rect, endpoint.socketFace) + : rect.center; + return true; + } + + private Vector2 SocketPoint(Rect rect, JSocketFace face) + { + var center = rect.center; + var half = cellPixels * 0.5f; + return face switch { JSocketFace.North => new Vector2(center.x, center.y - half), JSocketFace.South => new Vector2(center.x, center.y + half), JSocketFace.East => new Vector2(center.x + half, center.y), _ => new Vector2(center.x - half, center.y) }; - FillMarker(p, point, size * 2f, WorldEditorColors.Socket); + } + + private Color GetSocketColor(Rect rect, JSocketFace face) + { + var tile = ScreenRectToTile(rect); + var stage = worldLayout.FindStageAt(state.activeLayer, tile); + if (stage is null) + { + return WorldEditorColors.Socket; + } + + var socket = worldLayout.FindSocketAt(stage.id, tile, face); + if (socket is null) + { + return WorldEditorColors.Socket; + } + + var adjacentTile = Adjacent(tile, face); + var adjacentStage = worldLayout.FindStageAt(state.activeLayer, adjacentTile); + if (adjacentStage is null || adjacentStage.id == stage.id) + { + return WorldEditorColors.Socket; + } + + var adjacentSocket = worldLayout.FindSocketAt(adjacentStage.id, adjacentTile, Opposite(face)); + if (adjacentSocket is null) + { + return WorldEditorColors.SocketPaired; + } + + return worldLayout.FindConnectionBetween(socket.id, adjacentSocket.id) is null + ? WorldEditorColors.SocketPaired + : WorldEditorColors.SocketConnected; } private JSocketFace GetNearestFace(Vector2 mouse, JWorldTile tile) @@ -308,12 +1644,127 @@ namespace Editor.GON.WorldEditor return JSocketFace.West; } + private static float DistanceToFace(Vector2 mouse, Rect rect, JSocketFace face) + { + return face switch + { + JSocketFace.North => Mathf.Abs(mouse.y - rect.yMin), + JSocketFace.South => Mathf.Abs(mouse.y - rect.yMax), + JSocketFace.East => Mathf.Abs(mouse.x - rect.xMax), + _ => Mathf.Abs(mouse.x - rect.xMin) + }; + } + + private void ClearSelectedConnectionIf(string connectionId) + { + if (state.selectedConnectionId == connectionId) + { + state.selectedConnectionId = null; + } + } + + private void FrameGridCenter(Vector2 gridCenter) + { + var rect = contentRect; + if (rect.width <= 1f || rect.height <= 1f) + { + return; + } + + pan = rect.center - gridCenter * cellPixels; + MarkDirtyRepaint(); + } + + private bool TryGetStageGridCenter(WorldStageAuthoring stage, out Vector2 center) + { + center = default; + if (stage is null || stage.occupiedTiles is null || stage.occupiedTiles.Count == 0) + { + return false; + } + + var minX = int.MaxValue; + var minY = int.MaxValue; + var maxX = int.MinValue; + var maxY = int.MinValue; + foreach (var tile in stage.occupiedTiles) + { + minX = Mathf.Min(minX, tile.x); + minY = Mathf.Min(minY, tile.y); + maxX = Mathf.Max(maxX, tile.x + 1); + maxY = Mathf.Max(maxY, tile.y + 1); + } + + center = new Vector2((minX + maxX) * 0.5f, (minY + maxY) * 0.5f); + return true; + } + + private bool TryGetEndpointGridCenter(WorldEndpointAuthoring endpoint, out Vector2 center) + { + center = default; + var stage = worldLayout.FindStage(endpoint.stageId); + if (stage is null || stage.worldLayer != state.activeLayer) + { + return false; + } + + center = new Vector2(endpoint.worldTile.x + 0.5f, endpoint.worldTile.y + 0.5f); + return true; + } + + private bool TryGetConnectionGridCenter(string connectionId, out Vector2 center) + { + center = default; + foreach (var connection in worldLayout.connections) + { + if (connection == null || connection.id != connectionId) + { + continue; + } + + var endpointA = worldLayout.FindEndpoint(connection.endpointAId); + var endpointB = worldLayout.FindEndpoint(connection.endpointBId); + var centerA = default(Vector2); + var centerB = default(Vector2); + var hasA = endpointA is not null && TryGetEndpointGridCenter(endpointA, out centerA); + var hasB = endpointB is not null && TryGetEndpointGridCenter(endpointB, out centerB); + + if (hasA && hasB) + { + center = (centerA + centerB) * 0.5f; + return true; + } + + if (hasA) + { + center = centerA; + return true; + } + + if (hasB) + { + center = centerB; + return true; + } + + return false; + } + + return false; + } + private Rect TileRect(JWorldTile tile) { var min = GridToScreen(new Vector2(tile.x, tile.y)); return new Rect(min.x, min.y, cellPixels, cellPixels); } + private JWorldTile ScreenRectToTile(Rect rect) + { + var grid = ScreenToGridFloat(new Vector2(rect.xMin + 1f, rect.yMin + 1f)); + return new JWorldTile(Mathf.FloorToInt(grid.x), Mathf.FloorToInt(grid.y)); + } + private bool TryScreenToTile(Vector2 screen, out JWorldTile tile) { var grid = ScreenToGridFloat(screen); @@ -321,6 +1772,37 @@ namespace Editor.GON.WorldEditor return true; } + private static RectInt GetTileRect(JWorldTile a, JWorldTile b) + { + var xMin = Mathf.Min(a.x, b.x); + var yMin = Mathf.Min(a.y, b.y); + var xMax = Mathf.Max(a.x, b.x) + 1; + var yMax = Mathf.Max(a.y, b.y) + 1; + return new RectInt(xMin, yMin, xMax - xMin, yMax - yMin); + } + + private static JWorldTile Adjacent(JWorldTile tile, JSocketFace face) + { + return face switch + { + JSocketFace.North => new JWorldTile(tile.x, tile.y - 1), + JSocketFace.South => new JWorldTile(tile.x, tile.y + 1), + JSocketFace.East => new JWorldTile(tile.x + 1, tile.y), + _ => new JWorldTile(tile.x - 1, tile.y) + }; + } + + private static JSocketFace Opposite(JSocketFace face) + { + return face switch + { + JSocketFace.North => JSocketFace.South, + JSocketFace.South => JSocketFace.North, + JSocketFace.East => JSocketFace.West, + _ => JSocketFace.East + }; + } + private Vector2 ScreenToGridFloat(Vector2 screen) { return (screen - pan) / cellPixels; @@ -362,6 +1844,39 @@ namespace Editor.GON.WorldEditor FillRect(p, new Rect(center.x - half, center.y - half, size, size), color); } + private void FillCircle(Painter2D p, Vector2 center, float radius, Color color) + { + const int segments = 24; + p.fillColor = color; + p.BeginPath(); + p.MoveTo(center + new Vector2(radius, 0f)); + for (var i = 1; i <= segments; i++) + { + var angle = Mathf.PI * 2f * i / segments; + p.LineTo(center + new Vector2(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius)); + } + p.ClosePath(); + p.Fill(); + } + + private void DrawDottedCircle(Painter2D p, Vector2 center, float radius, Color color, float width) + { + const int dots = 24; + const float dotRadians = Mathf.PI * 2f / dots * 0.38f; + for (var i = 0; i < dots; i++) + { + var angle = Mathf.PI * 2f * i / dots; + var a = angle - dotRadians * 0.5f; + var b = angle + dotRadians * 0.5f; + DrawLine( + p, + center + new Vector2(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius), + center + new Vector2(Mathf.Cos(b) * radius, Mathf.Sin(b) * radius), + color, + width); + } + } + private void DrawLine(Painter2D p, Vector2 a, Vector2 b, Color color, float width) { p.strokeColor = color; diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 1199c65..15b980e 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,2 +1,2 @@ -{"type":"meta","next_id":{"DSC":2,"AGD":2,"DEC":2,"PLN":5,"LSN":1,"CLSN":1}} -{"type":"discussion","id":"DSC-0001","status":"accepted","ticket":"world-editor-journey-spec","title":"World Editor / Journey Spec","created_at":"2026-06-19","updated_at":"2026-06-19","tags":["world-editor","journey","stage-contract","architecture"],"agendas":[{"id":"AGD-0001","file":"AGD-0001-world-editor-journey-spec.md","status":"accepted","created_at":"2026-06-19","updated_at":"2026-06-19"}],"decisions":[{"id":"DEC-0001","file":"DEC-0001-world-editor-journey-v1.md","status":"accepted","created_at":"2026-06-19","updated_at":"2026-06-19","ref_agenda":"AGD-0001"}],"plans":[{"id":"PLN-0001","file":"PLN-0001-world-journey-spec.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0002","file":"PLN-0002-journey-runtime-model.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0003","file":"PLN-0003-world-editor-authoring.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0004","file":"PLN-0004-journey-commit-validation.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]}],"lessons":[]} +{"type":"meta","next_id":{"DSC":2,"AGD":2,"DEC":2,"PLN":5,"LSN":2,"CLSN":1}} +{"type":"discussion","id":"DSC-0001","status":"done","ticket":"world-editor-journey-spec","title":"World Editor / Journey Spec","created_at":"2026-06-19","updated_at":"2026-06-20","tags":["world-editor","journey","stage-contract","architecture"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-world-editor-journey-spec/LSN-0001-editor-authoring-runtime-publication.md","status":"done","created_at":"2026-06-20","updated_at":"2026-06-20"}]} diff --git a/discussion/lessons/DSC-0001-world-editor-journey-spec/LSN-0001-editor-authoring-runtime-publication.md b/discussion/lessons/DSC-0001-world-editor-journey-spec/LSN-0001-editor-authoring-runtime-publication.md new file mode 100644 index 0000000..377af22 --- /dev/null +++ b/discussion/lessons/DSC-0001-world-editor-journey-spec/LSN-0001-editor-authoring-runtime-publication.md @@ -0,0 +1,52 @@ +--- +id: LSN-0001 +ticket: world-editor-journey-spec +title: Separate editor authoring data from runtime Journey publication +created: 2026-06-20 +tags: + - world-editor + - journey + - unity + - scriptableobjects +--- + +## Context + +The Gardens of Nil World Editor needs to organize the macro-world while the Stage Editor owns internal stage content. The first Journey implementation had to support editor iteration without letting unfinished authoring data leak into gameplay playtests. + +The resulting model uses two ScriptableObject layers: + +- `WorldLayoutSO` is editor-only authoring data for stages, footprints, endpoints, and connections. +- `JGraphSO` is the published runtime Journey graph consumed by gameplay code. + +## Key Decisions + +### World Editor / Journey V1 + +**What:** Keep world authoring data under editor code and publish a separate runtime-safe Journey graph through an explicit commit action. + +**Why:** Designers need to edit incomplete worlds without changing the runtime contract used by playtests. Gameplay code should depend on stable Journey data and must not reference editor-only types. + +**Trade-offs:** The model requires a publication step and validation rules between authoring and runtime data. In exchange, it avoids coupling runtime traversal to mutable editor state and keeps Stage bake ownership separate from world topology authoring. + +## Patterns and Algorithms + +- Treat editor assets as work-in-progress sources of truth, not runtime APIs. +- Publish runtime data through a deliberate commit action after validation. +- Keep generated runtime records plain and queryable: `JStage`, `JEndpoint`, `JConnection`, and `JGraphSO`. +- Use stable string ids for cross-editor contracts because Unity object references are not enough to bridge World Editor, Stage Editor, and runtime data safely. +- Model physical transition space as stages, not as connections. `JConnection` remains logical and never owns space. + +## Pitfalls + +- If runtime code reads editor-only data directly, playtests can accidentally depend on incomplete authoring changes. +- If connections own physical space, the boundary between World Editor and Stage Editor becomes ambiguous. +- If validation does not prune stale authoring records, deleted or moved stages can leave invisible data that still affects Journey publication. +- If StageDefinition becomes the runtime Journey contract, the Stage Editor and runtime traversal become coupled too early. + +## Takeaways + +- Use `WorldLayoutSO` for authoring and `JGraphSO` for runtime Journey. +- Keep commit/publish explicit and validation-driven. +- Keep Journey runtime code outside `Assets/Scripts/Editor`. +- Preserve Stage Editor ownership of scene geometry and bake output. diff --git a/discussion/workflow/agendas/AGD-0001-world-editor-journey-spec.md b/discussion/workflow/agendas/AGD-0001-world-editor-journey-spec.md deleted file mode 100644 index 6a7a7cd..0000000 --- a/discussion/workflow/agendas/AGD-0001-world-editor-journey-spec.md +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: AGD-0001 -ticket: world-editor-journey-spec -title: World Editor / Journey Spec -status: accepted -created: 2026-06-19 -resolved: 2026-06-19 -decision: DEC-0001 -tags: - - world-editor - - journey - - stage-contract - - architecture ---- - -## Contexto - -O World Editor precisa organizar o macro-mundo jogavel de Gardens of Nil sem assumir responsabilidade de Stage Editor. O modelo proposto separa: - -- World Editor: camadas macro, tiles ocupados por Stage, contratos de conexao, regras de travessia, loading/progressao e vizinhanca. -- Stage Editor: geometria interna, props, inimigos, puzzles, floors internos, camera interna, portas finais e navegacao dentro do Stage. - -A unidade base proposta e `WorldTile = 24m x 24m`, com cada tile representando aproximadamente uma tela jogavel. O mundo e editado como 2D por `WorldLayer`; verticalidade interna continua no Stage Editor, e verticalidade macro fica sob `JEndpointLift`. - -O sistema central de conexao entre Stages se chama `Journey`. Cada `Stage` declara `JEndpoints`, e cada `JConnection` conecta exatamente dois endpoints. Todo espaco fisico pertence sempre a um Stage; se for necessario corredor, antecamara, sala de loading, elevador longo ou mecanismo de transicao, isso deve virar outro Stage, nao uma conexao com area propria. - -Contexto adicional levantado durante a discussao: o Stage Editor ja esta em andamento em `Assets/Scripts/Editor/GardensStageEditor`, com `StageDefinition` em `Assets/Scripts/Editor/GardensStageEditor/StageDefinition.cs`. Esse `StageDefinition` descreve dados editoriais internos do Stage, como floors, grid/cells e objetos de authoring, e por estar sob `Assets/Scripts/Editor` nao deve ser assumido como dado consultavel in game. - -Direcao proposta pelo usuario: `StageDefinition` deve permanecer como dado de editor, enquanto os dados de Journey precisam existir em um pacote runtime consultavel pelo jogo, provavelmente em `Assets/Scripts/Intrepid/Gameplay/Journey`. - -Refinamento proposto pelo usuario: podem existir ambos os ScriptableObjects. Um SO pode ser editorial/editor-only, usado durante authoring de Stage e World. Quando o trabalho for consolidado, o editor gera ou atualiza os dados do lado runtime do Journey. A intencao e evitar que edicao em andamento de stages/world afete playtest ou runtime. - -Refinamento de fluxo editorial: inicialmente existirao dois editores, um World Editor e um Stage Editor. No futuro eles podem ser consolidados em uma unica experiencia, onde um duplo clique em um Stage no World Editor abre o Stage Editor correspondente. Hoje o Stage Editor recebe o `StageDefinition` manualmente; a integracao futura deve remover essa friccao. - -Nomes aceitos para o contrato inicial: `WorldLayoutSO` para o SO editorial do World Editor e `JGraphSO` para o SO runtime consultavel pelo Journey. Elementos do Journey devem usar prefixo `J`; se forem ScriptableObjects, devem tambem terminar com `SO`. - -Escopo aceito para commit/publicacao do World Editor: o botao de commit gera somente os dados logicos do Journey, como graph, endpoints, connections e metadados necessarios para travessia/loading/progressao. Ele nao gera Stages, cenas, geometria interna, objetos internos ou conteudo do Stage Editor. Cada Stage continua sendo gerado/bakeado a partir do Stage Editor. Os dados publicados do Journey servem como contrato de integracao para que os Stages implementem seus endpoints e conexoes correspondentes. - -Fechamentos de V1: `WorldTile = 24m` e `WorldLayerHeight = 24m` devem ser defaults configuraveis no `WorldLayoutSO`, nao constantes hardcoded. `Stage`, `JEndpoint` e `JConnection` devem ter ids estaveis por GUID/string; o sistema de tags existente pode ser util para classificacao/consulta, mas nao substitui identidade estavel. `JEndpointLift` fica limitado a uma unica `JConnection` na V1. `JEndpointAnchor` pode ter multiplas conexoes, mas a selecao de destino ja existe in game e nao precisa ser tratada por esta V1. Loading hints ficam fora da V1; a arquitetura deve apenas permitir adicionar dados depois. O Journey pode ter uma representacao logica de Stage, chamada `JStage`, mas o Stage de cena nao e gerado pelo botao do World Editor. Na V1, a integracao World Editor -> Stage Editor permanece manual. - -Fechamentos finais de V1: `JGraphSO` deve registrar metadata minima de publicacao, incluindo `sourceWorldLayoutId`, `publishedAt`, `schemaVersion` e, se barato, `contentHash`. Requirements ficam como extensao futura de `JConnection`, sem estrutura concreta obrigatoria na V1. Endpoint obrigatorio sem conexao deve bloquear commit como erro; endpoint opcional sem conexao pode ser warning. - -Tecnologia de editor aceita para V1: o World Editor deve usar a mesma abordagem do Stage Editor atual: `EditorWindow` com UI Toolkit (`UnityEngine.UIElements` e `UnityEditor.UIElements`) e canvas custom baseado em `VisualElement.generateVisualContent`. Isso evita introduzir outro stack de window/editor UI e facilita uma futura integracao entre World Editor e Stage Editor. - -## Problema - -Precisamos transformar a proposta de World Editor / Journey em contrato arquitetural consistente antes de editar specs ou codigo. A proposta ja contem muitas decisoes aparentemente firmes, mas ainda ha pontos que mudam serializacao, validacao, fluxo de edicao, runtime e sincronizacao com Stage Editor. - -O risco principal e cristalizar cedo demais um modelo que parece simples no editor, mas que depois nao fecha bem com runtime, loading, authoring de cenas Unity ou validacao entre o contrato runtime do Journey, o `StageDefinition` editor-only e markers reais na Scene. - -## Pontos Criticos - -- `WorldTile` e `WorldLayer` parecem bons como contrato macro, mas precisam ter semantica clara: unidade de authoring, unidade de loading, unidade de progressao, ou apenas visualizacao editorial. -- `Stage` como conjunto de tiles sem overlap simplifica validacao, mas pode restringir intencoes visuais de mundo se designers tentarem modelar fundo, paralaxe ou volumes sobrepostos no World Editor. -- `JConnection` sem espaco fisico e uma regra forte. Ela evita corredores invisiveis no grafo, mas exige disciplina para criar Transition Stages quando a travessia tem presenca fisica. -- `JEndpointSocket`, `JEndpointAnchor` e `JEndpointLift` cobrem tres categorias boas, mas a cardinalidade e compatibilidade precisam virar invariantes do modelo. -- `StageDefinition` existente pertence ao Stage Editor e e editor-only. O Journey precisa de um contrato runtime proprio, separado do asset interno do Stage Editor. -- A fronteira entre dado autoral/editorial e dado runtime precisa ficar explicita: o World Editor pode produzir/validar dados de Journey, mas o jogo nao deve depender de tipos localizados em `Assets/Scripts/Editor`. -- O modelo pode ter dois niveis de SO: um SO editorial para World Editor/authoring e um SO consolidado runtime em Journey. Ainda falta nomear os tipos e definir a direcao de sync/publicacao. -- O fluxo de edicao tambem precisa considerar integracao entre World Editor e Stage Editor: manter editores separados no inicio, mas desenhar o contrato para permitir abrir o Stage Editor a partir do World Editor. -- Requirements podem pertencer a `JConnection` como dados simples ou extensao futura; loading hints ficam fora da V1. -- Stage Editor Sync ainda e direcao futura. Na V1, a integracao entre World Editor e Stage Editor permanece manual. -- O World Editor deve seguir a tecnologia de window ja usada no Stage Editor, hoje localizado em `Assets/Scripts/Editor/GON/StageEditor`. - -## Opcoes - -### Opcao A - Aceitar o modelo proposto como arquitetura base - -- **Abordagem:** tratar o texto atual como base quase fechada para uma decisao normativa, mantendo apenas detalhes menores para planos futuros. -- **Pro:** acelera a criacao de specs e implementacao do editor; reduz ambiguidade no curto prazo. -- **Contra:** pode esconder decisoes ainda nao maduras, especialmente sync com Stage Editor, requirements e lifts multi-parada. -- **Manutenibilidade:** boa se as premissas estiverem corretas; cara se for necessario mudar cardinalidade ou identidade de endpoints depois. - -### Opcao B - Fechar primeiro o contrato minimo do Journey - -- **Abordagem:** separar o nucleo estavel do Journey: `WorldLayer`, `WorldTile`, `Stage`, `JEndpoint`, `JConnection`, compatibilidade, cardinalidade e validacoes de erro. Deixar runtime selection, UX detalhada de wiring, requirements avancados e sync Scene como decisoes subsequentes. -- **Pro:** cria uma base normativa pequena e implementavel sem resolver todo o ecossistema de uma vez. -- **Contra:** exige mais de uma decisao/plano antes de o sistema completo estar especificado. -- **Manutenibilidade:** forte, porque preserva invariantes essenciais e deixa extensoes menos maduras fora do contrato inicial. - -### Opcao B2 - Fechar contrato minimo com separacao editor/runtime - -- **Abordagem:** manter a Opcao B, mas acrescentar como decisao de base que `StageDefinition` existente e editor-only, e que os tipos/dados consultaveis in game do Journey ficam em `Assets/Scripts/Intrepid/Gameplay/Journey`. -- **Pro:** respeita o Stage Editor ja em andamento; evita dependencia runtime em assemblies de editor; cria uma fronteira limpa entre authoring e gameplay. -- **Contra:** exige mapear como o World Editor sincroniza ou exporta dados para o asset runtime do Journey. -- **Manutenibilidade:** mais forte que a Opcao B simples, porque separa explicitamente contratos de authoring e contratos de runtime antes de surgirem referencias cruzadas indevidas. - -### Opcao B3 - Dois SOs com publicacao explicita - -- **Abordagem:** manter um SO editorial para authoring de World/Stage e gerar um SO runtime consolidado para Journey apenas em um passo explicito de publicacao/consolidacao. -- **Pro:** protege playtest de edicoes incompletas; permite validacoes editoriais mais ricas sem carregar complexidade no runtime; reduz acoplamento entre Stage Editor, World Editor e gameplay. -- **Contra:** introduz um pipeline de publicacao que precisa ser validado, versionado e facil de diagnosticar quando editor e runtime divergirem. -- **Manutenibilidade:** forte se o runtime SO for tratado como artefato publicado e verificavel, nao como espelho editado manualmente. - -### Opcao C - Prototipar editor antes de fechar contrato - -- **Abordagem:** implementar um prototipo Unity do World Editor e deixar a experiencia editorial revelar o melhor modelo. -- **Pro:** valida rapidamente fluxo de pintura, conexoes, visualizacao por layer e wiring. -- **Contra:** viola o fluxo do repositorio para mudancas arquiteturais se nao houver decisao e plano antes do codigo; pode gerar codigo descartavel como fonte acidental de verdade. -- **Manutenibilidade:** arriscada se o prototipo virar contrato implicito. - -## Sugestao / Recomendacao - -Recomendacao inicial: seguir a Opcao B3. - -O texto proposto deve virar uma agenda que separa contrato minimo de extensoes. A primeira decisao deveria fechar apenas o nucleo do World Editor / Journey: - -- World Editor e 2D por `WorldLayer`. -- `WorldTile = 24m x 24m`. -- `Stage` pertence a uma unica layer e ocupa um conjunto sem overlap de tiles. -- Todo espaco fisico pertence a `Stage`. -- `JConnection` nunca possui espaco fisico. -- `JEndpointSocket`, `JEndpointAnchor` e `JEndpointLift` sao os tipos iniciais. -- Compatibilidade inicial e apenas tipo igual com tipo igual. -- `JConnection` e sempre uma ligacao 1:1. -- Cardinalidade inicial: socket uma conexao, anchor varias conexoes, lift uma conexao. -- Anchor e Lift nao coexistem no mesmo tile; sockets podem coexistir com ambos. -- Validacoes de erro e warning precisam ser classificadas antes de execucao. -- `StageDefinition` existente em `Assets/Scripts/Editor/GardensStageEditor` permanece editor-only e nao e a API runtime do Journey. -- Os tipos e assets runtime do Journey devem viver fora de `Assets/Scripts/Editor`, com local inicial recomendado em `Assets/Scripts/Intrepid/Gameplay/Journey`. -- O World Editor pode manter um SO editorial proprio e publicar/consolidar um SO runtime do Journey em etapa explicita, para que playtest use apenas dados validados. -- O SO editorial do World Editor deve se chamar `WorldLayoutSO`. -- O SO runtime do Journey deve se chamar `JGraphSO`. -- A publicacao inicial do Journey deve ser uma acao explicita no World Editor, provavelmente um botao de commit/publicacao do Journey. -- O commit do Journey gera somente o contrato logico runtime: graph, endpoints, connections e dados relacionados. Stage generation/bake permanece responsabilidade exclusiva do Stage Editor. -- O Journey pode conter `JStage` como representacao logica runtime/publicada de um Stage no graph. -- O World Editor e o Stage Editor podem nascer separados. Na V1, a integracao entre eles pode continuar manual; a arquitetura deve permitir que futuramente um duplo clique em Stage abra o Stage Editor com o `StageDefinition` correto. -- `WorldTile = 24m` e `WorldLayerHeight = 24m` entram como defaults configuraveis no `WorldLayoutSO`. -- `Stage`, `JStage`, `JEndpoint` e `JConnection` devem ter ids estaveis por GUID/string. Tags podem complementar classificacao e consulta, mas nao substituem ids. -- Loading hints ficam fora da V1; `JConnection` deve continuar extensivel para dados futuros. -- `JGraphSO` deve registrar metadata minima de publicacao: `sourceWorldLayoutId`, `publishedAt`, `schemaVersion` e opcionalmente `contentHash`. -- Requirements ficam fora da estrutura concreta obrigatoria da V1, mas `JConnection` deve permanecer extensivel para recebe-los depois. -- Endpoint obrigatorio sem conexao deve bloquear commit como erro. Endpoint opcional sem conexao pode ser warning. -- A implementacao do World Editor deve usar `EditorWindow` + UI Toolkit, seguindo o padrao do Stage Editor existente. - -As decisoes abertas devem ser registradas como proximos topicos, nao misturadas no primeiro contrato: - -- como o runtime escolhe destino de Anchor multi-conexao; -- representacao visual de conexoes unidirecionais; -- estrutura concreta de requirements; -- protocolo de sync entre Stage Editor markers, `StageDefinition` editor-only e o asset runtime do Journey; -- detalhes de UX de tela propria de wiring; -- loading hints e politicas de streaming. -- consolidacao visual/operacional futura entre World Editor e Stage Editor. - -## Perguntas em Aberto - -- [x] `WorldTile = 24m x 24m` deve ser hardcoded no contrato inicial ou configuravel por `WorldLayoutSO` com default 24m? -- [x] `WorldLayerHeight = 24m` e uma constante normativa, um default editavel, ou apenas uma recomendacao de authoring? -- [x] O `StageDefinition` existente deve ser tratado como editor-only, nao como fonte runtime do Journey? -- [x] O SO editorial do World Editor deve se chamar `WorldLayoutSO`, `WorldAuthoringSO`, ou outro nome? -- [x] O asset runtime consultavel in game deve se chamar `JGraphSO`, seguindo prefixo `J` e sufixo `SO`? -- [x] O World Editor edita diretamente o asset runtime do Journey ou mantem dados editoriais e exporta/sincroniza um asset runtime? -- [x] Qual acao explicita publica/consolida o SO editorial para o SO runtime: botao no editor, menu command, build step, ou combinacao? -- [x] Como o runtime SO registra versao/hash/origem para diagnosticar divergencia entre authoring e playtest? -- [x] O botao de commit do Journey deve apenas validar/publicar `JGraphSO`, ou tambem atualizar/gerar assets auxiliares? -- [x] A integracao por duplo clique deve abrir o Stage Editor existente passando o `StageDefinition` do Stage, ou criar uma camada comum de selecao/editor shell? -- [x] Um `JEndpointLift` deve permanecer limitado a uma unica `JConnection` no contrato inicial? -- [x] Anchor multi-destino precisa de regra runtime agora, ou basta registrar que o endpoint pode ter varias conexoes e adiar a selecao? -- [x] Requirements devem entrar no contrato inicial como estrutura concreta ou apenas como extensao planejada de `JConnection`? -- [x] Loading hints entram na primeira decisao ou ficam para uma decisao de streaming/loading? -- [x] O build valido deve exigir todos os endpoints obrigatorios conectados, ou isso fica como validacao por profile/contexto? -- [x] Como identificar estavelmente endpoints: GUID proprio, id derivado do Stage, ou referencia Unity serializada? -- [x] O Stage Editor Sync e requisito do primeiro plano de execucao ou uma fase posterior? -- [x] Qual tecnologia de window/editor UI deve ser usada pelo World Editor? - -## Criterio para Encerrar - -Esta agenda pode virar decisao quando houver acordo sobre: - -- quais invariantes entram no contrato inicial do World Editor / Journey; -- quais topicos ficam explicitamente fora do primeiro contrato; -- a fronteira de fonte de verdade entre o asset runtime do Journey e os dados editoriais do World/Stage Editor; -- a fronteira entre `StageDefinition` editor-only e o asset runtime consultavel pelo Journey; -- a fronteira entre SO editorial de World e SO runtime consolidado de Journey; -- o mecanismo minimo de publicacao/consolidacao entre authoring e runtime; -- que a publicacao do World Editor e limitada aos dados logicos do Journey; -- os nomes `WorldLayoutSO` e `JGraphSO`; -- o fluxo inicial de integracao entre World Editor e Stage Editor; -- a cardinalidade inicial de Socket, Anchor e Lift; -- a granularidade minima de Requirements e Loading Hints no primeiro spec; -- a estrategia inicial para ids de Stage, endpoint e connection. -- a tecnologia de editor para a primeira implementacao do World Editor. - -## Discussion - -Entrada inicial recebida em 2026-06-19: proposta "Gardens of Nil - World Editor / Journey Spec" com vinte secoes cobrindo objetivo, principios, unidades, Stage, Journey, endpoints, conexoes, validacoes, fluxo de edicao, visualizacao, decisoes confirmadas e questoes abertas. - -Atualizacao de discussao em 2026-06-19: o usuario apontou que o Stage Editor ja esta em andamento em `Assets/Scripts/Editor/GardensStageEditor`, incluindo `StageDefinition`. A orientacao proposta e que `StageDefinition` seja usado somente no editor, enquanto os dados de Journey devem ser consultaveis in game e ficar em `Assets/Scripts/Intrepid/Gameplay/Journey`. Verificacao local confirmou que `StageDefinition.cs` esta sob `Assets/Scripts/Editor/GardensStageEditor` e modela dados internos/editoriais do Stage. - -Atualizacao de discussao em 2026-06-19: o usuario propĂ´s manter ambos os SOs: um SO editorial para edicao de Stage/World e um SO runtime consolidado no lado Journey. A motivacao e evitar que edicoes em andamento afetem playtest. A recomendacao da agenda foi ajustada para dois SOs com publicacao explicita. - -Atualizacao de discussao em 2026-06-19: o usuario definiu os nomes `WorldLayoutSO` para o SO editorial do World Editor e `JourneyGraphSO` para o SO runtime do Journey. Tambem indicou que havera dois editores inicialmente, World e Stage, com possibilidade futura de unificacao; o fluxo desejado e duplo clique em um Stage no World Editor abrir o Stage Editor com o `StageDefinition` correto. Para publicacao, a preferencia inicial e um botao no World Editor para commitar/publicar o Journey. - -Atualizacao de discussao em 2026-06-19: o usuario fechou que o botao de commit do World Editor gera somente as coisas do Journey: graph, connections, endpoints e demais dados logicos. Stages continuam sendo gerados a partir de cada Stage no Stage Editor, pois sao outra preocupacao. Os SOs de endpoints e connections publicados pelo World Editor devem facilitar a integracao posterior com os Stages. - -Atualizacao de discussao em 2026-06-19: o usuario fechou os defaults e escopo de V1. `WorldTile` e `WorldLayerHeight` ficam como defaults configuraveis de 24m no `WorldLayoutSO`. Identidade deve usar GUID/string estavel, com tags como apoio de classificacao quando util. `JEndpointLift` fica 1:1. Anchor multi-destino pode existir no graph, sem tratar selecao nesta V1 porque a selecao ja existe in game. Loading hints ficam fora da V1. O Journey pode ter `JStage` como representacao logica de Stage. O botao do World Editor nao gera Stage de cena; bake continua no Stage Editor. Integracao World/Stage Editor fica manual na V1. - -Atualizacao de discussao em 2026-06-19: o usuario definiu a convencao de nomes do Journey: elementos usam prefixo `J` e ScriptableObjects terminam com `SO`. Com isso, o runtime graph passa a ser `JGraphSO`. Tambem aceitou as sugestoes finais: metadata minima de publicacao no SO runtime, requirements como extensao futura de `JConnection`, e validacao onde endpoint obrigatorio sem conexao e erro enquanto endpoint opcional sem conexao e warning. - -Atualizacao de discussao em 2026-06-19: o usuario definiu que o World Editor deve usar a mesma tecnologia ja usada no Stage Editor para gerenciamento da Window. Verificacao local mostrou que o Stage Editor atual esta em `Assets/Scripts/Editor/GON/StageEditor`, usando `EditorWindow`, UI Toolkit (`UnityEngine.UIElements` / `UnityEditor.UIElements`) e canvas custom via `VisualElement.generateVisualContent`. - -## Resolution - -Aceita em 2026-06-19. - -Resolucao: emitir uma decisao V1 para o World Editor / Journey com `WorldLayoutSO` como SO editorial do World Editor e `JGraphSO` como SO runtime publicado do Journey. O commit do World Editor publica somente dados logicos do Journey: `JStage`, endpoints, connections, graph e metadata de publicacao. Stage scene generation e bake permanecem no Stage Editor. O World Editor V1 deve usar `EditorWindow` + UI Toolkit seguindo o Stage Editor existente. Requirements concretos, loading hints, sync profundo com Stage Editor, wiring avancado e consolidacao visual dos editores ficam fora da V1. diff --git a/discussion/workflow/decisions/DEC-0001-world-editor-journey-v1.md b/discussion/workflow/decisions/DEC-0001-world-editor-journey-v1.md deleted file mode 100644 index 0b199a0..0000000 --- a/discussion/workflow/decisions/DEC-0001-world-editor-journey-v1.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -id: DEC-0001 -ticket: world-editor-journey-spec -title: World Editor / Journey V1 -status: accepted -created: 2026-06-19 -accepted: 2026-06-19 -agenda: AGD-0001 -plans: - - PLN-0001 - - PLN-0002 - - PLN-0003 - - PLN-0004 -tags: - - world-editor - - journey - - stage-contract - - architecture ---- - -## Status - -Accepted. - -This decision defines the V1 architecture contract for the Gardens of Nil World Editor and Journey runtime graph. - -## Contexto - -Gardens of Nil needs a World Editor to author the macro-world without taking over Stage Editor responsibilities. The Stage Editor already exists under `Assets/Scripts/Editor/GON/StageEditor` and uses `StageDefinition` for editor-only stage authoring. - -The Journey system is the logical contract for travel between Stages. It MUST be available to gameplay/runtime code and MUST NOT depend on types located under `Assets/Scripts/Editor`. - -## Decisao - -The World Editor V1 SHALL use a two-asset model: - -- `WorldLayoutSO` is the editor/authoring ScriptableObject for the World Editor. -- `JGraphSO` is the published runtime ScriptableObject for Journey. - -Journey elements MUST use the `J` prefix. Journey ScriptableObjects MUST also use the `SO` suffix. - -The World Editor SHALL publish Journey data through an explicit commit action in the World Editor UI. This commit MUST generate only logical Journey data: `JGraphSO`, `JStage`, `JEndpoint` records, `JConnection` records, graph topology, validation-relevant flags, and publication metadata. - -The World Editor commit MUST NOT generate Stage scenes, Stage geometry, internal Stage objects, baked Stage content, or Stage Editor data. Stage generation and bake remain Stage Editor responsibilities. - -## Rationale - -Separating `WorldLayoutSO` from `JGraphSO` protects playtest/runtime from incomplete authoring edits. It also keeps editor-only structures out of gameplay assemblies and allows richer authoring data without forcing that complexity into runtime. - -Keeping Stage bake separate preserves the current Stage Editor ownership model. The World Editor defines where Stages are, how they connect, and what Journey contract they expose; the Stage Editor decides how each Stage implements its internal scene. - -Using the same editor window technology as the Stage Editor reduces implementation risk and keeps a future editor consolidation path open. - -## Invariantes / Contrato - -- World Editor is 2D per `WorldLayer`. -- `WorldTile` default size is `24m x 24m`. -- `WorldLayerHeight` default is `24m`. -- `WorldTile` size and `WorldLayerHeight` MUST be configurable on `WorldLayoutSO`; V1 defaults are 24m. -- A Stage belongs to exactly one `WorldLayer`. -- A Stage occupies a set of WorldTiles. -- A WorldTile MUST NOT be occupied by more than one Stage in the same WorldLayer. -- Physical space MUST always belong to a Stage. -- `JConnection` MUST NOT own physical space. -- If a corridor, antecamera, transition room, long lift, or loading mask has physical presence, it MUST be modeled as a Stage. -- `JGraphSO` MAY contain a logical Stage representation named `JStage`. -- `JStage`, `JEndpoint`, and `JConnection` MUST have stable GUID/string ids. -- Tags MAY be used for classification and lookup, but MUST NOT replace stable identity. -- Initial endpoint types are `JEndpointSocket`, `JEndpointAnchor`, and `JEndpointLift`. -- Endpoint compatibility in V1 is same-type only: socket to socket, anchor to anchor, lift to lift. -- `JConnection` connects exactly two endpoints. -- `JEndpointSocket` accepts at most one `JConnection`. -- `JEndpointAnchor` may have multiple `JConnections`. -- `JEndpointLift` accepts exactly one `JConnection` in V1. -- `JEndpointAnchor` and `JEndpointLift` MUST NOT coexist on the same WorldTile. -- Sockets MAY coexist with anchors or lifts. -- Loading hints are out of V1. -- Concrete requirement modeling is out of V1; `JConnection` MUST remain extensible so requirements can be added later. -- A required endpoint without a connection MUST block World Editor commit as an error. -- An optional endpoint without a connection MAY be reported as a warning. -- `JGraphSO` MUST record minimum publication metadata: `sourceWorldLayoutId`, `publishedAt`, and `schemaVersion`. -- `JGraphSO` SHOULD record `contentHash` when cheap and deterministic enough to be useful. - -## Impactos - -Spec: - -- A future spec pass MUST document `WorldLayoutSO`, `JGraphSO`, `JStage`, endpoint types, connection rules, publication metadata, and validation levels in English. - -Runtime: - -- Runtime Journey code SHALL live outside `Assets/Scripts/Editor`, with initial target path `Assets/Scripts/Intrepid/Gameplay/Journey`. -- Runtime MUST consume `JGraphSO`, not `WorldLayoutSO` and not `StageDefinition`. - -Editor: - -- World Editor V1 SHALL use `EditorWindow` with UI Toolkit (`UnityEngine.UIElements` and `UnityEditor.UIElements`). -- World Editor V1 SHOULD use a custom canvas based on `VisualElement.generateVisualContent`, matching the current Stage Editor approach. -- Stage Editor integration remains manual in V1. -- Future integration MAY allow double-clicking a Stage in the World Editor to open the Stage Editor with the correct `StageDefinition`. - -Stage Editor: - -- `StageDefinition` remains editor-only. -- Stage bake/generation remains owned by the Stage Editor. -- Stage scenes are not generated by the World Editor commit button. - -Firmware: - -- No impact. - -Tooling: - -- A plan is required before code/spec execution. -- Validation should distinguish commit-blocking errors from warnings. - -## Fora de Escopo - -- Runtime destination selection UI/logic for multi-connection anchors. -- Visual representation of unidirectional connections. -- Concrete `JRequirement` structure. -- Loading hints and streaming policies. -- Deep Stage Editor marker sync. -- Unified World/Stage Editor shell. -- Automatic Stage Editor opening from the World Editor in V1. - -## Referencias - -- Agenda: `discussion/workflow/agendas/AGD-0001-world-editor-journey-spec.md` -- Existing Stage Editor path: `Assets/Scripts/Editor/GON/StageEditor` -- Existing Stage Definition: `Assets/Scripts/Editor/GON/StageEditor/StageDefinition.cs` - -## Propagacao Necessaria - -- Create a plan from this decision before implementation. -- Add or update English canonical specs for World Editor / Journey before or alongside code execution, depending on the plan. -- Implement runtime Journey types under `Assets/Scripts/Intrepid/Gameplay/Journey`. -- Implement World Editor authoring types under an editor-only path. -- Ensure runtime assemblies do not depend on `Assets/Scripts/Editor` types. - -## Revision Log - -- 2026-06-19: Initial accepted decision from AGD-0001. diff --git a/discussion/workflow/plans/PLN-0001-world-journey-spec.md b/discussion/workflow/plans/PLN-0001-world-journey-spec.md deleted file mode 100644 index 462d05e..0000000 --- a/discussion/workflow/plans/PLN-0001-world-journey-spec.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -id: PLN-0001 -ticket: world-editor-journey-spec -title: World Editor / Journey V1 Spec -status: done -created: 2026-06-19 -decisions: - - DEC-0001 -tags: - - spec - - world-editor - - journey ---- - -## Briefing - -Write the canonical English specification for the World Editor / Journey V1 contract accepted in DEC-0001. This plan produces documentation only. It does not implement Unity code. - -## Source Decisions - -- `DEC-0001`: World Editor / Journey V1. - -## Target - -Create or update the canonical spec location used by this repository with an English spec covering: - -- `WorldLayoutSO` as editor authoring data. -- `JGraphSO` as runtime Journey data. -- `JStage`, `JEndpoint`, `JEndpointSocket`, `JEndpointAnchor`, `JEndpointLift`, and `JConnection`. -- World tile/layer defaults and configurability. -- Commit/publication boundary. -- Validation error and warning rules. -- V1 exclusions. - -## Scope - -- Inspect the repository for the canonical spec/docs location before editing. -- Add an English World Editor / Journey V1 spec if none exists. -- If a relevant spec already exists, update it instead of creating a parallel source of truth. -- Link the spec back to `DEC-0001`. -- Document that runtime Journey code lives outside `Assets/Scripts/Editor`. -- Document that Stage bake remains owned by the Stage Editor. - -## Out Of Scope - -- C# implementation. -- Unity Editor UI implementation. -- Stage Editor changes. -- Loading hints, concrete requirements, deep Stage marker sync, and editor unification. - -## Execution Plan - -1. Locate canonical docs/specs. - - Use `rg --files` to find existing `spec`, `docs`, or design contract files. - - Target the existing canonical location if present. - - If no canonical spec location exists, create a minimal `specs/` or repository-approved equivalent only after confirming repo convention from nearby files. - -2. Draft the V1 spec. - - Add sections for authoring model, runtime model, endpoint model, connection model, commit flow, validation, and exclusions. - - Use normative language: `MUST`, `MUST NOT`, `SHALL`, `SHOULD`, and `MAY`. - - Keep the spec in English. - -3. Define V1 data contracts in prose. - - `WorldLayoutSO`: default `worldTileSize = 24m`, default `worldLayerHeight = 24m`, stages, occupied tiles, authoring endpoints, and editor metadata. - - `JGraphSO`: `sourceWorldLayoutId`, `publishedAt`, `schemaVersion`, optional `contentHash`, `JStage[]`, `JEndpoint[]`, and `JConnection[]`. - - `JStage`: logical runtime stage record, not scene generation data. - -4. Define validation levels. - - Errors block commit. - - Warnings do not block commit. - - Required endpoint without connection is an error. - - Optional endpoint without connection is a warning. - -5. Document non-goals. - - No loading hints in V1. - - No concrete requirements in V1. - - No Stage scene generation from World Editor commit. - - No automatic World Editor to Stage Editor integration in V1. - -## Acceptance Criteria - -- The spec is in English. -- The spec references `DEC-0001`. -- The spec names `WorldLayoutSO` and `JGraphSO`. -- The spec states that Journey elements use prefix `J`, and Journey ScriptableObjects use suffix `SO`. -- The spec states that World Editor commit publishes only logical Journey data. -- The spec states that runtime MUST consume `JGraphSO`, not `WorldLayoutSO` or `StageDefinition`. - -## Validation - -- Review the spec against every invariant in `DEC-0001`. -- Search the spec for residual Portuguese text before finalizing. -- Confirm no duplicate contradictory spec was created. -- No automated tests are required for this plan. - -## Risks - -- The repository may not yet have a canonical specs directory. -- If spec location is unclear, stop before creating a new documentation tree and ask for direction. -- Over-documenting future features could accidentally make them V1 commitments. diff --git a/discussion/workflow/plans/PLN-0002-journey-runtime-model.md b/discussion/workflow/plans/PLN-0002-journey-runtime-model.md deleted file mode 100644 index 2f208ad..0000000 --- a/discussion/workflow/plans/PLN-0002-journey-runtime-model.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -id: PLN-0002 -ticket: world-editor-journey-spec -title: Journey Runtime Model -status: done -created: 2026-06-19 -decisions: - - DEC-0001 -tags: - - runtime - - journey - - scriptableobject ---- - -## Briefing - -Implement the runtime Journey data model consumed by gameplay. This plan creates runtime-safe types under `Assets/Scripts/Intrepid/Gameplay/Journey` and keeps them independent from editor-only code. - -## Source Decisions - -- `DEC-0001`: World Editor / Journey V1. - -## Target - -Create the runtime Journey model: - -- `JGraphSO` -- `JStage` -- `JEndpoint` -- `JEndpointSocket` -- `JEndpointAnchor` -- `JEndpointLift` -- `JConnection` -- supporting enums/value types for endpoint kind, directionality, world tile coordinates, socket face, and validation metadata when needed. - -## Scope - -- Runtime serializable data only. -- ScriptableObject asset type for `JGraphSO`. -- Stable GUID/string ids for `JStage`, `JEndpoint`, and `JConnection`. -- Publication metadata on `JGraphSO`. -- Query helpers that do not depend on `UnityEditor`. - -## Out Of Scope - -- World Editor UI. -- Commit/publish pipeline. -- Stage scene generation. -- Stage Editor bake. -- Loading hints. -- Concrete `JRequirement` structure. -- Runtime destination selection UI for multi-anchor connections. - -## Execution Plan - -1. Create runtime folder structure. - - Target `Assets/Scripts/Intrepid/Gameplay/Journey`. - - Preserve existing `.meta` files if Unity already created the folder. - -2. Add `JGraphSO`. - - Implement as a `ScriptableObject`. - - Fields: `sourceWorldLayoutId`, `publishedAt`, `schemaVersion`, optional `contentHash`, `List`, `List`, `List`. - - Add read-only query methods for stages, endpoints, and connections by id. - - Do not reference `UnityEditor`. - -3. Add `JStage`. - - Serializable runtime logical Stage record. - - Fields: stable id, display/name field, world layer, occupied world tiles, optional tag references if compatible with existing tag conventions. - - Do not include Stage scene generation or Stage bake data. - -4. Add endpoint model. - - Use a serializable base record or discriminated model that Unity can serialize reliably. - - Include stable id, owning `JStage` id, world tile coordinate, endpoint kind, required flag, and type-specific data. - - Socket data includes face. - - Anchor data supports multiple connections by model validation, not by embedded scene interaction logic. - - Lift data includes vertical alignment requirements by world tile coordinate and distinct world layer. - -5. Add `JConnection`. - - Fields: stable id, endpoint A id, endpoint B id, directionality. - - Keep extensibility for future requirements without implementing a concrete requirement system in V1. - - Do not add loading hints in V1. - -6. Add query helpers. - - Connections for endpoint id. - - Endpoints for stage id. - - Stage lookup by id. - - Optional lightweight validation-facing helpers without editor dependencies. - -## Acceptance Criteria - -- Runtime Journey files live under `Assets/Scripts/Intrepid/Gameplay/Journey`. -- No runtime Journey file imports `UnityEditor`. -- Journey runtime ScriptableObject is named `JGraphSO`. -- Runtime Journey element names use prefix `J`. -- `JGraphSO` contains publication metadata required by `DEC-0001`. -- `JStage`, `JEndpoint`, and `JConnection` use stable GUID/string ids. -- Loading hints and concrete requirements are not implemented as V1 commitments. - -## Validation - -- Unity compile succeeds. -- Search `Assets/Scripts/Intrepid/Gameplay/Journey` for `UnityEditor`; it must return no runtime dependency. -- Create a temporary `JGraphSO` asset in Unity and verify its serialized fields are visible and persist. -- No automated tests are required for this phase. - -## Risks - -- Unity serialization may not handle polymorphic endpoint classes as desired without `[SerializeReference]`; choose a serialization pattern consistent with the Unity version and repository style. -- Existing tag APIs may be runtime-safe but should not be forced into V1 if they complicate serialization. -- Overbuilding query APIs could harden runtime behavior that is intentionally out of scope. diff --git a/discussion/workflow/plans/PLN-0003-world-editor-authoring.md b/discussion/workflow/plans/PLN-0003-world-editor-authoring.md deleted file mode 100644 index f7d817d..0000000 --- a/discussion/workflow/plans/PLN-0003-world-editor-authoring.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -id: PLN-0003 -ticket: world-editor-journey-spec -title: World Editor Authoring UI and WorldLayoutSO -status: done -created: 2026-06-19 -decisions: - - DEC-0001 -tags: - - editor - - world-editor - - authoring ---- - -## Briefing - -Implement the editor-only World Editor authoring surface and `WorldLayoutSO`. This plan creates the authoring model and window technology, but does not publish `JGraphSO`; publishing is covered by PLN-0004. - -## Source Decisions - -- `DEC-0001`: World Editor / Journey V1. - -## Target - -Create a V1 World Editor under an editor-only path, using the same window technology as the Stage Editor: - -- `EditorWindow` -- UI Toolkit -- custom canvas via `VisualElement.generateVisualContent` - -Create `WorldLayoutSO` as the editor/authoring ScriptableObject. - -## Scope - -- Editor-only `WorldLayoutSO`. -- Editor window shell. -- Top bar with `WorldLayoutSO` selection and create/save controls. -- 2D layer selector. -- Grid canvas for WorldTiles. -- Basic stage footprint authoring. -- Authoring data for sockets, anchors, lifts, and required/optional endpoint flags. -- Manual Stage Editor integration only. - -## Out Of Scope - -- Runtime `JGraphSO` implementation; covered by PLN-0002. -- Commit/publish pipeline; covered by PLN-0004. -- Stage scene bake. -- Automatic double-click opening of Stage Editor in V1. -- Advanced wiring screen. -- Loading hints and concrete requirements. - -## Execution Plan - -1. Create editor folder structure. - - Target `Assets/Scripts/Editor/GON/WorldEditor`. - - Mirror Stage Editor conventions from `Assets/Scripts/Editor/GON/StageEditor`. - -2. Implement `WorldLayoutSO`. - - ScriptableObject under editor-only code. - - Fields: stable layout id, `worldTileSize` default 24, `worldLayerHeight` default 24, stages, authoring endpoints, authoring connections, and editor metadata. - - Stages in `WorldLayoutSO` reference or identify their editor `StageDefinition` where needed, but this remains authoring-only. - -3. Implement authoring records. - - World stage record with stable id, display name, layer, occupied tiles, color, optional `StageDefinition` reference. - - Authoring endpoint records for socket, anchor, and lift. - - Authoring connection records with stable ids and endpoint ids. - - Required endpoint flag. - -4. Implement `WorldEditorWindow`. - - Use `EditorWindow`. - - Use UI Toolkit. - - Add menu item under the same family as Stage Editor, e.g. `Tools/Gardens Of Nil/World Editor`. - - Top bar: `WorldLayoutSO` object field, create button, save button, validate button placeholder, commit button placeholder or disabled until PLN-0004. - -5. Implement `WorldMapCanvas`. - - Use `VisualElement.generateVisualContent`. - - Render current `WorldLayer`. - - Render grid, stage footprints, occupied tiles, endpoints, and basic connection indicators. - - Implement pan/zoom behavior consistent with Stage Editor canvas patterns. - -6. Implement basic editing operations. - - Create/select Stage. - - Paint or remove occupied WorldTiles for selected Stage. - - Prevent obvious same-layer tile overlap at authoring time where possible. - - Add socket on free tile face. - - Add anchor or lift to tile, enforcing no anchor/lift coexistence. - -## Acceptance Criteria - -- World Editor files live under `Assets/Scripts/Editor/GON/WorldEditor`. -- `WorldLayoutSO` defaults to 24m tile size and 24m layer height. -- World Editor opens from Unity menu. -- World Editor uses `EditorWindow` and UI Toolkit. -- Canvas uses `VisualElement.generateVisualContent`. -- `WorldLayoutSO` is not consumed by runtime Journey code. -- Stage Editor integration remains manual in V1. - -## Validation - -- Unity compile succeeds. -- Open the World Editor window in Unity. -- Create a `WorldLayoutSO` asset. -- Author two stages on the same layer and verify same-tile overlap is prevented or reported. -- Add socket, anchor, and lift authoring endpoints. -- Verify anchor and lift cannot coexist on the same WorldTile. -- Verify no generated Stage scene or Stage bake occurs from these authoring actions. -- No automated tests are required for this phase. - -## Risks - -- UI scope can grow quickly; keep V1 focused on authoring enough data for commit. -- If `StageDefinition` moved or changes namespace, keep references isolated inside editor-only code. -- Editor-only `WorldLayoutSO` should not leak into runtime scenes or runtime Journey APIs. diff --git a/discussion/workflow/plans/PLN-0004-journey-commit-validation.md b/discussion/workflow/plans/PLN-0004-journey-commit-validation.md deleted file mode 100644 index 30670ba..0000000 --- a/discussion/workflow/plans/PLN-0004-journey-commit-validation.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -id: PLN-0004 -ticket: world-editor-journey-spec -title: Journey Commit and Validation -status: done -created: 2026-06-19 -decisions: - - DEC-0001 -tags: - - editor - - validation - - journey ---- - -## Briefing - -Implement the explicit World Editor commit action that validates `WorldLayoutSO` and publishes only logical Journey data into `JGraphSO`. - -## Source Decisions - -- `DEC-0001`: World Editor / Journey V1. - -## Target - -Create the publication pipeline from `WorldLayoutSO` to `JGraphSO`: - -- validation service, -- error/warning reporting, -- commit button behavior, -- deterministic conversion to runtime logical Journey data, -- publication metadata. - -## Scope - -- Editor-only validation and commit code. -- Conversion from World Editor authoring records to runtime `JGraphSO`. -- Generate/update `JStage`, `JEndpoint`, and `JConnection` records. -- Preserve stable ids across repeated commits. -- Block commit on errors. -- Allow commit with warnings. - -## Out Of Scope - -- Stage scene generation. -- Stage bake. -- Loading hints. -- Concrete requirements. -- Runtime traversal behavior. -- Automatic Stage Editor sync. -- Unified editor shell. - -## Execution Plan - -1. Add validation types. - - Target `Assets/Scripts/Editor/GON/WorldEditor`. - - Create severity enum: error and warning. - - Create validation message record with severity, message, and optional authoring object id. - -2. Add `WorldLayoutValidator`. - - Validate same-layer tile overlap. - - Validate connection endpoints exist. - - Validate connection does not connect an endpoint to itself. - - Validate endpoint type compatibility. - - Validate socket cardinality: at most one connection. - - Validate socket face is a free Stage tile face. - - Validate sockets connect opposite faces on adjacent tiles when physically connected. - - Validate anchor/lift cannot coexist on same WorldTile. - - Validate lift alignment by x/y and differing layer. - - Validate lift cardinality: exactly one connection when required, at most one connection in all cases. - - Validate required endpoint without connection as error. - - Validate optional endpoint without connection as warning. - -3. Add `JourneyCommitter`. - - Input: `WorldLayoutSO` and target `JGraphSO`. - - Run validation before writing. - - If errors exist, abort without modifying `JGraphSO`. - - If only warnings exist, allow commit and show warnings. - - Write `sourceWorldLayoutId`, `publishedAt`, `schemaVersion`, and optional deterministic `contentHash`. - - Write `JStage`, endpoint, and connection records only. - - Do not create Stage scenes, Stage geometry, Stage objects, or Stage bake artifacts. - -4. Wire commit into `WorldEditorWindow`. - - Add or enable commit button. - - Let the user choose/create a target `JGraphSO`. - - Display validation messages after commit attempt. - - Mark changed assets dirty and save through Unity asset APIs. - -5. Add validation display. - - Reuse UI Toolkit patterns from the Stage Editor inspector/top bar where practical. - - Show errors and warnings clearly. - - Keep visual polish secondary to correctness for V1. - -## Acceptance Criteria - -- Commit button publishes `JGraphSO` only. -- Commit button never generates Stage scenes or Stage bake output. -- Commit aborts when validation errors exist. -- Commit can proceed with warnings. -- Required endpoint without connection is an error. -- Optional endpoint without connection is a warning. -- `JGraphSO` publication metadata is written on successful commit. -- Repeated commits preserve stable ids rather than regenerating ids from names or positions. - -## Validation - -- Unity compile succeeds. -- Manually verify commit of a minimal valid layout with two stages and a socket connection. -- Manually verify commit aborts when two stages overlap on the same WorldLayer tile. -- Manually verify commit aborts when a required endpoint has no connection. -- Manually verify commit allows an optional unconnected endpoint and reports a warning. -- Manually verify no Stage scene, Stage geometry, or Stage bake output is created by commit. -- No automated tests are required for this phase. - -## Risks - -- Id preservation can be broken if conversion derives ids from mutable fields; use stored stable ids from authoring data. -- Validation and commit can become coupled to UI; keep validation and conversion in separate services. -- Content hash can become noisy if it includes non-deterministic ordering or timestamps; omit or narrow it if deterministic hashing is not cheap.