diff --git a/locale/ui_en.properties b/locale/ui_en.properties index 901f418..a573a0d 100644 --- a/locale/ui_en.properties +++ b/locale/ui_en.properties @@ -123,3 +123,48 @@ tools/interactive_editor/label_onclick_scenario = OnClick scenario: tools/interactive_editor/label_onclick_commands = OnClick commands: tools/interactive_editor/label_hover_enter = OnHoverEnter DSL: tools/interactive_editor/label_hover_leave = OnHoverLeave DSL: + +tools/map_editor/title = Map Editor +tools/map_editor/mod = Mod: +tools/map_editor/file = File: +tools/map_editor/load = Load +tools/map_editor/save = Save +tools/map_editor/save_ok = Saved. +tools/map_editor/reloaded = Map reloaded. +tools/map_editor/load_error = Load error +tools/map_editor/readonly = Cannot save: mod is not writable (zip) or file path is invalid. +tools/map_editor/no_mods = No writable mods +tools/map_editor/no_file = (new file) +tools/map_editor/reload_map = Reload map +tools/map_editor/background = Background: +tools/map_editor/zones = Zones +tools/map_editor/props = Selected zone +tools/map_editor/add_region = + Region +tools/map_editor/add_marker = + Marker +tools/map_editor/add_label = + Label +tools/map_editor/delete = Delete +tools/map_editor/select_mode = Select +tools/map_editor/draw_region = Draw region +tools/map_editor/id = ID: +tools/map_editor/id_empty = ID must not be empty. +tools/map_editor/duplicate_id = Duplicate zone ID. +tools/map_editor/title_key = Title locale key: +tools/map_editor/no_translation = no translation +tools/map_editor/scene = Scene: +tools/map_editor/scene_none = (none) +tools/map_editor/description = Description locale key: +tools/map_editor/image = Marker icon: +tools/map_editor/image_pos = Marker position (0-1): +tools/map_editor/label = Text label on map +tools/map_editor/locked = Closed for travel (locked) +tools/map_editor/hidden = Hidden from map +tools/map_editor/vertices = Polygon vertices +tools/map_editor/add_vertex = + Vertex +tools/map_editor/x = x: +tools/map_editor/y = y: +tools/map_editor/zone_missing = No zone selected. +tools/map_editor/preview = ▶ Preview +tools/map_editor/preview_tip = View the map as in game: hover, location card, markers +tools/map_editor/preview_on = Preview mode: Esc or ✕ returns to the editor. +tools/map_editor/preview_off = Edit mode. +tools/map_editor/preview_missing = Failed to load the map widget scene (map_widget.tscn). diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties index de13e6b..5f62134 100644 --- a/locale/ui_ru.properties +++ b/locale/ui_ru.properties @@ -122,3 +122,48 @@ tools/interactive_editor/label_onclick_scenario = Сценарий по клик tools/interactive_editor/label_onclick_commands = Команды по клику: tools/interactive_editor/label_hover_enter = OnHoverEnter DSL: tools/interactive_editor/label_hover_leave = OnHoverLeave DSL: + +tools/map_editor/title = Редактор карты +tools/map_editor/mod = Мод: +tools/map_editor/file = Файл: +tools/map_editor/load = Загрузить +tools/map_editor/save = Сохранить +tools/map_editor/save_ok = Сохранено. +tools/map_editor/reloaded = Карта перезагружена. +tools/map_editor/load_error = Ошибка загрузки +tools/map_editor/readonly = Не удалось сохранить: мод не для записи (zip) или неверный путь файла. +tools/map_editor/no_mods = Нет модов для записи +tools/map_editor/no_file = (новый файл) +tools/map_editor/reload_map = Перезагрузить карту +tools/map_editor/background = Фон: +tools/map_editor/zones = Зоны +tools/map_editor/props = Выбранная зона +tools/map_editor/add_region = + Регион +tools/map_editor/add_marker = + Метка +tools/map_editor/add_label = + Надпись +tools/map_editor/delete = Удалить +tools/map_editor/select_mode = Выбор +tools/map_editor/draw_region = Рисовать регион +tools/map_editor/id = ID: +tools/map_editor/id_empty = ID не может быть пустым. +tools/map_editor/duplicate_id = Дубликат ID зоны. +tools/map_editor/title_key = Ключ названия: +tools/map_editor/no_translation = нет перевода +tools/map_editor/scene = Сцена: +tools/map_editor/scene_none = (нет) +tools/map_editor/description = Ключ описания: +tools/map_editor/image = Иконка метки: +tools/map_editor/image_pos = Позиция метки (0-1): +tools/map_editor/label = Текстовая надпись на карте +tools/map_editor/locked = Закрыта для посещения +tools/map_editor/hidden = Скрыта с карты +tools/map_editor/vertices = Вершины полигона +tools/map_editor/add_vertex = + Вершина +tools/map_editor/x = x: +tools/map_editor/y = y: +tools/map_editor/zone_missing = Зона не выбрана. +tools/map_editor/preview = ▶ Просмотр +tools/map_editor/preview_tip = Посмотреть карту как в игре: наведение, карточка зоны, метки +tools/map_editor/preview_on = Режим просмотра: Esc или ✕ — вернуться к редактору. +tools/map_editor/preview_off = Режим редактирования. +tools/map_editor/preview_missing = Не удалось загрузить сцену карты (map_widget.tscn). diff --git a/src/MapEditor/MapEditorCanvas.cs b/src/MapEditor/MapEditorCanvas.cs new file mode 100644 index 0000000..8cfad31 --- /dev/null +++ b/src/MapEditor/MapEditorCanvas.cs @@ -0,0 +1,428 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.UI.Theming; +using Cthangover.Core.UI.Tool; +using Godot; + +namespace Cthangover.Tools.MapEditor +{ + /// + /// Interactive preview of the edited map: draws the background with the + /// exact aspect-preserving layout used by MapWidget, overlays every + /// zone polygon, marker and text label, and hosts the draggable handles. + /// Implements the two editor modes: + /// + /// select — clicking picks the topmost zone (polygon first, + /// then marker/label dots); handles move vertices and positions; + /// draw — clicking appends polygon vertices to the selected + /// region and double-click finishes the polygon and returns to select + /// mode. + /// + /// Right-click deletes a polygon vertex in either mode: the vertex nearest + /// to the pointer. While drawing, a click that misses every vertex removes + /// the last one ("undo point"); in select mode only a targeted vertex is + /// deleted. + /// The canvas owns no data: it reads and mutates + /// and raises so the + /// window can refresh the sidebar and JSON preview. Drawing is connected + /// through the "draw" signal and input through the GuiInput event, because + /// virtual method overrides are unreliable in mod assemblies. + /// + /// Zone fills and outlines are painted through — + /// the same definition the game's MapZonesLayer and the window's + /// game-like preview use — so a zone the author sees here and the same zone + /// on the live map are guaranteed to look identical. + /// + public partial class MapEditorCanvas : Control + { + /// Mode constant: clicking selects zones and drags handles. + public const string ModeSelect = "select"; + + /// Mode constant: clicking appends polygon vertices. + public const string ModeDraw = "draw"; + + private static readonly Color VertexColor = MapZoneStyle.VertexHandle; + private static readonly Color MarkerColor = MapZoneStyle.MarkerHandle; + private static readonly Color LabelColor = MapZoneStyle.LabelHandle; + + private readonly List _handles = new(); + + /// Data being edited; set once by the window. + public MapEditorController Controller; + + /// Reference background; null falls back to a flat rectangle. + public Texture2D Background; + + /// Current interaction mode, one of / . + public string Mode { get; private set; } = ModeSelect; + + /// Raised whenever the canvas mutates controller data (drag, draw, pick). + public event Action Changed; + + /// Raised with the newly picked zone index (-1 for empty space). + public event Action ZonePicked; + + public MapEditorCanvas() + { + MouseFilter = MouseFilterEnum.Stop; + ClipContents = true; + MapZoneStyle.Prepare(this); + GuiInput += OnGuiInput; + MouseExited += OnMouseExited; + Resized += OnResized; + Connect("draw", Callable.From((Action)DrawAll)); + } + + /// Switches the interaction mode and refreshes the handles. + public void SetMode(string mode) + { + Mode = mode == ModeDraw ? ModeDraw : ModeSelect; + RebuildHandles(); + QueueRedraw(); + } + + /// Assigns the background texture and rebuilds the layout. + public void SetBackground(Texture2D texture) + { + Background = texture; + RebuildHandles(); + QueueRedraw(); + } + + /// Recreates the handles from the current selection. Call after structural changes. + public void RebuildHandles() + { + foreach (var handle in _handles) + handle.QueueFree(); + _handles.Clear(); + + var zone = Controller?.Selected; + if (zone == null) + return; + + var rect = GetBackgroundRect(); + if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + return; + + if (zone.Points != null) + { + for (var i = 0; i < zone.Points.Count; i++) + AddHandle(rect, zone.Points[i], VertexColor, i); + } + + if (HasMarker(zone)) + AddHandle(rect, zone.ImagePos, MarkerColor, MapEditorHandle.MarkerHandle); + + if (zone.LabelPos.HasValue) + AddHandle(rect, zone.LabelPos.Value, LabelColor, MapEditorHandle.LabelHandle); + } + + /// + /// Aspect-preserving rectangle the background occupies inside the + /// canvas. Must stay identical to the runtime layout: the saved + /// normalised coordinates mean nothing if the editor and the game + /// disagree about this rectangle. + /// + public Rect2 GetBackgroundRect() + { + var controlSize = Size; + if (controlSize.X <= 0f || controlSize.Y <= 0f) + return new Rect2(); + + var textureSize = Background?.GetSize() ?? new Vector2(1920, 1080); + if (textureSize.X <= 0f || textureSize.Y <= 0f) + textureSize = new Vector2(1920, 1080); + + var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y); + var size = textureSize * scale; + return new Rect2((controlSize - size) / 2f, size); + } + + private void AddHandle(Rect2 rect, Vector2 normalized, Color color, int index) + { + var handle = new MapEditorHandle(color, index); + handle.DragUpdate = center => OnHandleDrag(index, center); + handle.DragEnd = () => Changed?.Invoke(); + handle.RightClicked = () => RemoveVertexHandle(index); + AddChild(handle); + handle.GlobalPosition = GlobalPosition + rect.Position + Clamp01(normalized) * rect.Size - handle.Size / 2f; + _handles.Add(handle); + } + + private void OnHandleDrag(int index, Vector2 globalCenter) + { + if (Controller == null) + return; + + var rect = GetBackgroundRect(); + if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + return; + + var local = globalCenter - GlobalPosition - rect.Position; + var normalized = Clamp01(new Vector2(local.X / rect.Size.X, local.Y / rect.Size.Y)); + + if (index == MapEditorHandle.MarkerHandle) + Controller.SetImagePos(normalized); + else if (index == MapEditorHandle.LabelHandle) + Controller.SetLabelPos(normalized); + else + Controller.SetVertex(index, normalized); + + QueueRedraw(); + Changed?.Invoke(); + } + + private void OnGuiInput(InputEvent @event) + { + if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed) + return; + + if (mb.ButtonIndex == MouseButton.Left) + { + if (Mode == ModeDraw) + { + Controller.AddVertex(Clamp01(PixelToNorm(mb.Position))); + if (mb.DoubleClick) + Mode = ModeSelect; + RebuildHandles(); + Changed?.Invoke(); + QueueRedraw(); + } + else + { + var hit = HitTest(mb.Position); + if (hit != Controller.SelectedIndex) + { + Controller.Select(hit); + RebuildHandles(); + ZonePicked?.Invoke(hit); + Changed?.Invoke(); + QueueRedraw(); + } + } + + AcceptEvent(); + } + else if (mb.ButtonIndex == MouseButton.Right) + { + RemoveVertexAtPointer(mb.Position, Mode == ModeDraw); + AcceptEvent(); + } + } + + private void OnMouseExited() + { + QueueRedraw(); + } + + private void OnResized() + { + RebuildHandles(); + QueueRedraw(); + } + + /// Topmost zone under a canvas-local point: polygons win over marker/label dots. + private int HitTest(Vector2 local) + { + var zones = Controller.Zones; + var rect = GetBackgroundRect(); + + for (var i = zones.Count - 1; i >= 0; i--) + { + var points = zones[i].Points; + if (points != null && points.Count >= 3 && Geometry2D.IsPointInPolygon(local, ToPixels(points, rect))) + return i; + } + + for (var i = zones.Count - 1; i >= 0; i--) + { + var zone = zones[i]; + if (HasMarker(zone) && (local - ToPixel(zone.ImagePos, rect)).Length() <= 24f) + return i; + if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= 24f) + return i; + } + + return -1; + } + + private void DrawAll() + { + var rect = GetBackgroundRect(); + if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + return; + + if (Background != null) + DrawTextureRect(Background, rect, false); + else + DrawRect(rect, UiTokens.Surface2); + + var zones = Controller?.Zones; + if (zones == null) + return; + + for (var i = 0; i < zones.Count; i++) + { + var selected = i == Controller.SelectedIndex; + DrawZone(zones[i], rect, selected); + } + } + + private void DrawZone(MapZone zone, Rect2 rect, bool selected) + { + if (zone.Points != null && zone.Points.Count >= 2) + MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, zone.Locked); + + if (HasMarker(zone)) + DrawMarker(zone, ToPixel(zone.ImagePos, rect), selected); + + if (zone.LabelPos.HasValue) + DrawLabelPill(zone, ToPixel(zone.LabelPos.Value, rect), selected); + } + + /// + /// Draws the marker emblem. A marker without an icon is rendered as a + /// placeholder crosshair instead of disappearing, so a freshly created + /// marker stays visible, selectable and draggable while the author is + /// still choosing an icon. + /// + private void DrawMarker(MapZone zone, Vector2 center, bool selected) + { + var outline = selected ? UiTokens.Accent : UiTokens.AccentLine; + var accent = selected ? UiTokens.Accent : MarkerColor; + + var icon = string.IsNullOrEmpty(zone.Image) ? null : Textures.Resolve(zone.Image); + if (icon == null) + { + DrawCircle(center, 18f, UiTokens.Alpha(UiTokens.Surface0, 0.55f)); + DrawArc(center, 18f, 0f, Mathf.Tau, 32, accent, 2f, true); + DrawLine(center - new Vector2(7f, 0f), center + new Vector2(7f, 0f), accent, 2f); + DrawLine(center - new Vector2(0f, 7f), center + new Vector2(0f, 7f), accent, 2f); + return; + } + + DrawCircle(center, 18f, UiTokens.Alpha(UiTokens.White, 0.85f)); + DrawArc(center, 18f, 0f, Mathf.Tau, 32, outline, 1.5f, true); + DrawTextureRect(icon, new Rect2(center - new Vector2(14f, 14f), new Vector2(28f, 28f)), false); + } + + private void DrawLabelPill(MapZone zone, Vector2 center, bool selected) + { + var text = string.IsNullOrEmpty(zone.Title) ? zone.ID : TranslationServer.Translate(zone.Title).ToString(); + if (string.IsNullOrEmpty(text)) + text = zone.ID; + + var font = GetThemeDefaultFont(); + var fontSize = GetThemeDefaultFontSize(); + var textSize = font.GetStringSize(text, HorizontalAlignment.Left, -1, fontSize); + var pill = new Rect2(center - new Vector2(textSize.X / 2f + 8f, textSize.Y / 2f + 3f), textSize + new Vector2(16f, 6f)); + + DrawRect(pill, UiTokens.Alpha(UiTokens.Surface0, 0.85f)); + DrawRect(pill, selected ? UiTokens.Accent : UiTokens.Line, false, 1f); + DrawString(font, new Vector2(pill.Position.X + 8f, pill.Position.Y + pill.Size.Y / 2f + fontSize / 3f), + text, HorizontalAlignment.Left, -1, fontSize, UiTokens.Ink); + } + + private static Vector2[] ToPixels(IReadOnlyList normalized, Rect2 rect) + { + var result = new Vector2[normalized.Count]; + for (var i = 0; i < result.Length; i++) + result[i] = rect.Position + normalized[i] * rect.Size; + return result; + } + + private static Vector2 ToPixel(Vector2 normalized, Rect2 rect) + { + return rect.Position + normalized * rect.Size; + } + + private Vector2 PixelToNorm(Vector2 local) + { + var rect = GetBackgroundRect(); + if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + return Vector2.Zero; + + return new Vector2((local.X - rect.Position.X) / rect.Size.X, (local.Y - rect.Position.Y) / rect.Size.Y); + } + + private static Vector2 Clamp01(Vector2 value) + { + return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f)); + } + + /// + /// A zone is marker-capable when it has an icon, or when it is still a + /// bare marker placeholder — no polygon and no text label. The + /// placeholder keeps a freshly created marker visible and draggable + /// before an icon is picked; once the zone grows a region or becomes a + /// pure inscription, the marker is only shown when an icon exists. + /// + private static bool HasMarker(MapZone zone) + { + if (!string.IsNullOrEmpty(zone.Image)) + return true; + + return (zone.Points == null || zone.Points.Count == 0) && !zone.LabelPos.HasValue; + } + + /// + /// Right-click handler: removes the polygon vertex nearest to the + /// pointer. While drawing, a click that misses every vertex removes the + /// last one instead, which makes right-click a natural "undo point"; in + /// select mode only a targeted vertex is deleted, so a stray click + /// cannot destroy geometry. + /// + private void RemoveVertexAtPointer(Vector2 local, bool fallbackToLast) + { + var zone = Controller?.Selected; + if (zone?.Points == null || zone.Points.Count == 0) + return; + + var rect = GetBackgroundRect(); + var index = FindNearestVertex(zone, local, rect); + if (index < 0 && fallbackToLast) + index = zone.Points.Count - 1; + if (index < 0) + return; + + Controller.RemoveVertex(index); + RebuildHandles(); + Changed?.Invoke(); + QueueRedraw(); + } + + private static int FindNearestVertex(MapZone zone, Vector2 local, Rect2 rect) + { + var best = -1; + var bestDistance = 24f; + + for (var i = 0; i < zone.Points.Count; i++) + { + var distance = (local - ToPixel(zone.Points[i], rect)).Length(); + if (distance <= bestDistance) + { + bestDistance = distance; + best = i; + } + } + + return best; + } + + /// + /// Deletes a vertex from its handle's right-click. Marker and label + /// handles (negative indexes) are not vertices and are ignored. + /// + private void RemoveVertexHandle(int index) + { + if (index < 0 || Controller == null) + return; + + Controller.RemoveVertex(index); + RebuildHandles(); + Changed?.Invoke(); + QueueRedraw(); + } + } +} diff --git a/src/MapEditor/MapEditorController.cs b/src/MapEditor/MapEditorController.cs new file mode 100644 index 0000000..0015634 --- /dev/null +++ b/src/MapEditor/MapEditorController.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.UI.Tool; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.MapEditor +{ + /// + /// Data model for the map editor. Holds the zone list being edited, the + /// current selection and all structural operations (add/remove zone, + /// add/remove/move vertex, move marker and label, toggle label mode). + /// Contains no UI and no runtime state: it edits raw authoring data that + /// serialises back into + /// mods/<mod>/map/*.json, while the live game state belongs to + /// MapZoneService. + /// + /// New zones get unique IDs automatically and a sensible default shape + /// (a small square for regions, centre position for markers and labels), + /// so an author can place an element first and refine it afterwards. All + /// coordinates are normalised [0..1] and clamped by the canvas, not here. + /// + public class MapEditorController + { + private const float DefaultRegionHalfSize = 0.06f; + private static readonly Vector2 DefaultCenter = new(0.5f, 0.5f); + + /// Zones currently being edited, in map file order. + public List Zones { get; private set; } = new(); + + /// Index of the selected zone, or -1 when nothing is selected. + public int SelectedIndex { get; private set; } = -1; + + /// The selected zone, or null when the selection is empty. + public MapZone Selected => SelectedIndex >= 0 && SelectedIndex < Zones.Count ? Zones[SelectedIndex] : null; + + /// Replaces all zones with the contents of a map file JSON envelope. + public void FromJson(string json) + { + Zones = MapFileService.ParseZones(json); + SelectedIndex = Zones.Count > 0 ? 0 : -1; + } + + /// Serialises the current zone list into the map file JSON envelope. + public string ToJson() => MapFileService.BuildJson(Zones); + + /// Drops all zones and the selection. + public void Clear() + { + Zones.Clear(); + SelectedIndex = -1; + } + + /// Creates a rectangular region around the map centre and selects it. + public MapZone AddRegion() + { + var zone = new MapZone + { + ID = MakeUniqueId("region"), + Title = string.Empty, + Points = new List + { + DefaultCenter + new Vector2(-DefaultRegionHalfSize, -DefaultRegionHalfSize), + DefaultCenter + new Vector2(DefaultRegionHalfSize, -DefaultRegionHalfSize), + DefaultCenter + new Vector2(DefaultRegionHalfSize, DefaultRegionHalfSize), + DefaultCenter + new Vector2(-DefaultRegionHalfSize, DefaultRegionHalfSize), + }, + }; + + return Append(zone); + } + + /// + /// Creates an icon marker at the map centre with no icon chosen yet and + /// selects it. The author assigns image and scene in the + /// sidebar. + /// + public MapZone AddMarker() + { + var zone = new MapZone + { + ID = MakeUniqueId("marker"), + Title = string.Empty, + ImagePos = DefaultCenter, + }; + + return Append(zone); + } + + /// + /// Creates a free-standing text label at the map centre and selects it. + /// The label exists as soon as label_pos is set; MapWidget + /// draws zones without an icon as inscriptions. + /// + public MapZone AddLabel() + { + var zone = new MapZone + { + ID = MakeUniqueId("label"), + Title = string.Empty, + LabelPos = DefaultCenter, + }; + + return Append(zone); + } + + /// Removes the zone at the given index and adjusts the selection. + public void RemoveAt(int index) + { + if (index < 0 || index >= Zones.Count) + return; + + Zones.RemoveAt(index); + + if (Zones.Count == 0) + SelectedIndex = -1; + else if (SelectedIndex > index) + SelectedIndex--; + else if (SelectedIndex == index) + SelectedIndex = Math.Min(index, Zones.Count - 1); + } + + /// Selects the zone at the given index; out-of-range clears the selection. + public void Select(int index) + { + SelectedIndex = index >= 0 && index < Zones.Count ? index : -1; + } + + /// Appends a vertex to the selected region's polygon. + public void AddVertex(Vector2 normalized) + { + var zone = Selected; + if (zone == null) + return; + + zone.Points ??= new List(); + zone.Points.Add(normalized); + } + + /// Moves one vertex of the selected region. + public void SetVertex(int index, Vector2 normalized) + { + var zone = Selected; + if (zone?.Points == null || index < 0 || index >= zone.Points.Count) + return; + + zone.Points[index] = normalized; + } + + /// Removes one vertex of the selected region. + public void RemoveVertex(int index) + { + var zone = Selected; + if (zone?.Points == null || index < 0 || index >= zone.Points.Count) + return; + + zone.Points.RemoveAt(index); + } + + /// Moves the icon marker of the selected zone. + public void SetImagePos(Vector2 normalized) + { + var zone = Selected; + if (zone != null) + zone.ImagePos = normalized; + } + + /// Moves the text label of the selected zone. + public void SetLabelPos(Vector2 normalized) + { + var zone = Selected; + if (zone != null) + zone.LabelPos = normalized; + } + + /// + /// Enables or disables the free-standing label of the selected zone. + /// Enabling without a known position places it at the map centre. + /// + public void SetLabelEnabled(bool enabled) + { + var zone = Selected; + if (zone == null) + return; + + zone.LabelPos = enabled ? zone.LabelPos ?? DefaultCenter : null; + } + + /// ID set of every zone in the list, for duplicate validation. + public HashSet UsedIds() + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var zone in Zones) + { + if (!string.IsNullOrEmpty(zone.ID)) + ids.Add(zone.ID); + } + return ids; + } + + /// + /// Builds an ID that is not used yet by appending a numeric suffix + /// (region, region_2, region_3, ...). + /// + public string MakeUniqueId(string baseId) + { + var used = UsedIds(); + if (!used.Contains(baseId)) + return baseId; + + var suffix = 2; + while (used.Contains($"{baseId}_{suffix}")) + suffix++; + + return $"{baseId}_{suffix}"; + } + + private MapZone Append(MapZone zone) + { + Zones.Add(zone); + SelectedIndex = Zones.Count - 1; + return zone; + } + } +} diff --git a/src/MapEditor/MapEditorHandle.cs b/src/MapEditor/MapEditorHandle.cs new file mode 100644 index 0000000..d1493df --- /dev/null +++ b/src/MapEditor/MapEditorHandle.cs @@ -0,0 +1,85 @@ +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Tools.MapEditor +{ + /// + /// Draggable 16x16 handle on the map editor canvas. One handle represents + /// either a polygon vertex, the marker position or the label position, + /// distinguished by with negative values reserved for + /// the position handles. Subscribes to the GuiInput C# event rather than + /// overriding _GuiInput for reliable event delivery in mod + /// assemblies, following InteractiveEditorHandle. + /// + public partial class MapEditorHandle : ColorRect + { + /// Handle role: 0..n — vertex index, or . + public const int MarkerHandle = -2; + + /// Handle role used for the label position handle. + public const int LabelHandle = -3; + + /// Vertex index or one of the negative role constants. + public int Index; + + /// Invoked during drag with the handle centre in global coordinates. + public System.Action DragUpdate; + + /// Invoked when the handle is clicked (mouse down). + public System.Action Clicked; + + /// Invoked when the drag ends (mouse up). + public System.Action DragEnd; + + /// + /// Invoked on a right-click over the handle. Handles stop mouse input, + /// so without this hook a right-click aimed exactly at a vertex would + /// never reach the canvas and the vertex could not be deleted there. + /// + public System.Action RightClicked; + + private bool _dragging; + private Vector2 _dragOffset; + + /// Creates a handle with the given colour and role index. + public MapEditorHandle(Color color, int index) + { + Index = index; + Size = new Vector2(16, 16); + Color = color; + MouseFilter = MouseFilterEnum.Stop; + MouseDefaultCursorShape = CursorShape.PointingHand; + GuiInput += OnGuiInput; + } + + private void OnGuiInput(InputEvent @event) + { + if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left) + { + if (mb.Pressed) + { + _dragging = true; + _dragOffset = GetGlobalMousePosition() - GlobalPosition; + Clicked?.Invoke(); + } + else + { + _dragging = false; + DragEnd?.Invoke(); + } + AcceptEvent(); + } + else if (@event is InputEventMouseButton right && right.ButtonIndex == MouseButton.Right && right.Pressed) + { + RightClicked?.Invoke(); + AcceptEvent(); + } + else if (@event is InputEventMouseMotion && _dragging) + { + var center = GlobalPosition + Size / 2; + GlobalPosition = GetGlobalMousePosition() - _dragOffset; + DragUpdate?.Invoke(center); + } + } + } +} diff --git a/src/MapEditor/MapEditorToolBoxButton.cs b/src/MapEditor/MapEditorToolBoxButton.cs new file mode 100644 index 0000000..5555996 --- /dev/null +++ b/src/MapEditor/MapEditorToolBoxButton.cs @@ -0,0 +1,24 @@ +using Cthangover.Core.UI.Tool; + +namespace Cthangover.Tools.MapEditor +{ + /// + /// Toolbox button that opens the map editor from the in-game toolbar. + /// Matches the tool id registered by ; + /// always visible because map authoring is part of the normal dev workflow. + /// + public class MapEditorToolBoxButton : IToolBoxButton + { + /// Matches . + public string ToolId => "map_editor"; + + /// No dedicated icon — the button uses its text label. + public string IconPath => ""; + + /// Translation key for the button label. + public string LocaleKey => "tools/map_editor/title"; + + /// Always visible in the toolbox. + public bool IsVisible() => true; + } +} diff --git a/src/MapEditor/MapEditorToolProvider.cs b/src/MapEditor/MapEditorToolProvider.cs new file mode 100644 index 0000000..c86f503 --- /dev/null +++ b/src/MapEditor/MapEditorToolProvider.cs @@ -0,0 +1,27 @@ +using Cthangover.Core.UI.Tool; +using Godot; + +namespace Cthangover.Tools.MapEditor +{ + /// + /// Registers the map editor as a discoverable tool window. Discovery is + /// automatic: ToolFactory scans mod assemblies for + /// implementations, so no manifest entry is + /// needed. The provider only knows how to build the window; all editing + /// logic lives in . + /// + public class MapEditorToolProvider : IToolProvider + { + /// Unique tool identifier used by the toolbox button. + public string Id => "map_editor"; + + /// Translation key for the tool's display name. + public string LocaleKey => "tools/map_editor/title"; + + /// Creates the editor window instance. + public Window CreateWindow() => new MapEditorWindow(); + + /// Adds the map editor's launcher button to the main menu tool block. + public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this); + } +} diff --git a/src/MapEditor/MapEditorWindow.cs b/src/MapEditor/MapEditorWindow.cs new file mode 100644 index 0000000..461c9c4 --- /dev/null +++ b/src/MapEditor/MapEditorWindow.cs @@ -0,0 +1,1019 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Cthangover.Core.Localization; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Scenes; +using Cthangover.Core.Settings; +using Cthangover.Core.UI; +using Cthangover.Core.UI.Theming; +using Cthangover.Core.UI.Tool; +using Cthangover.Core.Utils; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.MapEditor +{ + /// + /// Editor window for the world map. Authors pick a folder-backed mod and a + /// file under its map/ directory, then draw polygons, place icon + /// markers and drop free-standing text labels over a background preview. + /// + /// The editor edits raw authoring data only: the produced JSON matches + /// MapZone (polygon points, marker image/image_pos, + /// label label_pos, initial locked/hidden flags). + /// Runtime state — what scenarios open or reveal — is not touched; after a + /// successful save the editor asks the running map widget to reload so the + /// edit is visible immediately. + /// + /// Layout mirrors the other tool windows: toolbar for file and mode actions, + /// sidebar with the zone list and property editors, centre preview, and a + /// read-only JSON preview at the bottom for quick inspection. + /// + public partial class MapEditorWindow : ToolWindow + { + private readonly MapEditorController _controller = new(); + + private readonly List _writableMods = new(); + private readonly List _backgroundIds = new(); + private readonly List _sceneIds = new(); + private readonly List _textureIds = new(); + + private OptionButton _modSelect; + private LineEdit _fileEdit; + private OptionButton _modeSelect; + private OptionButton _backgroundSelect; + private Label _statusLabel; + + private ItemList _zoneList; + private LineEdit _idEdit; + private LineEdit _titleEdit; + private Label _titlePreview; + private OptionButton _sceneSelect; + private LineEdit _descEdit; + private LineEdit _imageEdit; + private ItemList _iconList; + private CheckBox _lockedCheck; + private CheckBox _hiddenCheck; + private CheckBox _labelCheck; + private LineEdit _posX; + private LineEdit _posY; + private LineEdit _labelX; + private LineEdit _labelY; + private VBoxContainer _vertexList; + + private MapEditorCanvas _canvas; + private TextEdit _jsonEdit; + private Button _previewButton; + private MapWidget _preview; + + private bool _suppress; + + public MapEditorWindow() : base("tools/map_editor/title") + { + Size = new Vector2I(1280, 820); + CthangoverTheme.Apply(this); + + ReloadLocalization(); + BuildUI(); + LoadModList(); + LoadSceneList(); + LoadTextureList(); + LoadBackgroundList(); + RefreshAll(); + } + + // ── UI construction ──────────────────────────────────────── + + private void BuildUI() + { + var outer = CreateFillContainer(); + AddChild(outer); + + outer.AddChild(BuildToolbar()); + + var main = new HBoxContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + outer.AddChild(main); + + var sidebarContent = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + main.AddChild(CreateSidebar(sidebarContent, 320)); + BuildSidebar(sidebarContent); + + var previewPanel = new PanelContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + main.AddChild(previewPanel); + + _canvas = new MapEditorCanvas { Controller = _controller }; + _canvas.Changed += OnCanvasChanged; + _canvas.ZonePicked += OnCanvasZonePicked; + previewPanel.AddChild(_canvas); + + _jsonEdit = new TextEdit + { + CustomMinimumSize = new Vector2(0, 150), + Editable = false, + WrapMode = TextEdit.LineWrappingMode.Boundary + }; + _jsonEdit.AddThemeFontOverride("font", GetMonospaceFont()); + outer.AddChild(_jsonEdit); + } + + private HBoxContainer BuildToolbar() + { + var toolbar = CreateToolbar(); + + toolbar.AddChild(new Label { Text = Tr("tools/map_editor/mod") }); + _modSelect = new OptionButton(); + _modSelect.ItemSelected += _ => OnFileChanged(); + toolbar.AddChild(_modSelect); + + toolbar.AddChild(new Label { Text = Tr("tools/map_editor/file") }); + _fileEdit = new LineEdit { Text = "map.json", CustomMinimumSize = new Vector2(160, 0) }; + toolbar.AddChild(_fileEdit); + + var loadBtn = new Button { Text = Tr("tools/map_editor/load") }; + loadBtn.Pressed += OnLoad; + toolbar.AddChild(loadBtn); + + var saveBtn = new Button { Text = Tr("tools/map_editor/save") }; + saveBtn.Pressed += OnSave; + toolbar.AddChild(saveBtn); + + var reloadBtn = new Button { Text = Tr("tools/map_editor/reload_map") }; + reloadBtn.Pressed += OnReloadMap; + toolbar.AddChild(reloadBtn); + + _modeSelect = new OptionButton(); + _modeSelect.AddItem(Tr("tools/map_editor/select_mode")); + _modeSelect.AddItem(Tr("tools/map_editor/draw_region")); + _modeSelect.ItemSelected += index => _canvas.SetMode(index == 1 ? MapEditorCanvas.ModeDraw : MapEditorCanvas.ModeSelect); + toolbar.AddChild(_modeSelect); + + toolbar.AddChild(new Label { Text = Tr("tools/map_editor/background") }); + _backgroundSelect = new OptionButton(); + _backgroundSelect.ItemSelected += OnBackgroundSelected; + toolbar.AddChild(_backgroundSelect); + + _previewButton = new Button { Text = Tr("tools/map_editor/preview") }; + _previewButton.TooltipText = Tr("tools/map_editor/preview_tip"); + _previewButton.Pressed += OnPreviewPressed; + toolbar.AddChild(_previewButton); + + _statusLabel = new Label { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, HorizontalAlignment = HorizontalAlignment.Right }; + _statusLabel.AddThemeColorOverride("font_color", UiTokens.Muted); + toolbar.AddChild(_statusLabel); + + return toolbar; + } + + private void BuildSidebar(VBoxContainer sidebar) + { + sidebar.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/zones") }); + + _zoneList = new ItemList { CustomMinimumSize = new Vector2(0, 140) }; + _zoneList.ItemSelected += OnZoneSelected; + sidebar.AddChild(_zoneList); + + var addRow = new HBoxContainer(); + sidebar.AddChild(addRow); + AddButton(addRow, "tools/map_editor/add_region", OnAddRegion); + AddButton(addRow, "tools/map_editor/add_marker", OnAddMarker); + AddButton(addRow, "tools/map_editor/add_label", OnAddLabel); + + var deleteBtn = new Button { Text = Tr("tools/map_editor/delete") }; + deleteBtn.Pressed += OnDeleteZone; + sidebar.AddChild(deleteBtn); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/props") }); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/id") }); + _idEdit = new LineEdit(); + _idEdit.TextChanged += _ => OnIdChanged(); + sidebar.AddChild(_idEdit); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/title_key") }); + _titleEdit = new LineEdit(); + _titleEdit.TextChanged += _ => OnTitleChanged(); + sidebar.AddChild(_titleEdit); + + _titlePreview = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart }; + _titlePreview.AddThemeColorOverride("font_color", UiTokens.Faint); + sidebar.AddChild(_titlePreview); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/scene") }); + _sceneSelect = new OptionButton(); + _sceneSelect.ItemSelected += _ => OnFieldsChanged(); + sidebar.AddChild(_sceneSelect); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/description") }); + _descEdit = new LineEdit(); + _descEdit.TextChanged += _ => OnFieldsChanged(); + sidebar.AddChild(_descEdit); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/image") }); + _imageEdit = new LineEdit(); + _imageEdit.TextChanged += _ => OnImageChanged(); + sidebar.AddChild(_imageEdit); + + _iconList = new ItemList { CustomMinimumSize = new Vector2(0, 100) }; + _iconList.ItemSelected += OnIconSelected; + sidebar.AddChild(_iconList); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/image_pos") }); + var posRow = new HBoxContainer(); + sidebar.AddChild(posRow); + _posX = AddLabeledLine(posRow, "tools/map_editor/x"); + _posY = AddLabeledLine(posRow, "tools/map_editor/y"); + _posX.TextChanged += _ => OnPositionChanged(); + _posY.TextChanged += _ => OnPositionChanged(); + + _labelCheck = new CheckBox { Text = Tr("tools/map_editor/label") }; + _labelCheck.Toggled += OnLabelToggled; + sidebar.AddChild(_labelCheck); + + var labelRow = new HBoxContainer(); + sidebar.AddChild(labelRow); + _labelX = AddLabeledLine(labelRow, "tools/map_editor/x"); + _labelY = AddLabeledLine(labelRow, "tools/map_editor/y"); + _labelX.TextChanged += _ => OnPositionChanged(); + _labelY.TextChanged += _ => OnPositionChanged(); + + _lockedCheck = new CheckBox { Text = Tr("tools/map_editor/locked") }; + _lockedCheck.Toggled += _ => OnFieldsChanged(); + sidebar.AddChild(_lockedCheck); + + _hiddenCheck = new CheckBox { Text = Tr("tools/map_editor/hidden") }; + _hiddenCheck.Toggled += _ => OnFieldsChanged(); + sidebar.AddChild(_hiddenCheck); + + sidebar.AddChild(new Label { Text = Tr("tools/map_editor/vertices") }); + + var vertexButtons = new HBoxContainer(); + sidebar.AddChild(vertexButtons); + var addVertexBtn = new Button { Text = Tr("tools/map_editor/add_vertex") }; + addVertexBtn.Pressed += OnAddVertex; + vertexButtons.AddChild(addVertexBtn); + + _vertexList = new VBoxContainer(); + sidebar.AddChild(_vertexList); + } + + private static Button AddButton(Container parent, string localeKey, Action pressed) + { + var button = new Button { Text = Tr(localeKey) }; + button.Pressed += pressed; + parent.AddChild(button); + return button; + } + + private static LineEdit AddLabeledLine(Container parent, string localeKey) + { + var label = new Label { Text = Tr(localeKey), CustomMinimumSize = new Vector2(20, 0) }; + parent.AddChild(label); + + var edit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + parent.AddChild(edit); + return edit; + } + + // ── data loading ─────────────────────────────────────────── + + private void LoadModList() + { + _writableMods.Clear(); + _modSelect.Clear(); + + _writableMods.AddRange(MapFileService.ListWritableMods()); + foreach (var modId in _writableMods) + _modSelect.AddItem(modId); + + if (_writableMods.Count == 0) + SetStatus(Tr("tools/map_editor/no_mods")); + } + + private void LoadSceneList() + { + _sceneIds.Clear(); + _sceneSelect.Clear(); + _sceneSelect.AddItem(Tr("tools/map_editor/scene_none")); + + var defs = SceneDefinitions.Collect(); + if (defs != null) + _sceneIds.AddRange(defs.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)); + + foreach (var scene in _sceneIds) + _sceneSelect.AddItem(scene); + } + + private void LoadTextureList() + { + _textureIds.Clear(); + _iconList.Clear(); + + _textureIds.AddRange(ModResourceService.GetTextureIds()); + foreach (var id in _textureIds) + _iconList.AddItem(id); + } + + private void LoadBackgroundList() + { + _backgroundIds.Clear(); + _backgroundSelect.Clear(); + + _backgroundIds.AddRange(ModResourceService.GetBackgroundIds()); + foreach (var id in _backgroundIds) + _backgroundSelect.AddItem(id); + + var index = _backgroundIds.FindIndex(id => string.Equals(id, "map", StringComparison.OrdinalIgnoreCase)); + if (index < 0 && _backgroundIds.Count > 0) + index = 0; + + if (index >= 0) + { + _backgroundSelect.Select(index); + _canvas.SetBackground(ModResourceService.LoadBackgroundTexture(_backgroundIds[index])); + } + } + + private string SelectedMod => _modSelect.Selected >= 0 && _modSelect.Selected < _writableMods.Count + ? _writableMods[_modSelect.Selected] + : null; + + private string SelectedFileName + { + get + { + var name = _fileEdit.Text.Trim().Replace('\\', '/'); + if (string.IsNullOrEmpty(name)) + name = "map.json"; + if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + name += ".json"; + return name; + } + } + + private void OnFileChanged() + { + OnLoad(); + } + + // ── toolbar actions ──────────────────────────────────────── + + private void OnLoad() + { + var modId = SelectedMod; + if (modId == null) + { + SetStatus(Tr("tools/map_editor/no_mods")); + return; + } + + var path = "map/" + SelectedFileName; + try + { + var text = MapFileService.ReadFile(modId, path); + if (string.IsNullOrEmpty(text)) + { + _controller.Clear(); + MarkClean(); + SetStatus(Tr("tools/map_editor/no_file")); + } + else + { + _controller.FromJson(text); + MarkClean(); + SetStatus(path); + } + } + catch (Exception ex) + { + SetStatus($"{Tr("tools/map_editor/load_error")}: {ex.Message}"); + GameLogger.Log("MAP_EDITOR", $"load failed for '{modId}/{path}': {ex.Message}", LogLevel.Error); + } + + RefreshAll(); + } + + private void OnSave() + { + var zone = _controller.Selected; + if (zone != null && !ValidateId(zone.ID, out var error)) + { + SetStatus(error); + return; + } + + var modId = SelectedMod; + if (modId == null || !MapFileService.WriteFile(modId, "map/" + SelectedFileName, _controller.ToJson())) + { + SetStatus(Tr("tools/map_editor/readonly")); + return; + } + + MarkClean(); + SetStatus(Tr("tools/map_editor/save_ok")); + ReloadRunningMap(); + } + + /// + /// Pushes the saved data into the running game. When the map panel was + /// already opened once, its widget re-reads through the service and + /// rebuilds; otherwise only the service is reloaded and the widget + /// picks the data up on its first open. + /// + private static void ReloadRunningMap() + { + if (UiHost.Instance != null + && UiHost.Instance.TryGetInstance("map", out var control) + && control is MapWidget mapWidget) + { + mapWidget.Reload(); + return; + } + + MapZoneService.Instance.Reload(); + } + + private void OnReloadMap() + { + ReloadRunningMap(); + SetStatus(Tr("tools/map_editor/reloaded")); + } + + // ── game-like preview ────────────────────────────────────── + + /// True while the game-like preview covers the editor. + public bool PreviewActive => _preview != null; + + /// + /// Opens the game-like preview on top of the editor: the real + /// map_widget.tscn fed with the current — possibly unsaved — + /// zone list, so hover highlights, marker pills, the location card and + /// the transition button behave exactly as in the running map. The + /// preview ends through the widget itself: its close button or Escape + /// hides it, and the editor resumes. Returns false when the map + /// widget scene cannot be resolved. + /// + public bool StartPreview() + { + if (_preview != null) + return true; + + ReloadLocalization(); + + var widget = TscnScenes.LoadAndBuild("scenes/ui/map_widget.tscn") as MapWidget; + if (widget == null) + { + SetStatus(Tr("tools/map_editor/preview_missing")); + return false; + } + + widget.SetPreviewZones(_controller.Zones); + widget.VisibilityChanged += OnPreviewVisibilityChanged; + AddChild(widget); + widget.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + _preview = widget; + + widget.EnsureConstructed(); + widget.Show(); + SetStatus(Tr("tools/map_editor/preview_on")); + return true; + } + + /// Closes the preview and returns to editing. + public void StopPreview() + { + if (_preview == null) + return; + + var widget = _preview; + _preview = null; + + if (GodotObject.IsInstanceValid(widget)) + { + widget.VisibilityChanged -= OnPreviewVisibilityChanged; + widget.QueueFree(); + } + + UpdateTitlePreview(); + _canvas.QueueRedraw(); + SetStatus(Tr("tools/map_editor/preview_off")); + } + + private void OnPreviewPressed() + { + StartPreview(); + } + + private void OnPreviewVisibilityChanged() + { + if (_preview != null && GodotObject.IsInstanceValid(_preview) && !_preview.Visible) + StopPreview(); + } + + /// + /// Re-reads every mod's locale files so keys edited after the game + /// booted — the common case while authoring titles and descriptions — + /// resolve to their new values in the editor and in the preview. + /// + private static void ReloadLocalization() + { + if (GameData.Instance == null) + return; + + try + { + LocaleLoader.LoadCurrentLanguage(); + GameLogger.Log("MAP_EDITOR", "localisation reloaded"); + } + catch (Exception ex) + { + GameLogger.Log("MAP_EDITOR", $"localisation reload failed: {ex.Message}", LogLevel.Warning); + } + } + + private void OnBackgroundSelected(long index) + { + if (index < 0 || index >= _backgroundIds.Count) + return; + + _canvas.SetBackground(ModResourceService.LoadBackgroundTexture(_backgroundIds[(int)index])); + } + + // ── zone structure actions ───────────────────────────────── + + private void OnAddRegion() + { + _controller.AddRegion(); + _modeSelect.Select(1); + _canvas.SetMode(MapEditorCanvas.ModeDraw); + SetDirty(); + RefreshAll(); + } + + private void OnAddMarker() + { + _controller.AddMarker(); + _modeSelect.Select(0); + _canvas.SetMode(MapEditorCanvas.ModeSelect); + SetDirty(); + RefreshAll(); + } + + private void OnAddLabel() + { + _controller.AddLabel(); + _modeSelect.Select(0); + _canvas.SetMode(MapEditorCanvas.ModeSelect); + SetDirty(); + RefreshAll(); + } + + private void OnDeleteZone() + { + if (_controller.SelectedIndex < 0) + { + SetStatus(Tr("tools/map_editor/zone_missing")); + return; + } + + _controller.RemoveAt(_controller.SelectedIndex); + SetDirty(); + RefreshAll(); + } + + private void OnZoneSelected(long index) + { + if (_suppress) + return; + + _controller.Select((int)index); + RefreshAll(); + } + + private void OnCanvasZonePicked(int index) + { + if (_suppress) + return; + + _controller.Select(index); + RefreshSelectionOnly(); + } + + private void OnCanvasChanged() + { + if (_suppress) + return; + + SetDirty(); + RefreshValuesOnly(); + } + + private void OnAddVertex() + { + var zone = _controller.Selected; + if (zone == null) + { + SetStatus(Tr("tools/map_editor/zone_missing")); + return; + } + + _controller.AddVertex(zone.Points != null && zone.Points.Count > 0 + ? zone.Points[zone.Points.Count - 1] + : new Godot.Vector2(0.5f, 0.5f)); + SetDirty(); + RefreshAll(); + } + + private void RemoveVertex(int index) + { + _controller.RemoveVertex(index); + SetDirty(); + RefreshAll(); + } + + // ── property editors ─────────────────────────────────────── + + private void OnIdChanged() + { + if (_suppress) + return; + + var zone = _controller.Selected; + if (zone == null) + return; + + var id = _idEdit.Text.Trim(); + if (ValidateId(id, out var error)) + { + zone.ID = id; + SetDirty(); + } + else + { + SetStatus(error); + } + + RefreshJson(); + UpdateZoneListLabel(); + } + + private bool ValidateId(string id, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(id)) + { + error = Tr("tools/map_editor/id_empty"); + return false; + } + + foreach (var zone in _controller.Zones) + { + if (!ReferenceEquals(zone, _controller.Selected) + && string.Equals(zone.ID, id, StringComparison.OrdinalIgnoreCase)) + { + error = Tr("tools/map_editor/duplicate_id"); + return false; + } + } + + return true; + } + + private void OnTitleChanged() + { + if (_suppress) + return; + + var zone = _controller.Selected; + if (zone == null) + return; + + zone.Title = _titleEdit.Text.Trim(); + UpdateTitlePreview(); + SetDirty(); + RefreshJson(); + } + + private void OnFieldsChanged() + { + if (_suppress) + return; + + var zone = _controller.Selected; + if (zone == null) + return; + + zone.Scene = _sceneSelect.Selected <= 0 ? string.Empty : _sceneIds[(int)_sceneSelect.Selected - 1]; + zone.Description = _descEdit.Text.Trim(); + zone.Locked = _lockedCheck.ButtonPressed; + zone.Hidden = _hiddenCheck.ButtonPressed; + + SetDirty(); + RefreshJson(); + UpdateZoneListLabel(); + } + + private void OnImageChanged() + { + if (_suppress) + return; + + var zone = _controller.Selected; + if (zone == null) + return; + + zone.Image = _imageEdit.Text.Trim(); + SetDirty(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + RefreshJson(); + } + + private void OnIconSelected(long index) + { + if (_suppress || index < 0 || index >= _textureIds.Count) + return; + + var zone = _controller.Selected; + if (zone == null) + return; + + zone.Image = _textureIds[(int)index]; + _imageEdit.Text = zone.Image; + SetDirty(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + RefreshJson(); + } + + private void OnPositionChanged() + { + if (_suppress) + return; + + var zone = _controller.Selected; + if (zone == null) + return; + + if (TryParseFloat(_posX.Text, out var mx) && TryParseFloat(_posY.Text, out var my)) + zone.ImagePos = Clamp01(new Godot.Vector2(mx, my)); + + if (zone.LabelPos.HasValue + && TryParseFloat(_labelX.Text, out var lx) + && TryParseFloat(_labelY.Text, out var ly)) + { + zone.LabelPos = Clamp01(new Godot.Vector2(lx, ly)); + } + + SetDirty(); + _canvas.QueueRedraw(); + RefreshJson(); + } + + private void OnLabelToggled(bool enabled) + { + if (_suppress) + return; + + _controller.SetLabelEnabled(enabled); + _labelX.Editable = enabled; + _labelY.Editable = enabled; + _suppress = true; + try + { + WritePositionsToSidebar(_controller.Selected); + } + finally + { + _suppress = false; + } + SetDirty(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + RefreshJson(); + } + + // ── refresh ──────────────────────────────────────────────── + + private void RefreshAll() + { + RefreshZoneList(); + RefreshValuesOnly(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + } + + private void RefreshSelectionOnly() + { + RefreshZoneList(); + RefreshValuesOnly(); + } + + private void RefreshValuesOnly() + { + _suppress = true; + try + { + WriteSidebarFromController(); + } + finally + { + _suppress = false; + } + + RefreshVertexList(); + RefreshJson(); + } + + private void RefreshZoneList() + { + _suppress = true; + try + { + _zoneList.Clear(); + foreach (var zone in _controller.Zones) + _zoneList.AddItem(string.IsNullOrEmpty(zone.ID) ? "?" : zone.ID); + + if (_controller.SelectedIndex >= 0) + _zoneList.Select(_controller.SelectedIndex); + } + finally + { + _suppress = false; + } + } + + private void UpdateZoneListLabel() + { + var index = _controller.SelectedIndex; + if (index < 0 || index >= _zoneList.ItemCount) + return; + + _zoneList.SetItemText(index, _controller.Selected?.ID ?? "?"); + } + + private void WriteSidebarFromController() + { + var zone = _controller.Selected; + var hasZone = zone != null; + + _idEdit.Editable = hasZone; + _titleEdit.Editable = hasZone; + _descEdit.Editable = hasZone; + _imageEdit.Editable = hasZone; + _sceneSelect.Disabled = !hasZone; + _lockedCheck.Disabled = !hasZone; + _hiddenCheck.Disabled = !hasZone; + _labelCheck.Disabled = !hasZone; + _posX.Editable = hasZone; + _posY.Editable = hasZone; + _labelX.Editable = hasZone && zone.LabelPos.HasValue; + _labelY.Editable = hasZone && zone.LabelPos.HasValue; + + if (!hasZone) + { + _idEdit.Text = string.Empty; + _titleEdit.Text = string.Empty; + _descEdit.Text = string.Empty; + _imageEdit.Text = string.Empty; + _titlePreview.Text = string.Empty; + _posX.Text = string.Empty; + _posY.Text = string.Empty; + _labelX.Text = string.Empty; + _labelY.Text = string.Empty; + _labelCheck.ButtonPressed = false; + return; + } + + _idEdit.Text = zone.ID ?? string.Empty; + _titleEdit.Text = zone.Title ?? string.Empty; + _descEdit.Text = zone.Description ?? string.Empty; + _imageEdit.Text = zone.Image ?? string.Empty; + _lockedCheck.ButtonPressed = zone.Locked; + _hiddenCheck.ButtonPressed = zone.Hidden; + _labelCheck.ButtonPressed = zone.LabelPos.HasValue; + + var sceneIndex = _sceneIds.FindIndex(id => string.Equals(id, zone.Scene, StringComparison.OrdinalIgnoreCase)); + _sceneSelect.Select(sceneIndex >= 0 ? sceneIndex + 1 : 0); + + var iconIndex = _textureIds.FindIndex(id => string.Equals(id, zone.Image, StringComparison.OrdinalIgnoreCase)); + if (iconIndex >= 0) + _iconList.Select(iconIndex); + + WritePositionsToSidebar(zone); + UpdateTitlePreview(); + } + + private void WritePositionsToSidebar(MapZone zone) + { + if (zone == null) + return; + + _posX.Text = zone.ImagePos.X.ToString("F3", CultureInfo.InvariantCulture); + _posY.Text = zone.ImagePos.Y.ToString("F3", CultureInfo.InvariantCulture); + + var labelPos = zone.LabelPos ?? Godot.Vector2.Zero; + _labelX.Text = labelPos.X.ToString("F3", CultureInfo.InvariantCulture); + _labelY.Text = labelPos.Y.ToString("F3", CultureInfo.InvariantCulture); + } + + private void UpdateTitlePreview() + { + var key = _titleEdit.Text.Trim(); + if (string.IsNullOrEmpty(key)) + { + _titlePreview.Text = string.Empty; + return; + } + + var translated = TranslationServer.Translate(key).ToString(); + _titlePreview.Text = string.Equals(translated, key, StringComparison.Ordinal) + ? Tr("tools/map_editor/no_translation") + : translated; + } + + private void RefreshVertexList() + { + foreach (var child in _vertexList.GetChildren()) + child.QueueFree(); + + var zone = _controller.Selected; + if (zone?.Points == null) + return; + + for (var i = 0; i < zone.Points.Count; i++) + { + var index = i; + var point = zone.Points[i]; + var row = new HBoxContainer(); + + var xEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + xEdit.Text = point.X.ToString("F3", CultureInfo.InvariantCulture); + xEdit.TextChanged += text => + { + if (TryParseFloat(text, out var value)) + _controller.SetVertex(index, new Godot.Vector2(value, point.Y)); + OnVertexEdited(); + }; + row.AddChild(xEdit); + + var yEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + yEdit.Text = point.Y.ToString("F3", CultureInfo.InvariantCulture); + yEdit.TextChanged += text => + { + if (TryParseFloat(text, out var value)) + _controller.SetVertex(index, new Godot.Vector2(point.X, value)); + OnVertexEdited(); + }; + row.AddChild(yEdit); + + var deleteBtn = new Button { Text = "X", CustomMinimumSize = new Vector2(32, 0) }; + deleteBtn.Pressed += () => RemoveVertex(index); + row.AddChild(deleteBtn); + + _vertexList.AddChild(row); + } + } + + private void OnVertexEdited() + { + SetDirty(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + RefreshJson(); + } + + private void RefreshJson() + { + _jsonEdit.Text = _controller.ToJson(); + } + + private void SetStatus(string text) + { + _statusLabel.Text = text ?? string.Empty; + } + + private static string Tr(string key) => TranslationServer.Translate(key); + + private static bool TryParseFloat(string text, out float value) + { + return float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + + private static Godot.Vector2 Clamp01(Godot.Vector2 value) + { + return new Godot.Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f)); + } + } +} diff --git a/src/Services/MapFileService.cs b/src/Services/MapFileService.cs new file mode 100644 index 0000000..627c398 --- /dev/null +++ b/src/Services/MapFileService.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Providers; +using Cthangover.Core.UI.Tool; +using Cthangover.Core.Utils; + +namespace Cthangover.Tools.Services +{ + /// + /// Static service for discovering, reading and writing world map zone files + /// (mods/<mod>/map/*.json) from the map editor. Follows + /// : reading goes through the mod file + /// provider abstraction so zip mods are visible, writing is deliberately + /// limited to folder-backed mods because zip archives are read-only. + /// + /// New files are created through the provider's RootPath rather than + /// GetFileSystemPath, which reports null for files that do not + /// exist yet — the editor must be able to author a brand-new map file, not + /// only overwrite an existing one. + /// + public static class MapFileService + { + private const string MapDir = "map"; + private const string JsonExtension = ".json"; + + /// + /// IDs of every loaded mod that can be written to (folder-backed). + /// Zip mods are skipped silently: they appear in the game but the editor + /// cannot save into them. + /// + public static List ListWritableMods() + { + var result = new List(); + var manager = ModManager.Instance; + if (manager?.Mods == null) + return result; + + foreach (var pair in manager.Mods) + { + if (pair.Value?.FileProvider is FolderModFileProvider) + result.Add(pair.Key); + } + + result.Sort(StringComparer.OrdinalIgnoreCase); + return result; + } + + /// + /// Mod-relative paths of all .json files under the mod's + /// map/ directory, including nested subdirectories. Returns an + /// empty list for unknown mods. + /// + public static List ListMapFiles(string modId) + { + var result = new List(); + if (string.IsNullOrEmpty(modId)) + return result; + + CollectJsonFiles(modId, MapDir, result); + result.Sort(StringComparer.OrdinalIgnoreCase); + return result; + } + + /// Reads a map file through the mod provider; null when missing. + public static string ReadFile(string modId, string relativePath) + { + return ModManager.Instance.ReadFileText(modId, relativePath); + } + + /// + /// Writes a map file into a folder-backed mod, creating the + /// map/ directory and any missing parents. Returns false + /// for zip mods, unknown mods and empty paths. + /// + public static bool WriteFile(string modId, string relativePath, string text) + { + if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath)) + return false; + + var mod = ModManager.Instance.GetMod(modId); + if (mod?.FileProvider is not FolderModFileProvider provider) + return false; + + var root = provider.RootPath; + if (string.IsNullOrEmpty(root)) + return false; + + var absolute = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', '/'))); + var directory = Path.GetDirectoryName(absolute); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(absolute, text); + return true; + } + + /// + /// Parses the standard {"Items": [...]} envelope into zones. + /// Throws on malformed JSON so the editor + /// can show the error instead of silently opening an empty map. + /// + public static List ParseZones(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + var data = JsonSerializer.Deserialize>(json, JsonOptionsProvider.ReadOptions); + return data?.Items ?? new List(); + } + + /// + /// Serialises zones into the standard envelope with indentation and + /// without null fields, so hand-editing the result stays pleasant and + /// optional properties such as label_pos only appear when used. + /// + public static string BuildJson(IEnumerable zones) + { + var data = new FileData { Items = zones?.ToList() ?? new List() }; + return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions); + } + + private static void CollectJsonFiles(string modId, string directory, List result) + { + foreach (var entry in ModManager.Instance.ListFiles(modId, directory)) + { + if (entry.EndsWith("/")) + { + CollectJsonFiles(modId, entry.TrimEnd('/'), result); + continue; + } + + if (entry.EndsWith(JsonExtension, StringComparison.OrdinalIgnoreCase)) + result.Add(entry.Replace('\\', '/')); + } + } + } +} diff --git a/src/Services/ModResourceService.cs b/src/Services/ModResourceService.cs index 6982580..25ea70d 100644 --- a/src/Services/ModResourceService.cs +++ b/src/Services/ModResourceService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using Cthangover.Core.Factories.Impls; @@ -53,6 +54,27 @@ namespace Cthangover.Tools.Services } return set; } + + /// + /// Returns every texture logical name (filename without extension) from + /// all configured texture groups, sorted for stable UI lists. Used by + /// the map editor's marker icon picker: these are exactly the IDs that + /// MapZone.Image accepts, because resolution also keys textures + /// by basename. + /// + public static List GetTextureIds() + { + var ids = new List(); + var textures = Textures.Collect(); + if (textures == null) + return ids; + + foreach (var kvp in textures) + ids.Add(kvp.Key); + + ids.Sort(StringComparer.OrdinalIgnoreCase); + return ids; + } /// /// Loads a background texture by ID via .