From da4f1caaa73615e74a7ec769b982100b366a7b79 Mon Sep 17 00:00:00 2001 From: Nilton Constantino Date: Fri, 19 Jun 2026 13:40:55 +0100 Subject: [PATCH] implements PLN-0003 world editor authoring --- .../GON/WorldEditor/WorldEditorColors.cs | 15 + .../GON/WorldEditor/WorldEditorColors.cs.meta | 11 + .../GON/WorldEditor/WorldEditorState.cs | 19 + .../GON/WorldEditor/WorldEditorState.cs.meta | 11 + .../GON/WorldEditor/WorldEditorWindow.cs | 275 +++++++++++++ .../GON/WorldEditor/WorldEditorWindow.cs.meta | 11 + .../Editor/GON/WorldEditor/WorldLayoutSO.cs | 175 ++++++++ .../GON/WorldEditor/WorldLayoutSO.cs.meta | 11 + .../Editor/GON/WorldEditor/WorldMapCanvas.cs | 375 ++++++++++++++++++ .../GON/WorldEditor/WorldMapCanvas.cs.meta | 11 + discussion/index.ndjson | 2 +- .../plans/PLN-0003-world-editor-authoring.md | 2 +- 12 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs.meta create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs.meta create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs.meta create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs.meta create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs create mode 100644 Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs.meta diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs new file mode 100644 index 0000000..4b67450 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs @@ -0,0 +1,15 @@ +using UnityEngine; + +namespace Editor.GON.WorldEditor +{ + public static class WorldEditorColors + { + public static readonly Color Background = new(0.08f, 0.09f, 0.1f, 1f); + 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 Anchor = new(0.35f, 0.9f, 0.75f, 1f); + public static readonly Color Lift = new(0.95f, 0.45f, 0.35f, 1f); + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs.meta new file mode 100644 index 0000000..c9c5c37 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorColors.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c934ed58cc61487aaf68600c93e0d8b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs new file mode 100644 index 0000000..6ef1a41 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs @@ -0,0 +1,19 @@ +namespace Editor.GON.WorldEditor +{ + public enum WorldEditorTool + { + Select = 0, + PaintStage = 1, + EraseStage = 2, + Socket = 3, + Anchor = 4, + Lift = 5 + } + + public sealed class WorldEditorState + { + public int activeLayer; + public string selectedStageId; + public WorldEditorTool activeTool = WorldEditorTool.PaintStage; + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs.meta new file mode 100644 index 0000000..9e76da8 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6782fb3fdb8f49d5939b0c92f86adad9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs new file mode 100644 index 0000000..721d0f6 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using Intrepid.Gameplay.Journey; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Editor.GON.WorldEditor +{ + public sealed class WorldEditorWindow : EditorWindow + { + private WorldLayoutSO layout; + private WorldEditorState state; + private VisualElement topBar; + private WorldMapCanvas canvas; + private Label infoLabel; + + [MenuItem("Tools/Gardens Of Nil/World Editor")] + public static void Open() + { + var window = GetWindow(); + window.titleContent = new GUIContent("Gardens World Editor"); + window.minSize = new Vector2(900, 600); + window.Show(); + } + + public void CreateGUI() + { + state ??= new WorldEditorState(); + + rootVisualElement.Clear(); + rootVisualElement.style.flexDirection = FlexDirection.Column; + + topBar = new VisualElement + { + style = + { + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + paddingLeft = 8, + paddingRight = 8, + paddingTop = 6, + paddingBottom = 6, + borderBottomWidth = 1, + borderBottomColor = new Color(1f, 1f, 1f, 0.12f) + } + }; + rootVisualElement.Add(topBar); + + canvas = new WorldMapCanvas(this, state); + canvas.SetLayout(layout); + rootVisualElement.Add(canvas); + + infoLabel = new Label + { + style = + { + paddingLeft = 8, + paddingTop = 4, + paddingBottom = 4 + } + }; + rootVisualElement.Add(infoLabel); + + RebuildTopBar(); + RebuildInfo(); + } + + public void MutateLayout(string undoName, Action mutation) + { + if (layout is null || mutation is null) + { + return; + } + + Undo.RecordObject(layout, undoName); + mutation(); + layout.EnsureDefaults(); + EditorUtility.SetDirty(layout); + canvas?.RequestRepaint(); + RebuildTopBar(); + RebuildInfo(); + Repaint(); + } + + public WorldLayoutSO Layout => layout; + + private void RebuildTopBar() + { + if (topBar is null) + { + return; + } + + topBar.Clear(); + + var layoutField = new ObjectField("World Layout") + { + objectType = typeof(WorldLayoutSO), + value = layout, + style = + { + minWidth = 240 + } + }; + layoutField.RegisterValueChangedCallback(evt => SetLayout(evt.newValue as WorldLayoutSO)); + topBar.Add(layoutField); + + topBar.Add(new Button(CreateNewLayoutAsset) + { + text = "New", + style = { marginLeft = 6 } + }); + + if (layout is null) + { + return; + } + + 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 } + }); + + topBar.Add(new Button(ValidatePlaceholder) + { + text = "Validate", + style = { marginLeft = 8 } + }); + + var commit = new Button(CommitPlaceholder) + { + text = "Commit Journey", + style = { marginLeft = 4 } + }; + commit.SetEnabled(false); + topBar.Add(commit); + + topBar.Add(new Button(SaveLayout) + { + text = "Save", + style = { marginLeft = 8 } + }); + } + + private void RebuildInfo() + { + if (infoLabel is null) + { + return; + } + + if (layout is null) + { + infoLabel.text = "Select or create a WorldLayoutSO."; + return; + } + + var selected = layout.FindStage(state.selectedStageId); + infoLabel.text = selected is null + ? "No stage selected." + : "Selected: " + selected.displayName + " (" + selected.id + ")"; + } + + private void SetLayout(WorldLayoutSO newLayout) + { + layout = newLayout; + layout?.EnsureDefaults(); + if (layout?.FindStage(state.selectedStageId) is null) + { + state.selectedStageId = null; + } + + canvas?.SetLayout(layout); + RebuildTopBar(); + RebuildInfo(); + } + + private void CreateNewLayoutAsset() + { + var path = EditorUtility.SaveFilePanelInProject( + "Create WorldLayoutSO", + "WorldLayout", + "asset", + "Choose where to create the new WorldLayoutSO asset."); + + if (string.IsNullOrEmpty(path)) + { + return; + } + + var asset = CreateInstance(); + asset.EnsureDefaults(); + AssetDatabase.CreateAsset(asset, path); + AssetDatabase.SaveAssets(); + SetLayout(asset); + } + + private void CreateStage() + { + MutateLayout("Create world stage", delegate + { + var stage = new WorldStageAuthoring + { + id = layout.CreateId("stage"), + displayName = "Stage " + (layout.stages.Count + 1), + worldLayer = state.activeLayer, + color = UnityEngine.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; + }); + } + + private void SaveLayout() + { + if (layout is null) + { + return; + } + + EditorUtility.SetDirty(layout); + AssetDatabase.SaveAssets(); + Debug.Log("Saved WorldLayoutSO: " + AssetDatabase.GetAssetPath(layout)); + } + + private void ValidatePlaceholder() + { + EditorUtility.DisplayDialog("World Editor", "Validation is implemented by PLN-0004.", "OK"); + } + + private void CommitPlaceholder() + { + EditorUtility.DisplayDialog("World Editor", "Journey commit is implemented by PLN-0004.", "OK"); + } + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs.meta new file mode 100644 index 0000000..9fbc4d7 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a24a25a37da44f078a55114d872d2f66 +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 new file mode 100644 index 0000000..4cd0efa --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using Editor.GON.StageEditor; +using Intrepid.Gameplay.Journey; +using UnityEngine; + +namespace Editor.GON.WorldEditor +{ + [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 List stages = new(); + public List endpoints = new(); + public List connections = new(); + + public void EnsureDefaults() + { + if (string.IsNullOrWhiteSpace(id)) + { + id = Guid.NewGuid().ToString("N"); + } + + if (worldTileSize <= 0f) + { + worldTileSize = 24f; + } + + if (worldLayerHeight <= 0f) + { + worldLayerHeight = 24f; + } + + stages ??= new List(); + endpoints ??= new List(); + connections ??= new List(); + + foreach (var stage in stages) + { + stage?.EnsureDefaults(); + } + } + + public string CreateId(string prefix) + { + return prefix + "_" + Guid.NewGuid().ToString("N"); + } + + public WorldStageAuthoring FindStage(string stageId) + { + EnsureDefaults(); + foreach (var stage in stages) + { + if (stage != null && stage.id == stageId) + { + return stage; + } + } + + return null; + } + + public WorldStageAuthoring FindStageAt(int worldLayer, JWorldTile tile) + { + EnsureDefaults(); + foreach (var stage in stages) + { + if (stage != null && stage.worldLayer == worldLayer && stage.ContainsTile(tile)) + { + return stage; + } + } + + return null; + } + + public bool IsTileOccupiedByOther(string stageId, int worldLayer, JWorldTile tile) + { + var occupyingStage = FindStageAt(worldLayer, tile); + return occupyingStage != null && occupyingStage.id != stageId; + } + + public WorldEndpointAuthoring FindEndpointAt(string stageId, JWorldTile tile, JEndpointKind kind) + { + EnsureDefaults(); + foreach (var endpoint in endpoints) + { + if (endpoint != null && endpoint.stageId == stageId && endpoint.kind == kind && + endpoint.worldTile.x == tile.x && endpoint.worldTile.y == tile.y) + { + return endpoint; + } + } + + return null; + } + } + + [Serializable] + public sealed class WorldStageAuthoring + { + public string id; + public string displayName; + public int worldLayer; + public Color color = new(0.25f, 0.62f, 0.95f, 0.7f); + public StageDefinition stageDefinition; + public List occupiedTiles = new(); + + public void EnsureDefaults() + { + occupiedTiles ??= new List(); + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = "Stage"; + } + } + + public bool ContainsTile(JWorldTile tile) + { + EnsureDefaults(); + foreach (var occupiedTile in occupiedTiles) + { + if (occupiedTile.x == tile.x && occupiedTile.y == tile.y) + { + return true; + } + } + + return false; + } + + public void AddTile(JWorldTile tile) + { + EnsureDefaults(); + if (!ContainsTile(tile)) + { + occupiedTiles.Add(tile); + } + } + + public void RemoveTile(JWorldTile tile) + { + EnsureDefaults(); + for (var i = occupiedTiles.Count - 1; i >= 0; i--) + { + if (occupiedTiles[i].x == tile.x && occupiedTiles[i].y == tile.y) + { + occupiedTiles.RemoveAt(i); + } + } + } + } + + [Serializable] + public sealed class WorldEndpointAuthoring + { + public string id; + public string stageId; + public JWorldTile worldTile; + public JEndpointKind kind; + public bool required = true; + public JSocketFace socketFace; + } + + [Serializable] + public sealed class WorldConnectionAuthoring + { + public string id; + public string endpointAId; + public string endpointBId; + public JConnectionDirectionality directionality = JConnectionDirectionality.Bidirectional; + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs.meta new file mode 100644 index 0000000..5158dce --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8cbbff7dfcc24d2b9030f41f9f2ef718 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs new file mode 100644 index 0000000..72749e3 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs @@ -0,0 +1,375 @@ +using Intrepid.Gameplay.Journey; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Editor.GON.WorldEditor +{ + public sealed class WorldMapCanvas : VisualElement + { + private const float MinCellPixels = 16f; + private const float MaxCellPixels = 96f; + + private readonly WorldEditorWindow owner; + private readonly WorldEditorState state; + + private WorldLayoutSO layout; + private Vector2 pan = new(60f, 60f); + private float cellPixels = 36f; + private bool isPanning; + private Vector2 panStartMouse; + private Vector2 panStartValue; + + public WorldMapCanvas(WorldEditorWindow owner, WorldEditorState state) + { + this.owner = owner; + this.state = state; + + focusable = true; + style.flexGrow = 1f; + style.overflow = Overflow.Hidden; + style.backgroundColor = WorldEditorColors.Background; + + generateVisualContent += OnGenerateVisualContent; + RegisterCallback(OnMouseDown); + RegisterCallback(OnMouseMove); + RegisterCallback(OnMouseUp); + RegisterCallback(OnWheel); + } + + public void SetLayout(WorldLayoutSO newLayout) + { + layout = newLayout; + layout?.EnsureDefaults(); + MarkDirtyRepaint(); + } + + public void RequestRepaint() + { + MarkDirtyRepaint(); + } + + private void OnMouseDown(MouseDownEvent evt) + { + Focus(); + if (evt.button == 1 || evt.button == 2) + { + isPanning = true; + panStartMouse = evt.localMousePosition; + panStartValue = pan; + this.CaptureMouse(); + evt.StopPropagation(); + return; + } + + if (evt.button != 0 || layout is null) + { + return; + } + + if (!TryScreenToTile(evt.localMousePosition, out var tile)) + { + return; + } + + ApplyTool(tile, evt.localMousePosition); + evt.StopPropagation(); + } + + private void OnMouseMove(MouseMoveEvent evt) + { + if (!isPanning) + { + return; + } + + pan = panStartValue + evt.localMousePosition - panStartMouse; + MarkDirtyRepaint(); + evt.StopPropagation(); + } + + private void OnMouseUp(MouseUpEvent evt) + { + if (!isPanning) + { + return; + } + + isPanning = false; + if (this.HasMouseCapture()) + { + this.ReleaseMouse(); + } + + evt.StopPropagation(); + } + + private void OnWheel(WheelEvent evt) + { + var mouse = evt.localMousePosition; + var before = ScreenToGridFloat(mouse); + var delta = -evt.delta.y * 0.08f; + cellPixels = Mathf.Clamp(cellPixels * (1f + delta), MinCellPixels, MaxCellPixels); + var after = ScreenToGridFloat(mouse); + pan += (after - before) * cellPixels; + evt.StopPropagation(); + MarkDirtyRepaint(); + } + + private void ApplyTool(JWorldTile tile, Vector2 mouse) + { + var stageAtTile = layout.FindStageAt(state.activeLayer, tile); + if (state.activeTool == WorldEditorTool.Select) + { + state.selectedStageId = stageAtTile?.id; + owner.MutateLayout("Select world stage", delegate { }); + return; + } + + var selectedStage = layout.FindStage(state.selectedStageId); + if (selectedStage is null) + { + return; + } + + if (selectedStage.worldLayer != state.activeLayer) + { + return; + } + + switch (state.activeTool) + { + case WorldEditorTool.PaintStage: + if (!layout.IsTileOccupiedByOther(selectedStage.id, state.activeLayer, tile)) + { + owner.MutateLayout("Paint world tile", delegate { selectedStage.AddTile(tile); }); + } + break; + case WorldEditorTool.EraseStage: + owner.MutateLayout("Erase world tile", delegate { selectedStage.RemoveTile(tile); }); + break; + case WorldEditorTool.Anchor: + AddEndpoint(selectedStage, tile, JEndpointKind.Anchor, JSocketFace.North); + break; + case WorldEditorTool.Lift: + AddEndpoint(selectedStage, tile, JEndpointKind.Lift, JSocketFace.North); + break; + case WorldEditorTool.Socket: + AddEndpoint(selectedStage, tile, JEndpointKind.Socket, GetNearestFace(mouse, tile)); + break; + } + } + + private void AddEndpoint(WorldStageAuthoring stage, JWorldTile tile, JEndpointKind kind, JSocketFace face) + { + if (!stage.ContainsTile(tile)) + { + return; + } + + if ((kind == JEndpointKind.Anchor && layout.FindEndpointAt(stage.id, tile, JEndpointKind.Lift) != null) || + (kind == JEndpointKind.Lift && layout.FindEndpointAt(stage.id, tile, JEndpointKind.Anchor) != null)) + { + return; + } + + if (layout.FindEndpointAt(stage.id, tile, kind) != null) + { + return; + } + + owner.MutateLayout("Add journey endpoint", delegate + { + layout.endpoints.Add(new WorldEndpointAuthoring + { + id = layout.CreateId("endpoint"), + stageId = stage.id, + worldTile = tile, + kind = kind, + socketFace = face, + required = true + }); + }); + } + + private void OnGenerateVisualContent(MeshGenerationContext mgc) + { + var p = mgc.painter2D; + DrawGrid(p); + DrawStages(p); + DrawEndpoints(p); + } + + private void DrawGrid(Painter2D p) + { + var rect = contentRect; + var min = ScreenToGridFloat(new Vector2(rect.xMin, rect.yMin)); + var max = ScreenToGridFloat(new Vector2(rect.xMax, rect.yMax)); + var minX = Mathf.FloorToInt(min.x) - 1; + var maxX = Mathf.CeilToInt(max.x) + 1; + var minY = Mathf.FloorToInt(min.y) - 1; + var maxY = Mathf.CeilToInt(max.y) + 1; + + for (var x = minX; x <= maxX; x++) + { + DrawLine(p, GridToScreen(new Vector2(x, minY)), GridToScreen(new Vector2(x, maxY)), + x == 0 ? WorldEditorColors.GridAxis : WorldEditorColors.Grid, x == 0 ? 2f : 1f); + } + + for (var y = minY; y <= maxY; y++) + { + DrawLine(p, GridToScreen(new Vector2(minX, y)), GridToScreen(new Vector2(maxX, y)), + y == 0 ? WorldEditorColors.GridAxis : WorldEditorColors.Grid, y == 0 ? 2f : 1f); + } + } + + private void DrawStages(Painter2D p) + { + if (layout is null) + { + return; + } + + layout.EnsureDefaults(); + foreach (var stage in layout.stages) + { + if (stage == null || stage.worldLayer != state.activeLayer) + { + continue; + } + + foreach (var tile in stage.occupiedTiles) + { + var rect = TileRect(tile); + FillRect(p, rect, stage.color); + StrokeRect(p, rect, stage.id == state.selectedStageId ? WorldEditorColors.Selected : Color.black, 2f); + } + } + } + + private void DrawEndpoints(Painter2D p) + { + if (layout is null) + { + return; + } + + foreach (var endpoint in layout.endpoints) + { + var stage = layout.FindStage(endpoint.stageId); + if (stage is null || stage.worldLayer != state.activeLayer) + { + continue; + } + + var rect = TileRect(endpoint.worldTile); + var center = rect.center; + switch (endpoint.kind) + { + case JEndpointKind.Anchor: + FillMarker(p, center, Mathf.Max(8f, cellPixels * 0.28f), WorldEditorColors.Anchor); + break; + case JEndpointKind.Lift: + FillMarker(p, center, Mathf.Max(8f, cellPixels * 0.28f), WorldEditorColors.Lift); + DrawLine(p, center + Vector2.down * 7f, center + Vector2.up * 7f, Color.white, 2f); + break; + case JEndpointKind.Socket: + DrawSocket(p, rect, endpoint.socketFace); + break; + } + } + } + + 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 + { + 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 JSocketFace GetNearestFace(Vector2 mouse, JWorldTile tile) + { + var rect = TileRect(tile); + var left = Mathf.Abs(mouse.x - rect.xMin); + var right = Mathf.Abs(mouse.x - rect.xMax); + var top = Mathf.Abs(mouse.y - rect.yMin); + var bottom = Mathf.Abs(mouse.y - rect.yMax); + var min = Mathf.Min(left, right, top, bottom); + if (Mathf.Approximately(min, top)) return JSocketFace.North; + if (Mathf.Approximately(min, bottom)) return JSocketFace.South; + if (Mathf.Approximately(min, right)) return JSocketFace.East; + return JSocketFace.West; + } + + private Rect TileRect(JWorldTile tile) + { + var min = GridToScreen(new Vector2(tile.x, tile.y)); + return new Rect(min.x, min.y, cellPixels, cellPixels); + } + + private bool TryScreenToTile(Vector2 screen, out JWorldTile tile) + { + var grid = ScreenToGridFloat(screen); + tile = new JWorldTile(Mathf.FloorToInt(grid.x), Mathf.FloorToInt(grid.y)); + return true; + } + + private Vector2 ScreenToGridFloat(Vector2 screen) + { + return (screen - pan) / cellPixels; + } + + private Vector2 GridToScreen(Vector2 grid) + { + return pan + grid * cellPixels; + } + + private void FillRect(Painter2D p, Rect rect, Color color) + { + p.fillColor = color; + p.BeginPath(); + p.MoveTo(new Vector2(rect.xMin, rect.yMin)); + p.LineTo(new Vector2(rect.xMax, rect.yMin)); + p.LineTo(new Vector2(rect.xMax, rect.yMax)); + p.LineTo(new Vector2(rect.xMin, rect.yMax)); + p.ClosePath(); + p.Fill(); + } + + private void StrokeRect(Painter2D p, Rect rect, Color color, float width) + { + p.strokeColor = color; + p.lineWidth = width; + p.BeginPath(); + p.MoveTo(new Vector2(rect.xMin, rect.yMin)); + p.LineTo(new Vector2(rect.xMax, rect.yMin)); + p.LineTo(new Vector2(rect.xMax, rect.yMax)); + p.LineTo(new Vector2(rect.xMin, rect.yMax)); + p.ClosePath(); + p.Stroke(); + } + + private void FillMarker(Painter2D p, Vector2 center, float size, Color color) + { + var half = size * 0.5f; + FillRect(p, new Rect(center.x - half, center.y - half, size, size), color); + } + + private void DrawLine(Painter2D p, Vector2 a, Vector2 b, Color color, float width) + { + p.strokeColor = color; + p.lineWidth = width; + p.BeginPath(); + p.MoveTo(a); + p.LineTo(b); + p.Stroke(); + } + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs.meta new file mode 100644 index 0000000..6f2768d --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldMapCanvas.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 817967f2b72043129f5018488de4c53d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 79bac2a..4e0b917 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":"review","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":"review","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]}],"lessons":[]} +{"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":"review","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]}],"lessons":[]} diff --git a/discussion/workflow/plans/PLN-0003-world-editor-authoring.md b/discussion/workflow/plans/PLN-0003-world-editor-authoring.md index 6fc8e91..f7d817d 100644 --- a/discussion/workflow/plans/PLN-0003-world-editor-authoring.md +++ b/discussion/workflow/plans/PLN-0003-world-editor-authoring.md @@ -2,7 +2,7 @@ id: PLN-0003 ticket: world-editor-journey-spec title: World Editor Authoring UI and WorldLayoutSO -status: review +status: done created: 2026-06-19 decisions: - DEC-0001