map ui, skill ui, update skills data
This commit is contained in:
1 parent
86b3bf97a7
commit
d82733e3f7
10 files changed
+2061
No files matched your search
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Interactive preview of the edited map: draws the background with the
|
||||
/// exact aspect-preserving layout used by <c>MapWidget</c>, overlays every
|
||||
/// zone polygon, marker and text label, and hosts the draggable handles.
|
||||
/// Implements the two editor modes:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>select</b> — clicking picks the topmost zone (polygon first,
|
||||
/// then marker/label dots); handles move vertices and positions;</item>
|
||||
/// <item><b>draw</b> — clicking appends polygon vertices to the selected
|
||||
/// region and double-click finishes the polygon and returns to select
|
||||
/// mode.</item>
|
||||
/// </list>
|
||||
/// 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
|
||||
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> 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.
|
||||
/// <para/>
|
||||
/// Zone fills and outlines are painted through <see cref="MapZoneStyle"/> —
|
||||
/// the same definition the game's <c>MapZonesLayer</c> 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.
|
||||
/// </summary>
|
||||
public partial class MapEditorCanvas : Control
|
||||
{
|
||||
/// <summary>Mode constant: clicking selects zones and drags handles.</summary>
|
||||
public const string ModeSelect = "select";
|
||||
|
||||
/// <summary>Mode constant: clicking appends polygon vertices.</summary>
|
||||
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<MapEditorHandle> _handles = new();
|
||||
|
||||
/// <summary>Data being edited; set once by the window.</summary>
|
||||
public MapEditorController Controller;
|
||||
|
||||
/// <summary>Reference background; <c>null</c> falls back to a flat rectangle.</summary>
|
||||
public Texture2D Background;
|
||||
|
||||
/// <summary>Current interaction mode, one of <see cref="ModeSelect"/> / <see cref="ModeDraw"/>.</summary>
|
||||
public string Mode { get; private set; } = ModeSelect;
|
||||
|
||||
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, pick).</summary>
|
||||
public event Action Changed;
|
||||
|
||||
/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
|
||||
public event Action<int> ZonePicked;
|
||||
|
||||
public MapEditorCanvas()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
ClipContents = true;
|
||||
MapZoneStyle.Prepare(this);
|
||||
GuiInput += OnGuiInput;
|
||||
MouseExited += OnMouseExited;
|
||||
Resized += OnResized;
|
||||
Connect("draw", Callable.From((Action)DrawAll));
|
||||
}
|
||||
|
||||
/// <summary>Switches the interaction mode and refreshes the handles.</summary>
|
||||
public void SetMode(string mode)
|
||||
{
|
||||
Mode = mode == ModeDraw ? ModeDraw : ModeSelect;
|
||||
RebuildHandles();
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Assigns the background texture and rebuilds the layout.</summary>
|
||||
public void SetBackground(Texture2D texture)
|
||||
{
|
||||
Background = texture;
|
||||
RebuildHandles();
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Topmost zone under a canvas-local point: polygons win over marker/label dots.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Vector2> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static bool HasMarker(MapZone zone)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(zone.Image))
|
||||
return true;
|
||||
|
||||
return (zone.Points == null || zone.Points.Count == 0) && !zone.LabelPos.HasValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a vertex from its handle's right-click. Marker and label
|
||||
/// handles (negative indexes) are not vertices and are ignored.
|
||||
/// </summary>
|
||||
private void RemoveVertexHandle(int index)
|
||||
{
|
||||
if (index < 0 || Controller == null)
|
||||
return;
|
||||
|
||||
Controller.RemoveVertex(index);
|
||||
RebuildHandles();
|
||||
Changed?.Invoke();
|
||||
QueueRedraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="MapFileService"/> serialises back into
|
||||
/// <c>mods/<mod>/map/*.json</c>, while the live game state belongs to
|
||||
/// <c>MapZoneService</c>.
|
||||
/// <para/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class MapEditorController
|
||||
{
|
||||
private const float DefaultRegionHalfSize = 0.06f;
|
||||
private static readonly Vector2 DefaultCenter = new(0.5f, 0.5f);
|
||||
|
||||
/// <summary>Zones currently being edited, in map file order.</summary>
|
||||
public List<MapZone> Zones { get; private set; } = new();
|
||||
|
||||
/// <summary>Index of the selected zone, or -1 when nothing is selected.</summary>
|
||||
public int SelectedIndex { get; private set; } = -1;
|
||||
|
||||
/// <summary>The selected zone, or <c>null</c> when the selection is empty.</summary>
|
||||
public MapZone Selected => SelectedIndex >= 0 && SelectedIndex < Zones.Count ? Zones[SelectedIndex] : null;
|
||||
|
||||
/// <summary>Replaces all zones with the contents of a map file JSON envelope.</summary>
|
||||
public void FromJson(string json)
|
||||
{
|
||||
Zones = MapFileService.ParseZones(json);
|
||||
SelectedIndex = Zones.Count > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
/// <summary>Serialises the current zone list into the map file JSON envelope.</summary>
|
||||
public string ToJson() => MapFileService.BuildJson(Zones);
|
||||
|
||||
/// <summary>Drops all zones and the selection.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
Zones.Clear();
|
||||
SelectedIndex = -1;
|
||||
}
|
||||
|
||||
/// <summary>Creates a rectangular region around the map centre and selects it.</summary>
|
||||
public MapZone AddRegion()
|
||||
{
|
||||
var zone = new MapZone
|
||||
{
|
||||
ID = MakeUniqueId("region"),
|
||||
Title = string.Empty,
|
||||
Points = new List<Vector2>
|
||||
{
|
||||
DefaultCenter + new Vector2(-DefaultRegionHalfSize, -DefaultRegionHalfSize),
|
||||
DefaultCenter + new Vector2(DefaultRegionHalfSize, -DefaultRegionHalfSize),
|
||||
DefaultCenter + new Vector2(DefaultRegionHalfSize, DefaultRegionHalfSize),
|
||||
DefaultCenter + new Vector2(-DefaultRegionHalfSize, DefaultRegionHalfSize),
|
||||
},
|
||||
};
|
||||
|
||||
return Append(zone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an icon marker at the map centre with no icon chosen yet and
|
||||
/// selects it. The author assigns <c>image</c> and <c>scene</c> in the
|
||||
/// sidebar.
|
||||
/// </summary>
|
||||
public MapZone AddMarker()
|
||||
{
|
||||
var zone = new MapZone
|
||||
{
|
||||
ID = MakeUniqueId("marker"),
|
||||
Title = string.Empty,
|
||||
ImagePos = DefaultCenter,
|
||||
};
|
||||
|
||||
return Append(zone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a free-standing text label at the map centre and selects it.
|
||||
/// The label exists as soon as <c>label_pos</c> is set; <c>MapWidget</c>
|
||||
/// draws zones without an icon as inscriptions.
|
||||
/// </summary>
|
||||
public MapZone AddLabel()
|
||||
{
|
||||
var zone = new MapZone
|
||||
{
|
||||
ID = MakeUniqueId("label"),
|
||||
Title = string.Empty,
|
||||
LabelPos = DefaultCenter,
|
||||
};
|
||||
|
||||
return Append(zone);
|
||||
}
|
||||
|
||||
/// <summary>Removes the zone at the given index and adjusts the selection.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Selects the zone at the given index; out-of-range clears the selection.</summary>
|
||||
public void Select(int index)
|
||||
{
|
||||
SelectedIndex = index >= 0 && index < Zones.Count ? index : -1;
|
||||
}
|
||||
|
||||
/// <summary>Appends a vertex to the selected region's polygon.</summary>
|
||||
public void AddVertex(Vector2 normalized)
|
||||
{
|
||||
var zone = Selected;
|
||||
if (zone == null)
|
||||
return;
|
||||
|
||||
zone.Points ??= new List<Vector2>();
|
||||
zone.Points.Add(normalized);
|
||||
}
|
||||
|
||||
/// <summary>Moves one vertex of the selected region.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Removes one vertex of the selected region.</summary>
|
||||
public void RemoveVertex(int index)
|
||||
{
|
||||
var zone = Selected;
|
||||
if (zone?.Points == null || index < 0 || index >= zone.Points.Count)
|
||||
return;
|
||||
|
||||
zone.Points.RemoveAt(index);
|
||||
}
|
||||
|
||||
/// <summary>Moves the icon marker of the selected zone.</summary>
|
||||
public void SetImagePos(Vector2 normalized)
|
||||
{
|
||||
var zone = Selected;
|
||||
if (zone != null)
|
||||
zone.ImagePos = normalized;
|
||||
}
|
||||
|
||||
/// <summary>Moves the text label of the selected zone.</summary>
|
||||
public void SetLabelPos(Vector2 normalized)
|
||||
{
|
||||
var zone = Selected;
|
||||
if (zone != null)
|
||||
zone.LabelPos = normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables the free-standing label of the selected zone.
|
||||
/// Enabling without a known position places it at the map centre.
|
||||
/// </summary>
|
||||
public void SetLabelEnabled(bool enabled)
|
||||
{
|
||||
var zone = Selected;
|
||||
if (zone == null)
|
||||
return;
|
||||
|
||||
zone.LabelPos = enabled ? zone.LabelPos ?? DefaultCenter : null;
|
||||
}
|
||||
|
||||
/// <summary>ID set of every zone in the list, for duplicate validation.</summary>
|
||||
public HashSet<string> UsedIds()
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var zone in Zones)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(zone.ID))
|
||||
ids.Add(zone.ID);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an ID that is not used yet by appending a numeric suffix
|
||||
/// (<c>region</c>, <c>region_2</c>, <c>region_3</c>, ...).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.MapEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Draggable 16x16 handle on the map editor canvas. One handle represents
|
||||
/// either a polygon vertex, the marker position or the label position,
|
||||
/// distinguished by <see cref="Index"/> with negative values reserved for
|
||||
/// the position handles. Subscribes to the GuiInput C# event rather than
|
||||
/// overriding <c>_GuiInput</c> for reliable event delivery in mod
|
||||
/// assemblies, following <c>InteractiveEditorHandle</c>.
|
||||
/// </summary>
|
||||
public partial class MapEditorHandle : ColorRect
|
||||
{
|
||||
/// <summary>Handle role: 0..n — vertex index, <see cref="MarkerHandle"/> or <see cref="LabelHandle"/>.</summary>
|
||||
public const int MarkerHandle = -2;
|
||||
|
||||
/// <summary>Handle role used for the label position handle.</summary>
|
||||
public const int LabelHandle = -3;
|
||||
|
||||
/// <summary>Vertex index or one of the negative role constants.</summary>
|
||||
public int Index;
|
||||
|
||||
/// <summary>Invoked during drag with the handle centre in global coordinates.</summary>
|
||||
public System.Action<Vector2> DragUpdate;
|
||||
|
||||
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
|
||||
public System.Action Clicked;
|
||||
|
||||
/// <summary>Invoked when the drag ends (mouse up).</summary>
|
||||
public System.Action DragEnd;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public System.Action RightClicked;
|
||||
|
||||
private bool _dragging;
|
||||
private Vector2 _dragOffset;
|
||||
|
||||
/// <summary>Creates a handle with the given colour and role index.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Cthangover.Core.UI.Tool;
|
||||
|
||||
namespace Cthangover.Tools.MapEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Toolbox button that opens the map editor from the in-game toolbar.
|
||||
/// Matches the tool id registered by <see cref="MapEditorToolProvider"/>;
|
||||
/// always visible because map authoring is part of the normal dev workflow.
|
||||
/// </summary>
|
||||
public class MapEditorToolBoxButton : IToolBoxButton
|
||||
{
|
||||
/// <summary>Matches <see cref="MapEditorToolProvider.Id"/>.</summary>
|
||||
public string ToolId => "map_editor";
|
||||
|
||||
/// <summary>No dedicated icon — the button uses its text label.</summary>
|
||||
public string IconPath => "";
|
||||
|
||||
/// <summary>Translation key for the button label.</summary>
|
||||
public string LocaleKey => "tools/map_editor/title";
|
||||
|
||||
/// <summary>Always visible in the toolbox.</summary>
|
||||
public bool IsVisible() => true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Cthangover.Core.UI.Tool;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.MapEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the map editor as a discoverable tool window. Discovery is
|
||||
/// automatic: <c>ToolFactory</c> scans mod assemblies for
|
||||
/// <see cref="IToolProvider"/> implementations, so no manifest entry is
|
||||
/// needed. The provider only knows how to build the window; all editing
|
||||
/// logic lives in <see cref="MapEditorWindow"/>.
|
||||
/// </summary>
|
||||
public class MapEditorToolProvider : IToolProvider
|
||||
{
|
||||
/// <summary>Unique tool identifier used by the toolbox button.</summary>
|
||||
public string Id => "map_editor";
|
||||
|
||||
/// <summary>Translation key for the tool's display name.</summary>
|
||||
public string LocaleKey => "tools/map_editor/title";
|
||||
|
||||
/// <summary>Creates the editor window instance.</summary>
|
||||
public Window CreateWindow() => new MapEditorWindow();
|
||||
|
||||
/// <summary>Adds the map editor's launcher button to the main menu tool block.</summary>
|
||||
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Static service for discovering, reading and writing world map zone files
|
||||
/// (<c>mods/<mod>/map/*.json</c>) from the map editor. Follows
|
||||
/// <see cref="ScenarioFileService"/>: 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.
|
||||
/// <para/>
|
||||
/// New files are created through the provider's <c>RootPath</c> rather than
|
||||
/// <c>GetFileSystemPath</c>, which reports <c>null</c> 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.
|
||||
/// </summary>
|
||||
public static class MapFileService
|
||||
{
|
||||
private const string MapDir = "map";
|
||||
private const string JsonExtension = ".json";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static List<string> ListWritableMods()
|
||||
{
|
||||
var result = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mod-relative paths of all <c>.json</c> files under the mod's
|
||||
/// <c>map/</c> directory, including nested subdirectories. Returns an
|
||||
/// empty list for unknown mods.
|
||||
/// </summary>
|
||||
public static List<string> ListMapFiles(string modId)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrEmpty(modId))
|
||||
return result;
|
||||
|
||||
CollectJsonFiles(modId, MapDir, result);
|
||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Reads a map file through the mod provider; <c>null</c> when missing.</summary>
|
||||
public static string ReadFile(string modId, string relativePath)
|
||||
{
|
||||
return ModManager.Instance.ReadFileText(modId, relativePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a map file into a folder-backed mod, creating the
|
||||
/// <c>map/</c> directory and any missing parents. Returns <c>false</c>
|
||||
/// for zip mods, unknown mods and empty paths.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the standard <c>{"Items": [...]}</c> envelope into zones.
|
||||
/// Throws <see cref="JsonException"/> on malformed JSON so the editor
|
||||
/// can show the error instead of silently opening an empty map.
|
||||
/// </summary>
|
||||
public static List<MapZone> ParseZones(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new List<MapZone>();
|
||||
|
||||
var data = JsonSerializer.Deserialize<FileData<MapZone>>(json, JsonOptionsProvider.ReadOptions);
|
||||
return data?.Items ?? new List<MapZone>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialises zones into the standard envelope with indentation and
|
||||
/// without null fields, so hand-editing the result stays pleasant and
|
||||
/// optional properties such as <c>label_pos</c> only appear when used.
|
||||
/// </summary>
|
||||
public static string BuildJson(IEnumerable<MapZone> zones)
|
||||
{
|
||||
var data = new FileData<MapZone> { Items = zones?.ToList() ?? new List<MapZone>() };
|
||||
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
|
||||
}
|
||||
|
||||
private static void CollectJsonFiles(string modId, string directory, List<string> 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('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
@@ -54,6 +55,27 @@ namespace Cthangover.Tools.Services
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>MapZone.Image</c> accepts, because resolution also keys textures
|
||||
/// by basename.
|
||||
/// </summary>
|
||||
public static List<string> GetTextureIds()
|
||||
{
|
||||
var ids = new List<string>();
|
||||
var textures = Textures.Collect();
|
||||
if (textures == null)
|
||||
return ids;
|
||||
|
||||
foreach (var kvp in textures)
|
||||
ids.Add(kvp.Key);
|
||||
|
||||
ids.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
|
||||
/// Returns <c>null</c> on any error (missing resource, load failure).
|
||||
|
||||
Reference in new issue
Block a user