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 .