init mod
This commit is contained in:
commit
025a7aeee9
17 files changed
+2303
No files matched your search
@@ -0,0 +1,41 @@
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Closes a map zone for travel by ID. Used for zones that were open by
|
||||
/// default but must become unavailable at some story point, or to take back
|
||||
/// access granted earlier. The override is stored by the map service and
|
||||
/// persisted in the save, so the closure survives scene changes and play
|
||||
/// sessions.
|
||||
/// </summary>
|
||||
public class MapCloseZoneAction : IScenarioAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Registered as "map.close_zone" — closes the zone named by the
|
||||
/// required "zone_id" dialog variable. Unknown IDs are ignored with a
|
||||
/// warning.
|
||||
/// </summary>
|
||||
public string Name => "map.close_zone";
|
||||
|
||||
/// <summary>
|
||||
/// Reads "zone_id" and delegates to the map service. Missing
|
||||
/// "zone_id" is logged and the action does nothing instead of throwing
|
||||
/// inside dialog execution.
|
||||
/// </summary>
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var zoneId = ctx.GetParam("zone_id");
|
||||
|
||||
if (string.IsNullOrEmpty(zoneId))
|
||||
{
|
||||
ctx.Log("EVENT", "MapCloseZoneAction: missing 'zone_id' variable");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Map.Close(zoneId);
|
||||
ctx.Log("MAP", $"MapCloseZoneAction: close '{zoneId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Hides a map zone by ID, removing it from the map until it is shown
|
||||
/// again. Useful when a location is destroyed or temporarily inaccessible
|
||||
/// in the story and the marker should disappear rather than appear closed.
|
||||
/// The override is stored by the map service and persisted in the save.
|
||||
/// </summary>
|
||||
public class MapHideZoneAction : IScenarioAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Registered as "map.hide_zone" — hides the zone named by the
|
||||
/// required "zone_id" dialog variable. Unknown IDs are ignored with a
|
||||
/// warning.
|
||||
/// </summary>
|
||||
public string Name => "map.hide_zone";
|
||||
|
||||
/// <summary>
|
||||
/// Reads "zone_id" and delegates to the map service. Missing
|
||||
/// "zone_id" is logged and the action does nothing instead of throwing
|
||||
/// inside dialog execution.
|
||||
/// </summary>
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var zoneId = ctx.GetParam("zone_id");
|
||||
|
||||
if (string.IsNullOrEmpty(zoneId))
|
||||
{
|
||||
ctx.Log("EVENT", "MapHideZoneAction: missing 'zone_id' variable");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Map.Hide(zoneId);
|
||||
ctx.Log("MAP", $"MapHideZoneAction: hide '{zoneId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Opens a map zone for travel by ID. The counterpart of
|
||||
/// <see cref="MapCloseZoneAction"/>: authors close a zone in the map JSON
|
||||
/// (<c>"locked": true</c>) and then open it from a scenario when the story
|
||||
/// allows visiting it, e.g. after a quest tag is granted. The override is
|
||||
/// stored by the map service and persisted in the save, so the zone stays
|
||||
/// open across scene changes and play sessions.
|
||||
/// </summary>
|
||||
public class MapOpenZoneAction : IScenarioAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Registered as "map.open_zone" — opens the zone named by the
|
||||
/// required "zone_id" dialog variable. Unknown IDs are ignored with a
|
||||
/// warning, so a scenario can safely reference a zone added by another
|
||||
/// mod that may not be installed.
|
||||
/// </summary>
|
||||
public string Name => "map.open_zone";
|
||||
|
||||
/// <summary>
|
||||
/// Reads "zone_id" and delegates to the map service. Missing
|
||||
/// "zone_id" is a script authoring error: it is logged and the action
|
||||
/// does nothing instead of throwing inside dialog execution.
|
||||
/// </summary>
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var zoneId = ctx.GetParam("zone_id");
|
||||
|
||||
if (string.IsNullOrEmpty(zoneId))
|
||||
{
|
||||
ctx.Log("EVENT", "MapOpenZoneAction: missing 'zone_id' variable");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Map.Open(zoneId);
|
||||
ctx.Log("MAP", $"MapOpenZoneAction: open '{zoneId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Reveals a hidden map zone by ID. Hidden zones are completely absent from
|
||||
/// the map — no polygon, no marker, no label, no zone counter entry — which
|
||||
/// lets authors keep late-game locations out of the early map without
|
||||
/// shipping separate map files. The override is stored by the map service
|
||||
/// and persisted in the save.
|
||||
/// </summary>
|
||||
public class MapShowZoneAction : IScenarioAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Registered as "map.show_zone" — reveals the zone named by the
|
||||
/// required "zone_id" dialog variable. Unknown IDs are ignored with a
|
||||
/// warning.
|
||||
/// </summary>
|
||||
public string Name => "map.show_zone";
|
||||
|
||||
/// <summary>
|
||||
/// Reads "zone_id" and delegates to the map service. Missing
|
||||
/// "zone_id" is logged and the action does nothing instead of throwing
|
||||
/// inside dialog execution.
|
||||
/// </summary>
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var zoneId = ctx.GetParam("zone_id");
|
||||
|
||||
if (string.IsNullOrEmpty(zoneId))
|
||||
{
|
||||
ctx.Log("EVENT", "MapShowZoneAction: missing 'zone_id' variable");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Map.Show(zoneId);
|
||||
ctx.Log("MAP", $"MapShowZoneAction: show '{zoneId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Thin adapter from the scenario action layer to the process-wide
|
||||
/// <see cref="MapZoneService"/> singleton, following the same shape as
|
||||
/// <see cref="QuestServiceImpl"/>: the interface exists so
|
||||
/// <see cref="IActionContext"/> can be mocked in tests, while the actual
|
||||
/// state always lives in the singleton and therefore survives for the whole
|
||||
/// session and lands in the save through <c>RuntimeSerializer</c>.
|
||||
/// </summary>
|
||||
public class MapZoneServiceImpl : IMapZoneService
|
||||
{
|
||||
/// <summary>True when the zone is currently open for travel.</summary>
|
||||
public bool IsOpen(string id) => MapZoneService.Instance.IsOpen(id);
|
||||
|
||||
/// <summary>True when the zone is currently visible on the map.</summary>
|
||||
public bool IsVisible(string id) => MapZoneService.Instance.IsVisible(id);
|
||||
|
||||
/// <summary>Opens the zone for travel (overrides the map JSON's "locked" flag; persisted in the save).</summary>
|
||||
public void Open(string id) => MapZoneService.Instance.Open(id);
|
||||
|
||||
/// <summary>Closes the zone — the marker stays but travel is blocked (persisted in the save).</summary>
|
||||
public void Close(string id) => MapZoneService.Instance.Close(id);
|
||||
|
||||
/// <summary>Reveals a hidden zone on the map (persisted in the save).</summary>
|
||||
public void Show(string id) => MapZoneService.Instance.Show(id);
|
||||
|
||||
/// <summary>Hides a zone from the map entirely (persisted in the save).</summary>
|
||||
public void Hide(string id) => MapZoneService.Instance.Hide(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// One location marker on the world map: a round emblem with the zone icon,
|
||||
/// the localised name pill and an optional state note ("вы здесь" /
|
||||
/// "закрыто"). The node structure lives in <c>map_marker.tscn</c>; this
|
||||
/// class only binds zone data and repaints state-dependent styles from
|
||||
/// <see cref="UiTokens"/>.
|
||||
/// <para/>
|
||||
/// Visual state priority is current (green — the player's active scene) over
|
||||
/// selected (gold) over locked (dimmed, with a drawn padlock) over hover.
|
||||
/// The marker never navigates by itself: it reports presses and hover
|
||||
/// changes to <see cref="MapWidget"/>, which owns the selected zone, the
|
||||
/// location card and the actual scene switch. That keeps a marker reusable
|
||||
/// and the map behaviour in one place.
|
||||
/// </summary>
|
||||
public partial class MapMarker : VBoxContainer
|
||||
{
|
||||
[Signal] public delegate void ZonePressedEventHandler(string zoneId);
|
||||
[Signal] public delegate void ZoneHoverChangedEventHandler(string zoneId, bool hovered);
|
||||
|
||||
private const int EmblemRadius = 23;
|
||||
private const float LockBadgeRadius = 9f;
|
||||
|
||||
private Control _emblemWrap;
|
||||
private Panel _emblem;
|
||||
private TextureRect _glyph;
|
||||
private Label _nameLabel;
|
||||
private Label _noteLabel;
|
||||
|
||||
private string _zoneId;
|
||||
private bool _selected;
|
||||
private bool _current;
|
||||
private bool _locked;
|
||||
private bool _hover;
|
||||
|
||||
/// <summary>Resolves the prefab nodes and subscribes pointer events; the marker never navigates on its own, it only reports to <see cref="MapWidget"/>.</summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
ResolveNodes();
|
||||
GuiInput += OnGuiInput;
|
||||
MouseEntered += OnMouseEntered;
|
||||
MouseExited += OnMouseExited;
|
||||
}
|
||||
|
||||
/// <summary>Binds a zone to the marker; <paramref name="icon"/> is an already resolved marker texture.</summary>
|
||||
public void Setup(MapZone zone, Texture2D icon, bool current, bool locked)
|
||||
{
|
||||
_zoneId = zone.ID;
|
||||
_current = current;
|
||||
_locked = locked;
|
||||
_selected = false;
|
||||
_hover = false;
|
||||
|
||||
if (_glyph != null)
|
||||
_glyph.Texture = icon;
|
||||
|
||||
if (_nameLabel != null)
|
||||
_nameLabel.Text = TranslationServer.Translate(zone.Title);
|
||||
|
||||
RefreshStyle();
|
||||
}
|
||||
|
||||
/// <summary>Re-applies the dynamic zone state after the player moved to another scene.</summary>
|
||||
public void ApplyState(bool current, bool locked)
|
||||
{
|
||||
_current = current;
|
||||
_locked = locked;
|
||||
RefreshStyle();
|
||||
}
|
||||
|
||||
/// <summary>Panel-owned selection: the selected marker gets the golden ring and surface.</summary>
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
if (_selected == selected)
|
||||
return;
|
||||
_selected = selected;
|
||||
RefreshStyle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hover can come from this marker's own pointer events or from the
|
||||
/// paired polygon (<see cref="MapZonesLayer.SetHovered"/> mirror); the
|
||||
/// method only repaints and never re-emits, so the two layers cannot
|
||||
/// loop signals through each other.
|
||||
/// </summary>
|
||||
public void SetHovered(bool hovered)
|
||||
{
|
||||
if (_hover == hovered)
|
||||
return;
|
||||
_hover = hovered;
|
||||
RefreshStyle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the padlock badge of a closed zone over the emblem corner.
|
||||
/// A drawn badge is used instead of a texture so marker states need no
|
||||
/// extra icons from mods and stay sharp at any UI scale.
|
||||
/// </summary>
|
||||
public override void _Draw()
|
||||
{
|
||||
if (!_locked || _emblemWrap == null)
|
||||
return;
|
||||
|
||||
var wrap = _emblemWrap.GetRect();
|
||||
var center = new Vector2(wrap.End.X - 3f, wrap.Position.Y + 3f);
|
||||
var outline = UiTokens.BadgeLine(UiTokens.TypeEnemy);
|
||||
|
||||
DrawCircle(center, LockBadgeRadius, UiTokens.Surface0);
|
||||
DrawArc(center, LockBadgeRadius, 0f, Mathf.Tau, 28, outline, 1f, true);
|
||||
DrawRect(new Rect2(center.X - 4.5f, center.Y - 0.5f, 9f, 6.5f), UiTokens.TypeEnemy, false, 1.2f);
|
||||
DrawArc(center + new Vector2(0f, -0.5f), 3.2f, Mathf.Pi, Mathf.Tau, 14, UiTokens.TypeEnemy, 1.4f, true);
|
||||
}
|
||||
|
||||
private void ResolveNodes()
|
||||
{
|
||||
_emblemWrap = GetNodeOrNull<Control>("EmblemWrap");
|
||||
_emblem = GetNodeOrNull<Panel>("EmblemWrap/Emblem");
|
||||
_glyph = GetNodeOrNull<TextureRect>("EmblemWrap/Emblem/Glyph");
|
||||
_nameLabel = GetNodeOrNull<Label>("NameLabel");
|
||||
_noteLabel = GetNodeOrNull<Label>("NoteLabel");
|
||||
}
|
||||
|
||||
private void RefreshStyle()
|
||||
{
|
||||
var background = UiTokens.Surface0;
|
||||
var border = UiTokens.AccentLine;
|
||||
var glyphColor = UiTokens.AccentBright;
|
||||
var nameColor = UiTokens.Ink;
|
||||
var nameBorder = UiTokens.Line;
|
||||
var shadowColor = new Color(0f, 0f, 0f, 0f);
|
||||
var shadowSize = 0;
|
||||
|
||||
if (_locked)
|
||||
{
|
||||
border = UiTokens.LineDisabled;
|
||||
glyphColor = UiTokens.Faint;
|
||||
nameColor = UiTokens.Faint;
|
||||
nameBorder = UiTokens.LineDisabled;
|
||||
}
|
||||
|
||||
if (_selected)
|
||||
{
|
||||
background = UiTokens.SurfaceSelected;
|
||||
border = UiTokens.Accent;
|
||||
glyphColor = UiTokens.AccentHover;
|
||||
nameColor = UiTokens.AccentBright;
|
||||
nameBorder = UiTokens.AccentLine;
|
||||
shadowColor = UiTokens.Alpha(UiTokens.Accent, 0.35f);
|
||||
shadowSize = 6;
|
||||
}
|
||||
|
||||
if (_current)
|
||||
{
|
||||
background = UiTokens.Surface0;
|
||||
border = UiTokens.TypePassive;
|
||||
glyphColor = UiTokens.SuccessText;
|
||||
nameColor = UiTokens.SuccessText;
|
||||
nameBorder = UiTokens.BadgeLine(UiTokens.TypePassive);
|
||||
shadowColor = UiTokens.SuccessSoft;
|
||||
shadowSize = 3;
|
||||
}
|
||||
else if (_hover && !_selected && !_locked)
|
||||
{
|
||||
background = UiTokens.Surface1;
|
||||
border = UiTokens.AccentLineStrong;
|
||||
}
|
||||
|
||||
if (_emblem != null)
|
||||
{
|
||||
var style = UiKit.Frame(EmblemRadius, background, border);
|
||||
if (shadowSize > 0)
|
||||
{
|
||||
style.ShadowColor = shadowColor;
|
||||
style.ShadowSize = shadowSize;
|
||||
}
|
||||
_emblem.AddThemeStyleboxOverride("panel", style);
|
||||
}
|
||||
|
||||
if (_glyph != null)
|
||||
_glyph.Modulate = glyphColor;
|
||||
|
||||
if (_nameLabel != null)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.SlotTagBg, nameBorder);
|
||||
style.ContentMarginLeft = 8;
|
||||
style.ContentMarginRight = 8;
|
||||
style.ContentMarginTop = 2;
|
||||
style.ContentMarginBottom = 2;
|
||||
_nameLabel.AddThemeStyleboxOverride("normal", style);
|
||||
_nameLabel.AddThemeColorOverride("font_color", nameColor);
|
||||
_nameLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
}
|
||||
|
||||
ApplyNote();
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
private void ApplyNote()
|
||||
{
|
||||
if (_noteLabel == null)
|
||||
return;
|
||||
|
||||
_noteLabel.Visible = _current || _locked;
|
||||
if (!_noteLabel.Visible)
|
||||
return;
|
||||
|
||||
var color = _current ? UiTokens.SuccessText : UiTokens.DangerText;
|
||||
var fill = _current ? UiTokens.SuccessSoft : UiTokens.DangerSoft;
|
||||
var line = UiTokens.BadgeLine(_current ? UiTokens.TypePassive : UiTokens.TypeEnemy);
|
||||
|
||||
var style = UiKit.Frame(UiTokens.RadiusXs, fill, line);
|
||||
style.ContentMarginLeft = 6;
|
||||
style.ContentMarginRight = 6;
|
||||
style.ContentMarginTop = 1;
|
||||
style.ContentMarginBottom = 1;
|
||||
_noteLabel.AddThemeStyleboxOverride("normal", style);
|
||||
_noteLabel.AddThemeColorOverride("font_color", color);
|
||||
_noteLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontMicro);
|
||||
|
||||
_noteLabel.Text = TranslationServer.Translate(_current ? "ui/map/you_are_here" : "ui/map/closed_note").ToString();
|
||||
_noteLabel.Uppercase = true;
|
||||
}
|
||||
|
||||
private void OnGuiInput(InputEvent @event)
|
||||
{
|
||||
if (@event is not InputEventMouseButton button || !button.Pressed || button.ButtonIndex != MouseButton.Left)
|
||||
return;
|
||||
|
||||
EmitSignal(SignalName.ZonePressed, _zoneId);
|
||||
AcceptEvent();
|
||||
}
|
||||
|
||||
private void OnMouseEntered()
|
||||
{
|
||||
if (_hover)
|
||||
return;
|
||||
_hover = true;
|
||||
RefreshStyle();
|
||||
EmitSignal(SignalName.ZoneHoverChanged, _zoneId, true);
|
||||
}
|
||||
|
||||
private void OnMouseExited()
|
||||
{
|
||||
if (!_hover)
|
||||
return;
|
||||
_hover = false;
|
||||
RefreshStyle();
|
||||
EmitSignal(SignalName.ZoneHoverChanged, _zoneId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Cthangover.Core.Actions;
|
||||
using Cthangover.Core.Mods;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Wires the map subsystem into the kernel. On
|
||||
/// <c>OnModResourcesReady</c> it installs the zone-state hooks into
|
||||
/// <see cref="MapStateBridge"/> — export into the save's
|
||||
/// <c>__core__</c> slot, restore from a loaded save, and the
|
||||
/// new-game override reset — so the core save pipeline keeps working
|
||||
/// against the mod-owned <see cref="MapZoneService"/> singleton.
|
||||
/// </summary>
|
||||
public class MapModInitializer : IModInitializer
|
||||
{
|
||||
/// <summary>No per-mod work: the bridge binding happens once the whole mod set is ready.</summary>
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Installs the zone-state hooks (first registration wins).</summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
var service = MapZoneService.Instance;
|
||||
MapStateBridge.Register(service.ExportState, service.ImportState, service.ResetOverrides);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Full-screen world map overlay toggled by the Map button in the TopView
|
||||
/// toolbar. The window structure lives in <c>map_widget.tscn</c>; this
|
||||
/// behaviour resolves the nodes, paints them with <see cref="UiTokens"/>,
|
||||
/// renders the zones held by <see cref="MapZoneService"/> and drives the
|
||||
/// interaction.
|
||||
/// <para/>
|
||||
/// The widget no longer reads map JSON itself: the zone list and the live
|
||||
/// open/hidden state belong to <see cref="MapZoneService"/>, so scenarios
|
||||
/// (<c>map.open_zone</c> and friends), save loading and the map editor all
|
||||
/// affect the same state, and the map simply redraws from it. All
|
||||
/// coordinates are normalised [0..1] against the displayed background
|
||||
/// rectangle, which keeps the layout resolution-independent.
|
||||
/// <para/>
|
||||
/// Two layers compose a zone: the polygon drawn by <see cref="MapZonesLayer"/>
|
||||
/// is the generous click target, and the icon produced by the
|
||||
/// <c>map_marker.tscn</c> prefab is the readable pinned location. A zone
|
||||
/// with a <c>label_pos</c> but no icon is a pure inscription — it is drawn
|
||||
/// as text, has no click target and is excluded from the zone counter.
|
||||
/// Zones whose runtime state is hidden are absent from both layers
|
||||
/// entirely; that is how scenarios keep late-game locations off the map.
|
||||
/// A zone whose scene is empty or unknown stays closed — it remains
|
||||
/// selectable so the card can explain the block, but its marker is dimmed
|
||||
/// and the transition is disabled. The zone matching the active scene is
|
||||
/// marked "вы здесь" and cannot be entered again.
|
||||
/// <para/>
|
||||
/// The map closes on Escape, on the header close button, and automatically
|
||||
/// after a successful transition. Zone state is recomputed on every
|
||||
/// <see cref="ShowConstruct"/> and the widget also subscribes to
|
||||
/// <see cref="MapZoneChangedEvent"/>, so a zone opened by a scenario while
|
||||
/// the map is open updates live. <see cref="Reload"/> re-reads the raw zone
|
||||
/// data without losing runtime overrides and is used by the map editor.
|
||||
/// <see cref="SetPreviewZones"/> turns the widget into a game-like preview
|
||||
/// of unsaved editor data: zones and their authoring flags come from the
|
||||
/// caller instead of the service, while hover, selection, the location card
|
||||
/// and markers behave exactly as in the live map.
|
||||
/// </summary>
|
||||
public partial class MapWidget : Widget
|
||||
{
|
||||
private const float MapMarginPercent = 0.08f;
|
||||
private const string MarkerScenePath = "scenes/ui/map_marker.tscn";
|
||||
|
||||
private static readonly Color Transparent = new(0f, 0f, 0f, 0f);
|
||||
|
||||
private readonly Dictionary<string, MapMarker> _markers = new();
|
||||
private readonly List<Control> _labels = new();
|
||||
|
||||
private ModScenePrefab _markerPrefab;
|
||||
private Texture2D _mapTexture;
|
||||
private MapZone _selectedZone;
|
||||
private IReadOnlyList<MapZone> _previewZones;
|
||||
|
||||
private ColorRect _backdrop;
|
||||
private Panel _sheet;
|
||||
private TextureRect _mapImage;
|
||||
private Panel _frame;
|
||||
private MapZonesLayer _zonesLayer;
|
||||
private Control _markersLayer;
|
||||
private VBoxContainer _hud;
|
||||
private Label _hudTitle;
|
||||
private Label _hudSub;
|
||||
private Button _closeButton;
|
||||
private PanelContainer _legend;
|
||||
private Panel _legendCurrentDot;
|
||||
private Panel _legendAvailableDot;
|
||||
private Panel _legendLockedDot;
|
||||
private Label _legendCurrentText;
|
||||
private Label _legendAvailableText;
|
||||
private Label _legendLockedText;
|
||||
private PanelContainer _hint;
|
||||
private Label _hintLabel;
|
||||
private PanelContainer _card;
|
||||
private Panel _cardEmblem;
|
||||
private TextureRect _cardGlyph;
|
||||
private Label _cardName;
|
||||
private Label _cardSub;
|
||||
private HBoxContainer _cardBadges;
|
||||
private Label _cardDesc;
|
||||
private Panel _cardRule;
|
||||
private Label _cardCaption;
|
||||
private Label _cardHint;
|
||||
private Label _cardMessage;
|
||||
private Button _goButton;
|
||||
private CenterContainer _emptyState;
|
||||
private Label _emptyTitle;
|
||||
private Label _emptyHint;
|
||||
|
||||
/// <summary>Resolves nodes, applies styles and texts, and subscribes to zone changes, close/go buttons and layer signals.</summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
base._Ready();
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
_markerPrefab = new ModScenePrefab(MarkerScenePath);
|
||||
|
||||
ResolveNodes();
|
||||
ApplyStaticStyles();
|
||||
ApplyStaticTexts();
|
||||
|
||||
if (_emptyState != null)
|
||||
MoveChild(_emptyState, GetChildCount() - 1);
|
||||
|
||||
ModEventBus.Subscribe<MapZoneChangedEvent>(OnZoneStateChanged);
|
||||
|
||||
if (_closeButton != null)
|
||||
_closeButton.Pressed += OnClosePressed;
|
||||
if (_goButton != null)
|
||||
_goButton.Pressed += OnGoPressed;
|
||||
if (_zonesLayer != null)
|
||||
{
|
||||
_zonesLayer.ZoneClicked += OnZoneClicked;
|
||||
_zonesLayer.ZoneHovered += OnZoneHovered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from <see cref="MapZoneChangedEvent"/> so a closed map does not rebuild pointlessly.</summary>
|
||||
public override void _ExitTree()
|
||||
{
|
||||
ModEventBus.Unsubscribe<MapZoneChangedEvent>(OnZoneStateChanged);
|
||||
base._ExitTree();
|
||||
}
|
||||
|
||||
protected override void OnceConstruct()
|
||||
{
|
||||
if (_previewZones == null)
|
||||
MapZoneService.Instance.EnsureLoaded();
|
||||
|
||||
LoadMapBackground();
|
||||
if (_mapTexture == null)
|
||||
{
|
||||
UpdateHud();
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
protected override void ShowConstruct()
|
||||
{
|
||||
ClearSelection();
|
||||
LayoutMap();
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
protected override void HideDestruct()
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
|
||||
/// <summary>Closes the map on Escape while visible, consuming the input so it does not reach the scene underneath.</summary>
|
||||
public override void _Input(InputEvent @event)
|
||||
{
|
||||
if (!Visible)
|
||||
return;
|
||||
|
||||
if (@event is InputEventKey key && key.Pressed && key.Keycode == Key.Escape)
|
||||
{
|
||||
Hide();
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the raw map data through <see cref="MapZoneService"/> while
|
||||
/// keeping runtime overrides, then rebuilds every layer. Used by the map
|
||||
/// editor after saving so the running game reflects the edit without a
|
||||
/// restart; safe to call while the map is hidden.
|
||||
/// </summary>
|
||||
public void Reload()
|
||||
{
|
||||
MapZoneService.Instance.Reload();
|
||||
if (_mapTexture == null)
|
||||
LoadMapBackground();
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the widget into authoring preview mode used by the map
|
||||
/// editor: the supplied zone list replaces <see cref="MapZoneService"/>
|
||||
/// data, and the initial <see cref="MapZone.Locked"/> /
|
||||
/// <see cref="MapZone.Hidden"/> flags are applied directly, without
|
||||
/// runtime overrides. Nothing is written back to the service or the
|
||||
/// save, so the preview can show unsaved editor state next to the live
|
||||
/// game without disturbing either.
|
||||
/// </summary>
|
||||
public void SetPreviewZones(IReadOnlyList<MapZone> zones)
|
||||
{
|
||||
_previewZones = zones;
|
||||
if (!Visible || _mapTexture == null || _zonesLayer == null)
|
||||
return;
|
||||
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
private void OnZoneStateChanged(MapZoneChangedEvent @event)
|
||||
{
|
||||
if (!Visible || _mapTexture == null)
|
||||
return;
|
||||
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
private void RebuildAll()
|
||||
{
|
||||
if (_zonesLayer == null || _markersLayer == null)
|
||||
return;
|
||||
|
||||
ClearOverlay();
|
||||
|
||||
var zones = ZoneSource;
|
||||
foreach (var zone in zones)
|
||||
{
|
||||
if (!IsVisible(zone))
|
||||
continue;
|
||||
|
||||
_zonesLayer.AddZone(zone.ID, zone.Points, IsLocked(zone));
|
||||
}
|
||||
|
||||
foreach (var zone in zones)
|
||||
{
|
||||
if (!IsVisible(zone))
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(zone.Image))
|
||||
BuildMarker(zone);
|
||||
else if (zone.LabelPos.HasValue && !string.IsNullOrEmpty(zone.Title))
|
||||
BuildLabel(zone);
|
||||
}
|
||||
|
||||
RestoreSelection();
|
||||
UpdateHud();
|
||||
}
|
||||
|
||||
private void ClearOverlay()
|
||||
{
|
||||
foreach (var marker in _markers.Values)
|
||||
{
|
||||
marker.ZonePressed -= OnMarkerPressed;
|
||||
marker.ZoneHoverChanged -= OnMarkerHoverChanged;
|
||||
marker.QueueFree();
|
||||
}
|
||||
_markers.Clear();
|
||||
|
||||
foreach (var label in _labels)
|
||||
label.QueueFree();
|
||||
_labels.Clear();
|
||||
|
||||
_zonesLayer.ClearZones();
|
||||
}
|
||||
|
||||
private void BuildMarker(MapZone zone)
|
||||
{
|
||||
var icon = Textures.Resolve(zone.Image);
|
||||
if (icon == null)
|
||||
{
|
||||
GameLogger.Log("MAP", $"marker icon '{zone.Image}' not found for zone '{zone.ID}'", LogLevel.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var marker = _markerPrefab.Instantiate<MapMarker>();
|
||||
if (marker == null)
|
||||
{
|
||||
GameLogger.Log("MAP", $"failed to instantiate marker for zone '{zone.ID}'", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_markersLayer.AddChild(marker);
|
||||
marker.Setup(zone, icon, IsCurrent(zone), IsLocked(zone));
|
||||
marker.ZonePressed += OnMarkerPressed;
|
||||
marker.ZoneHoverChanged += OnMarkerHoverChanged;
|
||||
marker.Size = marker.GetCombinedMinimumSize();
|
||||
marker.Position = zone.ImagePos * _sheet.Size - marker.Size / 2f;
|
||||
|
||||
_markers[zone.ID] = marker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the free-standing text inscription of a zone that has a
|
||||
/// <c>label_pos</c> but no marker icon. Labels use the same pill styling
|
||||
/// as marker names but are deliberately non-interactive: they name
|
||||
/// regions, they are not locations.
|
||||
/// </summary>
|
||||
private void BuildLabel(MapZone zone)
|
||||
{
|
||||
var label = new Label
|
||||
{
|
||||
Text = Translate(zone.Title),
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
|
||||
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.SlotTagBg, UiTokens.Line);
|
||||
style.ContentMarginLeft = 10;
|
||||
style.ContentMarginRight = 10;
|
||||
style.ContentMarginTop = 3;
|
||||
style.ContentMarginBottom = 3;
|
||||
label.AddThemeStyleboxOverride("normal", style);
|
||||
label.AddThemeColorOverride("font_color", UiTokens.Ink);
|
||||
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
|
||||
_markersLayer.AddChild(label);
|
||||
label.Size = label.GetCombinedMinimumSize();
|
||||
label.Position = zone.LabelPos.Value * _sheet.Size - label.Size / 2f;
|
||||
_labels.Add(label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the selected-zone highlight after the layers were rebuilt
|
||||
/// from scratch, and clears the card when the selected zone became
|
||||
/// hidden (or disappeared after a reload).
|
||||
/// </summary>
|
||||
private void RestoreSelection()
|
||||
{
|
||||
if (_selectedZone == null)
|
||||
return;
|
||||
|
||||
if (!IsVisible(_selectedZone))
|
||||
{
|
||||
ClearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
_zonesLayer.SetSelected(_selectedZone.ID);
|
||||
foreach (var pair in _markers)
|
||||
pair.Value.SetSelected(pair.Key == _selectedZone.ID);
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
_selectedZone = null;
|
||||
_zonesLayer?.SetSelected(null);
|
||||
|
||||
foreach (var pair in _markers)
|
||||
pair.Value.SetSelected(false);
|
||||
|
||||
if (_card != null)
|
||||
_card.Visible = false;
|
||||
}
|
||||
|
||||
private void ResolveNodes()
|
||||
{
|
||||
_backdrop = GetNodeOrNull<ColorRect>("Bg");
|
||||
_sheet = GetNodeOrNull<Panel>("MapRoot/Sheet");
|
||||
_mapImage = GetNodeOrNull<TextureRect>("MapRoot/Sheet/MapImage");
|
||||
_frame = GetNodeOrNull<Panel>("MapRoot/Sheet/Frame");
|
||||
_zonesLayer = GetNodeOrNull<MapZonesLayer>("MapRoot/Sheet/ZonesLayer");
|
||||
_markersLayer = GetNodeOrNull<Control>("MapRoot/Sheet/MarkersLayer");
|
||||
_hud = GetNodeOrNull<VBoxContainer>("Hud");
|
||||
_hudTitle = GetNodeOrNull<Label>("Hud/Title");
|
||||
_hudSub = GetNodeOrNull<Label>("Hud/Sub");
|
||||
_closeButton = GetNodeOrNull<Button>("MapRoot/Sheet/CloseButton");
|
||||
_legend = GetNodeOrNull<PanelContainer>("Legend");
|
||||
_legendCurrentDot = GetNodeOrNull<Panel>("Legend/Items/Current/Dot");
|
||||
_legendCurrentText = GetNodeOrNull<Label>("Legend/Items/Current/Text");
|
||||
_legendAvailableDot = GetNodeOrNull<Panel>("Legend/Items/Available/Dot");
|
||||
_legendAvailableText = GetNodeOrNull<Label>("Legend/Items/Available/Text");
|
||||
_legendLockedDot = GetNodeOrNull<Panel>("Legend/Items/Locked/Dot");
|
||||
_legendLockedText = GetNodeOrNull<Label>("Legend/Items/Locked/Text");
|
||||
_hint = GetNodeOrNull<PanelContainer>("Hint");
|
||||
_hintLabel = GetNodeOrNull<Label>("Hint/Label");
|
||||
_card = GetNodeOrNull<PanelContainer>("ZoneCard");
|
||||
_cardEmblem = GetNodeOrNull<Panel>("ZoneCard/Column/Head/Emblem");
|
||||
_cardGlyph = GetNodeOrNull<TextureRect>("ZoneCard/Column/Head/Emblem/Glyph");
|
||||
_cardName = GetNodeOrNull<Label>("ZoneCard/Column/Head/Info/Name");
|
||||
_cardSub = GetNodeOrNull<Label>("ZoneCard/Column/Head/Info/Sub");
|
||||
_cardBadges = GetNodeOrNull<HBoxContainer>("ZoneCard/Column/Badges");
|
||||
_cardDesc = GetNodeOrNull<Label>("ZoneCard/Column/Desc");
|
||||
_cardRule = GetNodeOrNull<Panel>("ZoneCard/Column/Rule");
|
||||
_cardCaption = GetNodeOrNull<Label>("ZoneCard/Column/Caption");
|
||||
_cardHint = GetNodeOrNull<Label>("ZoneCard/Column/CardHint");
|
||||
_cardMessage = GetNodeOrNull<Label>("ZoneCard/Column/Message");
|
||||
_goButton = GetNodeOrNull<Button>("ZoneCard/Column/GoButton");
|
||||
_emptyState = GetNodeOrNull<CenterContainer>("EmptyState");
|
||||
_emptyTitle = GetNodeOrNull<Label>("EmptyState/EmptyBox/Title");
|
||||
_emptyHint = GetNodeOrNull<Label>("EmptyState/EmptyBox/Hint");
|
||||
}
|
||||
|
||||
private void ApplyStaticStyles()
|
||||
{
|
||||
if (_backdrop != null)
|
||||
UiKit.ApplyPanelBackdrop(_backdrop);
|
||||
|
||||
if (_sheet != null)
|
||||
_sheet.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Paper, UiTokens.PaperLine));
|
||||
|
||||
if (_frame != null)
|
||||
_frame.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusXs, Transparent, UiTokens.PaperInset));
|
||||
|
||||
if (_hudTitle != null)
|
||||
{
|
||||
_hudTitle.AddThemeFontSizeOverride("font_size", UiTokens.FontTitle);
|
||||
_hudTitle.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
}
|
||||
|
||||
if (_hudSub != null)
|
||||
{
|
||||
_hudSub.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
|
||||
_hudSub.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
}
|
||||
|
||||
if (_closeButton != null)
|
||||
{
|
||||
_closeButton.Text = "✕";
|
||||
CthangoverTheme.ApplyIcon(_closeButton);
|
||||
}
|
||||
|
||||
if (_goButton != null)
|
||||
CthangoverTheme.ApplyPrimary(_goButton);
|
||||
|
||||
StyleLegend();
|
||||
StyleHint();
|
||||
StyleCard();
|
||||
StyleEmptyState();
|
||||
}
|
||||
|
||||
private void StyleLegend()
|
||||
{
|
||||
if (_legend != null)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
|
||||
style.ContentMarginLeft = 14;
|
||||
style.ContentMarginRight = 14;
|
||||
style.ContentMarginTop = 10;
|
||||
style.ContentMarginBottom = 10;
|
||||
_legend.AddThemeStyleboxOverride("panel", style);
|
||||
}
|
||||
|
||||
StyleLegendItem(_legendCurrentDot, _legendCurrentText, UiTokens.TypePassive, UiTokens.SuccessSoft);
|
||||
StyleLegendItem(_legendAvailableDot, _legendAvailableText, UiTokens.Accent, UiTokens.AccentSoft);
|
||||
StyleLegendItem(_legendLockedDot, _legendLockedText, Transparent, Transparent);
|
||||
if (_legendLockedDot != null)
|
||||
_legendLockedDot.AddThemeStyleboxOverride("panel", UiKit.Frame(5, Transparent, UiTokens.LineFaint));
|
||||
}
|
||||
|
||||
private static void StyleLegendItem(Panel dot, Label text, Color dotColor, Color ringColor)
|
||||
{
|
||||
if (dot != null)
|
||||
{
|
||||
dot.CustomMinimumSize = new Vector2(10f, 10f);
|
||||
var style = UiKit.Frame(5, dotColor);
|
||||
style.ShadowColor = ringColor;
|
||||
style.ShadowSize = 2;
|
||||
dot.AddThemeStyleboxOverride("panel", style);
|
||||
}
|
||||
|
||||
if (text != null)
|
||||
{
|
||||
text.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
text.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
}
|
||||
}
|
||||
|
||||
private void StyleHint()
|
||||
{
|
||||
if (_hint != null)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.SlotTagBg, UiTokens.Line);
|
||||
style.ContentMarginLeft = 14;
|
||||
style.ContentMarginRight = 14;
|
||||
style.ContentMarginTop = 8;
|
||||
style.ContentMarginBottom = 8;
|
||||
_hint.AddThemeStyleboxOverride("panel", style);
|
||||
}
|
||||
|
||||
if (_hintLabel != null)
|
||||
{
|
||||
_hintLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
_hintLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
}
|
||||
}
|
||||
|
||||
private void StyleCard()
|
||||
{
|
||||
if (_card != null)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceRaised, UiTokens.Line);
|
||||
style.ShadowColor = new Color(0f, 0f, 0f, 0.5f);
|
||||
style.ShadowSize = 12;
|
||||
style.ShadowOffset = new Vector2(0f, 5f);
|
||||
style.ContentMarginLeft = 14;
|
||||
style.ContentMarginRight = 14;
|
||||
style.ContentMarginTop = 14;
|
||||
style.ContentMarginBottom = 14;
|
||||
_card.AddThemeStyleboxOverride("panel", style);
|
||||
}
|
||||
|
||||
if (_cardName != null)
|
||||
{
|
||||
_cardName.AddThemeFontSizeOverride("font_size", UiTokens.FontH2);
|
||||
_cardName.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
}
|
||||
|
||||
if (_cardSub != null)
|
||||
{
|
||||
_cardSub.AddThemeFontSizeOverride("font_size", UiTokens.FontMicro);
|
||||
_cardSub.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
}
|
||||
|
||||
if (_cardDesc != null)
|
||||
{
|
||||
_cardDesc.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
|
||||
_cardDesc.AddThemeColorOverride("font_color", UiTokens.Body);
|
||||
}
|
||||
|
||||
if (_cardRule != null)
|
||||
_cardRule.AddThemeStyleboxOverride("panel", UiKit.Frame(0, UiTokens.Line));
|
||||
|
||||
if (_cardCaption != null)
|
||||
{
|
||||
_cardCaption.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
|
||||
_cardCaption.AddThemeColorOverride("font_color", UiTokens.AccentDim);
|
||||
}
|
||||
|
||||
if (_cardHint != null)
|
||||
{
|
||||
_cardHint.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
_cardHint.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
}
|
||||
|
||||
if (_cardMessage != null)
|
||||
{
|
||||
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.DangerSoft, UiTokens.Danger);
|
||||
style.ContentMarginLeft = 12;
|
||||
style.ContentMarginRight = 12;
|
||||
style.ContentMarginTop = 8;
|
||||
style.ContentMarginBottom = 8;
|
||||
_cardMessage.AddThemeStyleboxOverride("normal", style);
|
||||
_cardMessage.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
_cardMessage.AddThemeColorOverride("font_color", UiTokens.DangerText);
|
||||
}
|
||||
}
|
||||
|
||||
private void StyleEmptyState()
|
||||
{
|
||||
if (_emptyTitle != null)
|
||||
{
|
||||
_emptyTitle.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
|
||||
_emptyTitle.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
}
|
||||
|
||||
if (_emptyHint != null)
|
||||
{
|
||||
_emptyHint.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
_emptyHint.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStaticTexts()
|
||||
{
|
||||
if (_hudTitle != null)
|
||||
_hudTitle.Text = Translate("ui/map/title").ToUpperInvariant();
|
||||
if (_hudSub != null)
|
||||
_hudSub.Text = Translate("ui/map/subtitle");
|
||||
if (_closeButton != null)
|
||||
_closeButton.TooltipText = Translate("ui/map/close");
|
||||
if (_legendCurrentText != null)
|
||||
_legendCurrentText.Text = Translate("ui/map/legend_current");
|
||||
if (_legendAvailableText != null)
|
||||
_legendAvailableText.Text = Translate("ui/map/legend_available");
|
||||
if (_legendLockedText != null)
|
||||
_legendLockedText.Text = Translate("ui/map/legend_locked");
|
||||
if (_hintLabel != null)
|
||||
_hintLabel.Text = Translate("ui/map/hint");
|
||||
if (_cardCaption != null)
|
||||
_cardCaption.Text = Translate("ui/map/transition").ToUpperInvariant();
|
||||
if (_cardHint != null)
|
||||
_cardHint.Text = Translate("ui/map/card_hint");
|
||||
if (_cardMessage != null)
|
||||
_cardMessage.Text = Translate("ui/map/locked_message");
|
||||
if (_emptyTitle != null)
|
||||
_emptyTitle.Text = Translate("ui/map/empty");
|
||||
if (_emptyHint != null)
|
||||
_emptyHint.Text = Translate("ui/map/empty_hint");
|
||||
}
|
||||
|
||||
private void LoadMapBackground()
|
||||
{
|
||||
_mapTexture = BackgroundFactory.Instance.Get("map");
|
||||
if (_mapTexture == null)
|
||||
{
|
||||
GameLogger.Log("MAP", "map texture not found, showing empty state");
|
||||
ShowEmptyState(true);
|
||||
LayoutMap();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mapImage != null)
|
||||
_mapImage.Texture = _mapTexture;
|
||||
|
||||
ShowEmptyState(false);
|
||||
LayoutMap();
|
||||
}
|
||||
|
||||
private void ShowEmptyState(bool empty)
|
||||
{
|
||||
if (_mapImage != null)
|
||||
_mapImage.Visible = !empty;
|
||||
if (_zonesLayer != null)
|
||||
_zonesLayer.Visible = !empty;
|
||||
if (_markersLayer != null)
|
||||
_markersLayer.Visible = !empty;
|
||||
if (_emptyState != null)
|
||||
_emptyState.Visible = empty;
|
||||
if (_legend != null)
|
||||
_legend.Visible = !empty;
|
||||
if (_hint != null)
|
||||
_hint.Visible = !empty;
|
||||
if (_card != null)
|
||||
_card.Visible = false;
|
||||
}
|
||||
|
||||
private void LayoutMap()
|
||||
{
|
||||
if (_sheet == null)
|
||||
return;
|
||||
|
||||
var viewportSize = GetViewportRect().Size;
|
||||
var available = viewportSize * (1f - 2f * MapMarginPercent);
|
||||
|
||||
Vector2 displaySize;
|
||||
if (_mapTexture == null)
|
||||
{
|
||||
displaySize = available;
|
||||
}
|
||||
else
|
||||
{
|
||||
var textureSize = _mapTexture.GetSize();
|
||||
if (textureSize.X <= 0f || textureSize.Y <= 0f)
|
||||
{
|
||||
GameLogger.Log("MAP", $"invalid map texture size: {textureSize}", LogLevel.Error);
|
||||
_sheet.Position = viewportSize * MapMarginPercent;
|
||||
_sheet.Size = available;
|
||||
return;
|
||||
}
|
||||
|
||||
var mapRatio = textureSize.X / textureSize.Y;
|
||||
var availableRatio = available.X / available.Y;
|
||||
if (mapRatio > availableRatio)
|
||||
{
|
||||
displaySize.X = available.X;
|
||||
displaySize.Y = available.X / mapRatio;
|
||||
}
|
||||
else
|
||||
{
|
||||
displaySize.Y = available.Y;
|
||||
displaySize.X = available.Y * mapRatio;
|
||||
}
|
||||
}
|
||||
|
||||
_sheet.Position = (viewportSize - displaySize) / 2f;
|
||||
_sheet.Size = displaySize;
|
||||
}
|
||||
|
||||
private void UpdateHud()
|
||||
{
|
||||
if (_hudSub == null)
|
||||
return;
|
||||
|
||||
var count = 0;
|
||||
foreach (var zone in ZoneSource)
|
||||
{
|
||||
if (!IsVisible(zone))
|
||||
continue;
|
||||
if (IsLocation(zone))
|
||||
count++;
|
||||
}
|
||||
|
||||
_hudSub.Text = $"{Translate("ui/map/subtitle")} · {string.Format(Translate("ui/map/zone_count"), count)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A "location" is anything the player can see as a place to travel to:
|
||||
/// a polygon region or a marker icon. Pure text inscriptions are not
|
||||
/// locations and are not counted in the HUD.
|
||||
/// </summary>
|
||||
private static bool IsLocation(MapZone zone)
|
||||
{
|
||||
return zone.Points != null && zone.Points.Count >= 3
|
||||
|| !string.IsNullOrEmpty(zone.Image);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zone list the widget currently renders: the preview list in
|
||||
/// authoring preview mode, otherwise the live
|
||||
/// <see cref="MapZoneService"/> data.
|
||||
/// </summary>
|
||||
private IReadOnlyList<MapZone> ZoneSource => _previewZones ?? MapZoneService.Instance.Zones;
|
||||
|
||||
/// <summary>
|
||||
/// Effective visibility of a single zone: the authoring flag in preview
|
||||
/// mode (no runtime overrides), otherwise the service state.
|
||||
/// </summary>
|
||||
private bool IsVisible(MapZone zone)
|
||||
{
|
||||
return _previewZones == null
|
||||
? MapZoneService.Instance.IsVisible(zone.ID)
|
||||
: !zone.Hidden;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective open state of a single zone: the authoring flag in preview
|
||||
/// mode, otherwise the service state. Whether the zone is actually
|
||||
/// enterable still depends on its scene resolving — see
|
||||
/// <see cref="IsLocked"/>.
|
||||
/// </summary>
|
||||
private bool IsOpen(MapZone zone)
|
||||
{
|
||||
return _previewZones == null
|
||||
? MapZoneService.Instance.IsOpen(zone.ID)
|
||||
: !zone.Locked;
|
||||
}
|
||||
|
||||
/// <summary>Turns a zone ID coming from a marker or polygon signal back into the rendered zone.</summary>
|
||||
private MapZone FindZone(string id)
|
||||
{
|
||||
if (_previewZones == null)
|
||||
return MapZoneService.Instance.Get(id);
|
||||
|
||||
foreach (var zone in _previewZones)
|
||||
{
|
||||
if (string.Equals(zone.ID, id, StringComparison.OrdinalIgnoreCase))
|
||||
return zone;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsCurrent(MapZone zone)
|
||||
{
|
||||
var current = SceneManager.GetCurrentSceneName();
|
||||
if (string.IsNullOrEmpty(current))
|
||||
return false;
|
||||
|
||||
return string.Equals(zone.Scene, current, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(zone.ID, current, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool IsLocked(MapZone zone)
|
||||
{
|
||||
return MapZoneRules.IsLocked(zone, IsOpen(zone));
|
||||
}
|
||||
|
||||
private void OnZoneClicked(string zoneId)
|
||||
{
|
||||
SelectZone(FindZone(zoneId));
|
||||
}
|
||||
|
||||
private void OnMarkerPressed(string zoneId)
|
||||
{
|
||||
SelectZone(FindZone(zoneId));
|
||||
}
|
||||
|
||||
private void OnZoneHovered(string zoneId, bool hovered)
|
||||
{
|
||||
if (_markers.TryGetValue(zoneId, out var marker))
|
||||
marker.SetHovered(hovered);
|
||||
}
|
||||
|
||||
private void OnMarkerHoverChanged(string zoneId, bool hovered)
|
||||
{
|
||||
_zonesLayer?.SetHovered(zoneId, hovered);
|
||||
}
|
||||
|
||||
private void SelectZone(MapZone zone)
|
||||
{
|
||||
if (zone == null)
|
||||
return;
|
||||
|
||||
_selectedZone = zone;
|
||||
_zonesLayer?.SetSelected(zone.ID);
|
||||
foreach (var pair in _markers)
|
||||
pair.Value.SetSelected(pair.Key == zone.ID);
|
||||
|
||||
RefreshCard(zone);
|
||||
}
|
||||
|
||||
private void RefreshCard(MapZone zone)
|
||||
{
|
||||
if (_card == null)
|
||||
return;
|
||||
|
||||
var current = IsCurrent(zone);
|
||||
var locked = IsLocked(zone);
|
||||
|
||||
if (_cardName != null)
|
||||
_cardName.Text = Translate(zone.Title);
|
||||
if (_cardSub != null)
|
||||
_cardSub.Text = string.IsNullOrEmpty(zone.Scene) ? zone.ID : $"scene: {zone.Scene}";
|
||||
if (_cardDesc != null)
|
||||
{
|
||||
_cardDesc.Visible = !string.IsNullOrEmpty(zone.Description);
|
||||
_cardDesc.Text = _cardDesc.Visible ? Translate(zone.Description) : string.Empty;
|
||||
}
|
||||
if (_cardMessage != null)
|
||||
_cardMessage.Visible = locked && !current;
|
||||
|
||||
RebuildCardBadges(current, locked);
|
||||
ApplyCardEmblem(zone, current, locked);
|
||||
|
||||
if (_goButton != null)
|
||||
{
|
||||
_goButton.Disabled = current || locked;
|
||||
_goButton.Text = current ? Translate("ui/map/here") : Translate("ui/map/go");
|
||||
}
|
||||
|
||||
_card.Visible = true;
|
||||
}
|
||||
|
||||
private void RebuildCardBadges(bool current, bool locked)
|
||||
{
|
||||
if (_cardBadges == null)
|
||||
return;
|
||||
|
||||
ClearChildren(_cardBadges);
|
||||
|
||||
if (current)
|
||||
_cardBadges.AddChild(UiKit.Badge(Translate("ui/map/current"), UiTokens.TypePassive));
|
||||
else if (locked)
|
||||
_cardBadges.AddChild(UiKit.Badge(Translate("ui/map/locked"), UiTokens.TypeEnemy));
|
||||
else
|
||||
_cardBadges.AddChild(UiKit.Badge(Translate("ui/map/available"), UiTokens.Accent));
|
||||
}
|
||||
|
||||
private void ApplyCardEmblem(MapZone zone, bool current, bool locked)
|
||||
{
|
||||
if (_cardEmblem == null)
|
||||
return;
|
||||
|
||||
var accent = current ? UiTokens.TypePassive : locked ? UiTokens.TypeEnemy : UiTokens.Accent;
|
||||
_cardEmblem.AddThemeStyleboxOverride("panel", UiKit.Frame(29, UiTokens.Surface2, UiTokens.BadgeLine(accent)));
|
||||
|
||||
if (_cardGlyph == null)
|
||||
return;
|
||||
|
||||
_cardGlyph.Texture = string.IsNullOrEmpty(zone.Image) ? null : Textures.Resolve(zone.Image);
|
||||
_cardGlyph.Modulate = current ? UiTokens.SuccessText : locked ? UiTokens.Faint : UiTokens.AccentBright;
|
||||
}
|
||||
|
||||
private void OnGoPressed()
|
||||
{
|
||||
var zone = _selectedZone;
|
||||
if (zone == null || string.IsNullOrEmpty(zone.Scene) || IsCurrent(zone) || IsLocked(zone))
|
||||
return;
|
||||
|
||||
var sceneManager = SceneContextNode.FindNode<SceneManager>("SceneManager");
|
||||
if (sceneManager == null)
|
||||
{
|
||||
GameLogger.Log("MAP", "SceneManager not found, cannot switch scene", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
GameLogger.Log("MAP", $"navigating to scene '{zone.Scene}' from zone '{zone.ID}'");
|
||||
sceneManager.SwitchScene(zone.Scene);
|
||||
Hide();
|
||||
}
|
||||
|
||||
private void OnClosePressed()
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
|
||||
private static void ClearChildren(Node parent)
|
||||
{
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
foreach (var child in parent.GetChildren())
|
||||
child.QueueFree();
|
||||
}
|
||||
|
||||
private static string Translate(string key)
|
||||
{
|
||||
return string.IsNullOrEmpty(key) ? string.Empty : TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes one interactive zone on the world map. Defines its bounding
|
||||
/// polygon (for a semi-transparent highlight), a clickable marker icon,
|
||||
/// a localised title label, and the target scene that opens when clicked.
|
||||
///
|
||||
/// All coordinate properties (<see cref="Points"/> and <see cref="ImagePos"/>)
|
||||
/// use normalised values in the [0.0 .. 1.0] range, relative to the map
|
||||
/// background image dimensions. This makes zone layouts resolution-independent
|
||||
/// and safe to share across mods.
|
||||
///
|
||||
/// Maps use the standard <c>{"Items": [...]}</c> JSON envelope in
|
||||
/// <c>mods/<name>/map/</c>. Zones are read per mod in load order, so later
|
||||
/// mods can override zones by <see cref="ID"/>.
|
||||
/// <para/>
|
||||
/// This type is raw authoring data only. The live open/hidden state of a
|
||||
/// zone is owned by <see cref="MapZoneService"/>, which seeds itself from
|
||||
/// <see cref="Locked"/>/<see cref="Hidden"/> and lets scenarios and code
|
||||
/// change it at runtime; only those runtime overrides are saved.
|
||||
/// </summary>
|
||||
public class MapZone : IIdentifiable
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique zone identifier used as the dictionary key in factory collections.
|
||||
/// Typically equals the target scene name for clarity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ID")]
|
||||
public string ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Polygon vertices in normalised map coordinates [0..1].
|
||||
/// Drawn as a semi-transparent colour wash over the map background
|
||||
/// to visually outline the zone.
|
||||
/// </summary>
|
||||
[JsonPropertyName("points")]
|
||||
[JsonConverter(typeof(Vector2ListJsonConverter))]
|
||||
public List<Vector2> Points { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Localisation key for the zone name displayed next to the marker.
|
||||
/// Resolved via <c>TranslationServer.Translate</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target scene identifier passed directly to
|
||||
/// <c>SceneManager.SwitchScene</c> when the marker is clicked.
|
||||
/// Causes the current visual-novel scene to transition to this zone.
|
||||
/// An empty or unresolved scene marks the zone as closed: the map shows
|
||||
/// its marker and polygon dimmed and blocks the transition button, which
|
||||
/// lets authors sketch future regions without breaking navigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scene")]
|
||||
public string Scene { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional localisation key for the flavour text shown in the location
|
||||
/// card. Zones without a description show only the state badge and the
|
||||
/// transition button, so map data can stay minimal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Asset ID of the marker icon texture, resolved through the mod texture
|
||||
/// system (e.g. <c>ModManager.ResolveTexture</c>).
|
||||
/// </summary>
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalised [0..1] position of the marker icon on the map image.
|
||||
/// Stored as <c>[x, y]</c> in JSON, deserialized into a <see cref="Vector2"/>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("image_pos")]
|
||||
[JsonConverter(typeof(Vector2JsonConverter))]
|
||||
public Vector2 ImagePos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalised [0..1] position of a free-standing text label drawn
|
||||
/// directly on the map. <c>null</c> (absent in JSON) means the zone has
|
||||
/// no label. A zone with a label but no <see cref="Image"/> is a pure
|
||||
/// inscription — it is not a clickable location, does not open the
|
||||
/// location card and is excluded from the zone counter. Used for
|
||||
/// region names that have no icon of their own.
|
||||
/// </summary>
|
||||
[JsonPropertyName("label_pos")]
|
||||
[JsonConverter(typeof(Vector2NullableJsonConverter))]
|
||||
public Vector2? LabelPos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authoring default for the zone being closed to travel. <c>false</c>
|
||||
/// (the default) means the zone is enterable as soon as its scene
|
||||
/// resolves. Runtime state is owned by <see cref="MapZoneService"/>:
|
||||
/// scenarios open and close zones through the <c>map.open_zone</c> /
|
||||
/// <c>map.close_zone</c> actions, and those overrides are persisted in
|
||||
/// the save, while this field only seeds the state of an untouched zone.
|
||||
/// </summary>
|
||||
[JsonPropertyName("locked")]
|
||||
public bool Locked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authoring default for the zone being completely hidden from the
|
||||
/// map. <c>false</c> (the default) draws the zone. Hidden zones are
|
||||
/// absent from the map until a scenario reveals them with the
|
||||
/// <c>map.show_zone</c> action; like <see cref="Locked"/>, this is only
|
||||
/// the initial value and the runtime override lives in
|
||||
/// <see cref="MapZoneService"/>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hidden")]
|
||||
public bool Hidden { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.UI;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared rule that decides whether a map zone is actually enterable.
|
||||
/// Lives outside <see cref="MapWidget"/> because three views must agree on
|
||||
/// what "closed" means: the live map, the map editor's game-like preview
|
||||
/// and the map editor canvas (plus its availability hint).
|
||||
/// <para/>
|
||||
/// The authoring flag alone is not the whole story: a zone with an empty or
|
||||
/// unregistered target scene is closed in the running game no matter what
|
||||
/// the flag says, because authors routinely sketch regions before the
|
||||
/// target scene exists and travelling must stay blocked. Without this
|
||||
/// shared helper the editor showed such zones as open while the player
|
||||
/// blocked them — the editor must surface the same result the player gets.
|
||||
/// </summary>
|
||||
public static class MapZoneRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Effective locked state of a zone. <paramref name="open"/> is the
|
||||
/// open state owned by the caller: the runtime override in the live map
|
||||
/// or the authoring <see cref="MapZone.Locked"/> flag in the editor. A
|
||||
/// zone is locked when it is missing, has no target scene, is closed by
|
||||
/// its state, or when its scene is not registered in the running game.
|
||||
/// </summary>
|
||||
public static bool IsLocked(MapZone zone, bool open)
|
||||
{
|
||||
if (zone == null || string.IsNullOrEmpty(zone.Scene))
|
||||
return true;
|
||||
|
||||
if (!open)
|
||||
return true;
|
||||
|
||||
var sceneManager = SceneContextNode.FindNode<SceneManager>("SceneManager");
|
||||
return sceneManager != null && !sceneManager.HasScene(zone.Scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.UI.Tool;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Single owner of the world map zone list and its live state. Replaces the
|
||||
/// raw "read JSON per mod" logic that used to live inside <see cref="MapWidget"/>
|
||||
/// so that the map, scenarios and mods all see the same zones and the same
|
||||
/// open/hidden flags.
|
||||
/// <para/>
|
||||
/// Two layers of state exist:
|
||||
/// <list type="bullet">
|
||||
/// <item>authoring data — <see cref="MapZone"/> loaded from
|
||||
/// <c>mods/<name>/map/*.json</c>, with initial <c>locked</c>/<c>hidden</c> flags;</item>
|
||||
/// <item>runtime overrides — sparse dictionaries managed here through
|
||||
/// <see cref="Open"/>, <see cref="Close"/>, <see cref="Show"/>, <see cref="Hide"/>
|
||||
/// and <see cref="ResetZone"/>.</item>
|
||||
/// </list>
|
||||
/// The effective state is the override when present, otherwise the authoring
|
||||
/// default. Overrides are what <see cref="ExportState"/> writes into the save
|
||||
/// and <see cref="ImportState"/> restores, so a mod update that changes the
|
||||
/// defaults is immediately reflected for every zone the player never touched.
|
||||
/// <para/>
|
||||
/// Every effective state change publishes <see cref="MapZoneChangedEvent"/> —
|
||||
/// <see cref="MapWidget"/> rebuilds its layers from it and
|
||||
/// <see cref="Scenes.ConditionStateMonitor"/> invalidates cached scenario
|
||||
/// conditions, because zone state can gate scenario headers.
|
||||
/// <para/>
|
||||
/// The single instance is process-wide and deliberately not owned by
|
||||
/// <see cref="AppServices"/>: zone overrides are identity-sensitive across
|
||||
/// the whole process (map widget, saves, scenario conditions). New-game
|
||||
/// reset goes through the AppServices resettable adapter ("map_zones",
|
||||
/// see <see cref="ResetOverrides"/>); unit tests inject zones via
|
||||
/// <see cref="LoadZones"/> while <c>GameEnvironment.IsRuntime</c> is false.
|
||||
/// </summary>
|
||||
public sealed class MapZoneService
|
||||
{
|
||||
/// <summary>Owns the process-wide instance; lazily created and alive for the whole session.</summary>
|
||||
private static readonly Lazy<MapZoneService> instance = new(() => new MapZoneService());
|
||||
|
||||
/// <summary>Process-wide singleton. Deliberately NOT owned by AppServices:
|
||||
/// zone state is identity-sensitive across the process (map widget,
|
||||
/// saves, scenario conditions); reset goes through the AppServices
|
||||
/// resettable adapter instead.</summary>
|
||||
public static MapZoneService Instance => instance.Value;
|
||||
|
||||
private readonly List<MapZone> zones = new();
|
||||
private readonly Dictionary<string, MapZone> byId = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, bool> openOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, bool> visibleOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool loaded;
|
||||
|
||||
private MapZoneService() { }
|
||||
|
||||
/// <summary>
|
||||
/// All loaded zones in mod load order, hidden ones included. The map
|
||||
/// filters by <see cref="IsVisible"/>; other consumers (e.g. the map
|
||||
/// editor) may want the full list.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MapZone> Zones => zones;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the zone list from the given data, replacing the previous list
|
||||
/// but preserving runtime overrides. This is the testable entry point;
|
||||
/// runtime code uses <see cref="ReloadFromMods"/>.
|
||||
/// </summary>
|
||||
public void LoadZones(IEnumerable<MapZone> newZones)
|
||||
{
|
||||
zones.Clear();
|
||||
byId.Clear();
|
||||
|
||||
if (newZones != null)
|
||||
{
|
||||
foreach (var zone in newZones)
|
||||
{
|
||||
if (zone == null || string.IsNullOrEmpty(zone.ID))
|
||||
continue;
|
||||
|
||||
if (byId.ContainsKey(zone.ID))
|
||||
{
|
||||
GameLogger.Log("MAP", $"duplicate zone id '{zone.ID}' ignored", LogLevel.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
zones.Add(zone);
|
||||
byId[zone.ID] = zone;
|
||||
}
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
GameLogger.Log("MAP", $"MapZoneService loaded {zones.Count} zone(s)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every loaded mod's <c>map/*.json</c> through its
|
||||
/// <see cref="IModFileProvider"/> and rebuilds the zone list. Safe to
|
||||
/// call outside the Godot runtime: the call is a no-op in unit tests,
|
||||
/// where zones are injected with <see cref="LoadZones"/> instead.
|
||||
/// </summary>
|
||||
public void ReloadFromMods()
|
||||
{
|
||||
if (!GameEnvironment.IsRuntime)
|
||||
{
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = new List<MapZone>();
|
||||
var options = JsonOptionsProvider.ReadOptions;
|
||||
|
||||
foreach (var kvp in ModManager.Instance.Mods)
|
||||
{
|
||||
var modId = kvp.Key;
|
||||
var provider = kvp.Value.FileProvider;
|
||||
|
||||
foreach (var entry in provider.ListFiles("map"))
|
||||
{
|
||||
if (entry.EndsWith("/") || !entry.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var jsonText = provider.ReadFileText(entry);
|
||||
if (jsonText == null)
|
||||
{
|
||||
GameLogger.Log("MAP", $"failed to read map json '{modId}/{entry}'", LogLevel.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileData = JsonSerializer.Deserialize<FileData<MapZone>>(jsonText, options);
|
||||
if (fileData?.Items == null)
|
||||
continue;
|
||||
|
||||
foreach (var zone in fileData.Items)
|
||||
{
|
||||
result.Add(zone);
|
||||
GameLogger.Log("MAP", $"loaded zone '{zone.ID}' from '{modId}'");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
GameLogger.Log("MAP", $"JSON parse failed for '{modId}/{entry}': {ex.Message}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadZones(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads zones on first use and re-reads nothing afterwards; call
|
||||
/// <see cref="Reload"/> when the mod set or the map files changed.
|
||||
/// </summary>
|
||||
public void EnsureLoaded()
|
||||
{
|
||||
if (!loaded)
|
||||
ReloadFromMods();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the raw zone data while keeping every runtime override, so a
|
||||
/// running game picks up map edits without losing progression. Publishes
|
||||
/// a zone change so the map rebuilds.
|
||||
/// </summary>
|
||||
public void Reload()
|
||||
{
|
||||
ReloadFromMods();
|
||||
ModEventBus.Publish(new MapZoneChangedEvent { ZoneId = string.Empty, Change = MapZoneChangeKind.Reload });
|
||||
}
|
||||
|
||||
/// <summary>Returns the zone with the given ID, or <c>null</c> when unknown.</summary>
|
||||
public MapZone Get(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return null;
|
||||
return byId.TryGetValue(id, out var zone) ? zone : null;
|
||||
}
|
||||
|
||||
/// <summary>True when a zone with the given ID is loaded from any mod.</summary>
|
||||
public bool Contains(string id) => Get(id) != null;
|
||||
|
||||
/// <summary>
|
||||
/// Effective open state: runtime override when present, otherwise the
|
||||
/// authoring default (<see cref="MapZone.Locked"/> negated). Unknown IDs
|
||||
/// are closed. Note that a zone is only actually enterable when its
|
||||
/// scene also resolves — that check stays in <see cref="MapWidget"/>.
|
||||
/// </summary>
|
||||
public bool IsOpen(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
if (openOverrides.TryGetValue(id, out var value))
|
||||
return value;
|
||||
return Get(id) is { Locked: false };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective visibility: runtime override when present, otherwise the
|
||||
/// authoring default (<see cref="MapZone.Hidden"/> negated). Unknown IDs
|
||||
/// are invisible.
|
||||
/// </summary>
|
||||
public bool IsVisible(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
if (visibleOverrides.TryGetValue(id, out var value))
|
||||
return value;
|
||||
return Get(id) is { Hidden: false };
|
||||
}
|
||||
|
||||
/// <summary>Opens the zone for travel, overriding the authoring default.</summary>
|
||||
public void Open(string id) => SetOpen(id, true);
|
||||
|
||||
/// <summary>Closes the zone for travel, overriding the authoring default.</summary>
|
||||
public void Close(string id) => SetOpen(id, false);
|
||||
|
||||
/// <summary>Reveals a hidden zone on the map.</summary>
|
||||
public void Show(string id) => SetVisible(id, true);
|
||||
|
||||
/// <summary>Removes the zone from the map entirely until shown again.</summary>
|
||||
public void Hide(string id) => SetVisible(id, false);
|
||||
|
||||
/// <summary>
|
||||
/// Drops both overrides, returning the zone to its authoring defaults.
|
||||
/// Unlike the other mutators this one works on unknown IDs too, so stale
|
||||
/// overrides of removed zones can be cleaned up.
|
||||
/// </summary>
|
||||
public void ResetZone(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return;
|
||||
|
||||
var changed = openOverrides.Remove(id);
|
||||
changed |= visibleOverrides.Remove(id);
|
||||
if (changed)
|
||||
PublishChange(id, MapZoneChangeKind.Reset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets every override, returning all zones to their authoring
|
||||
/// defaults. Called when a new game starts so progression from the
|
||||
/// previous playthrough does not leak into it.
|
||||
/// </summary>
|
||||
public void ResetOverrides()
|
||||
{
|
||||
if (openOverrides.Count == 0 && visibleOverrides.Count == 0)
|
||||
return;
|
||||
|
||||
openOverrides.Clear();
|
||||
visibleOverrides.Clear();
|
||||
PublishChange(string.Empty, MapZoneChangeKind.Reset);
|
||||
}
|
||||
|
||||
/// <summary>Copies the sparse overrides for the save system.</summary>
|
||||
public MapZoneStateData ExportState()
|
||||
{
|
||||
return new MapZoneStateData
|
||||
{
|
||||
Open = new Dictionary<string, bool>(openOverrides, StringComparer.OrdinalIgnoreCase),
|
||||
Visible = new Dictionary<string, bool>(visibleOverrides, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the overrides with the values from a save. A save is the
|
||||
/// authoritative override set of its own playthrough, so the previous
|
||||
/// overrides are dropped entirely; zone IDs that no longer exist are
|
||||
/// kept (they cost nothing and survive a temporarily disabled mod).
|
||||
/// </summary>
|
||||
public void ImportState(MapZoneStateData data)
|
||||
{
|
||||
openOverrides.Clear();
|
||||
visibleOverrides.Clear();
|
||||
|
||||
if (data?.Open != null)
|
||||
{
|
||||
foreach (var pair in data.Open)
|
||||
openOverrides[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
if (data?.Visible != null)
|
||||
{
|
||||
foreach (var pair in data.Visible)
|
||||
visibleOverrides[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
PublishChange(string.Empty, MapZoneChangeKind.Restore);
|
||||
}
|
||||
|
||||
private void SetOpen(string id, bool value)
|
||||
{
|
||||
if (!ChangeOverride(openOverrides, id, value, "open/close"))
|
||||
return;
|
||||
PublishChange(id, value ? MapZoneChangeKind.Open : MapZoneChangeKind.Close);
|
||||
}
|
||||
|
||||
private void SetVisible(string id, bool value)
|
||||
{
|
||||
if (!ChangeOverride(visibleOverrides, id, value, "show/hide"))
|
||||
return;
|
||||
PublishChange(id, value ? MapZoneChangeKind.Show : MapZoneChangeKind.Hide);
|
||||
}
|
||||
|
||||
private bool ChangeOverride(Dictionary<string, bool> overrides, string id, bool value, string operation)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
|
||||
if (!Contains(id))
|
||||
{
|
||||
GameLogger.Log("MAP", $"cannot {operation} unknown zone '{id}'", LogLevel.Warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (overrides.TryGetValue(id, out var current) && current == value)
|
||||
return false;
|
||||
|
||||
overrides[id] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void PublishChange(string zoneId, string change)
|
||||
{
|
||||
ModEventBus.Publish(new MapZoneChangedEvent { ZoneId = zoneId, Change = change });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Single source of truth for world map zone visuals, shared by the live
|
||||
/// map (<see cref="MapZonesLayer"/>, which also backs the map editor's
|
||||
/// game-like preview) and the map editor's editing canvas. The editor and
|
||||
/// the game read the same colours, pattern and outline parameters here, so
|
||||
/// the two views cannot drift apart when the style is tuned.
|
||||
/// <para/>
|
||||
/// Zone visuals are built from a two-tone accent pair — gold with orange
|
||||
/// as its partner — instead of flat washes of a single hue, so a zone reads
|
||||
/// as an area while staying in the gold UI family:
|
||||
/// <list type="bullet">
|
||||
/// <item>the fill is a diagonal hatch whose stripes alternate gold and
|
||||
/// orange, stamped through polygon UVs at a low state-dependent alpha so
|
||||
/// the map texture stays readable through the gaps;</item>
|
||||
/// <item>hover and selection use a two-tone dashed keyline: a solid orange
|
||||
/// polyline with gold dashes stamped on top, the classic marching-ants
|
||||
/// alternation repeated in the same accent pair.</item>
|
||||
/// </list>
|
||||
/// State only changes the hatch opacity and the outline weight, so the
|
||||
/// visual language is identical across normal, hover, selected and locked
|
||||
/// zones; the editor additionally uses the muted hidden state for zones the
|
||||
/// game would omit. The hatch is a tiny repeating texture stamped through
|
||||
/// polygon UVs rather than CPU line clipping, because the editor redraws on
|
||||
/// every drag frame and geometry clipping per frame would be wasteful.
|
||||
/// </summary>
|
||||
public static class MapZoneStyle
|
||||
{
|
||||
/// <summary>Side of the repeating hatch tile in pixels.</summary>
|
||||
public const int HatchTile = 12;
|
||||
|
||||
/// <summary>Thickness of one hatch stripe in pixels; the orange stripe follows the gold one immediately.</summary>
|
||||
public const int HatchStripe = 2;
|
||||
|
||||
/// <summary>Gold half of the zone accent pair, used for hatch stripes and keyline dashes.</summary>
|
||||
public static readonly Color Gold = UiTokens.AccentBright;
|
||||
|
||||
/// <summary>
|
||||
/// Orange half of the zone accent pair, the deeper tone of the hatch
|
||||
/// and the keyline underlay. Deliberately close to <see cref="Gold"/>
|
||||
/// (the <c>TypeUsed</c> token) so the hatch reads as one warm texture
|
||||
/// instead of two fighting colours.
|
||||
/// </summary>
|
||||
public static readonly Color Orange = UiTokens.TypeUsed;
|
||||
|
||||
private static readonly Color NormalFill = UiTokens.Alpha(UiTokens.White, 0.4f);
|
||||
private static readonly Color HoverFill = UiTokens.Alpha(UiTokens.White, 0.6f);
|
||||
private static readonly Color SelectedFill = UiTokens.Alpha(UiTokens.White, 0.8f);
|
||||
private static readonly Color LockedFill = UiTokens.Alpha(UiTokens.White, 0.18f);
|
||||
private static readonly Color HiddenFill = UiTokens.Alpha(UiTokens.Faint, 0.8f);
|
||||
|
||||
private static readonly Color OutlineSolid = UiTokens.Alpha(Orange, 0.95f);
|
||||
private static readonly Color OutlineDash = UiTokens.Alpha(Gold, 0.95f);
|
||||
private static readonly Color OutlineSolidSoft = UiTokens.Alpha(Orange, 0.6f);
|
||||
private static readonly Color OutlineDashSoft = UiTokens.Alpha(Gold, 0.7f);
|
||||
|
||||
private const float HoverOutlineWidth = 1.6f;
|
||||
private const float SelectedOutlineWidth = 2.6f;
|
||||
private const float OutlineDashLength = 7f;
|
||||
|
||||
/// <summary>Handle colour of a polygon vertex on the editing canvas.</summary>
|
||||
public static readonly Color VertexHandle = UiTokens.AccentBright;
|
||||
|
||||
/// <summary>Handle colour of a marker position on the editing canvas.</summary>
|
||||
public static readonly Color MarkerHandle = UiTokens.TypePassive;
|
||||
|
||||
/// <summary>Handle colour of a label position on the editing canvas.</summary>
|
||||
public static readonly Color LabelHandle = UiTokens.AccentDim;
|
||||
|
||||
private static Texture2D _hatchTexture;
|
||||
|
||||
/// <summary>
|
||||
/// Enables texture repeat and filtering needed by the hatch fills. Call
|
||||
/// once from the drawing control (the map zones layer and the editing
|
||||
/// canvas both do it when they enter the tree).
|
||||
/// </summary>
|
||||
public static void Prepare(CanvasItem canvas)
|
||||
{
|
||||
canvas.TextureRepeat = CanvasItem.TextureRepeatEnum.Enabled;
|
||||
canvas.TextureFilter = CanvasItem.TextureFilterEnum.Linear;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws one zone polygon in pixel coordinates with the shared style.
|
||||
/// Polygons that cannot be triangulated (a self-intersecting outline
|
||||
/// produced while authoring) skip the hatch fill, so Godot does not
|
||||
/// report a native triangulation error on every frame; the keyline is
|
||||
/// still drawn for the selected and hovered states, which is enough
|
||||
/// for the author to find and repair the shape.
|
||||
/// <para/>
|
||||
/// <paramref name="hidden"/> is only passed by the editing canvas: the
|
||||
/// game and the preview omit hidden zones entirely, but the author
|
||||
/// still has to see and edit them, so they are painted with the muted
|
||||
/// hidden hatch and a soft keyline instead of disappearing.
|
||||
/// </summary>
|
||||
public static void Draw(CanvasItem canvas, Vector2[] polygon, bool selected, bool hovered, bool locked, bool hidden = false)
|
||||
{
|
||||
if (canvas == null || polygon == null || polygon.Length < 2)
|
||||
return;
|
||||
|
||||
var fill = hidden ? HiddenFill : locked ? LockedFill : selected ? SelectedFill : hovered ? HoverFill : NormalFill;
|
||||
if (polygon.Length >= 3 && Geometry2D.TriangulatePolygon(polygon).Length > 0)
|
||||
DrawHatch(canvas, polygon, fill);
|
||||
|
||||
if (selected)
|
||||
DrawDashedKeyline(canvas, polygon, OutlineSolid, OutlineDash, SelectedOutlineWidth);
|
||||
else if (hidden || (hovered && !locked))
|
||||
DrawDashedKeyline(canvas, polygon, OutlineSolidSoft, OutlineDashSoft, HoverOutlineWidth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Colour of the repeating hatch pattern at a pixel offset: a 45°
|
||||
/// diagonal where the gold stripe is immediately followed by the orange
|
||||
/// one. Kept pure (no Godot resources) so the two guarantees the whole
|
||||
/// style rests on — the pattern tiles and every tile carries both
|
||||
/// accent colours — are unit tested without a rendering context.
|
||||
/// </summary>
|
||||
public static Color HatchPixel(int x, int y)
|
||||
{
|
||||
var band = (x + y) % HatchTile;
|
||||
if (band < 0)
|
||||
band += HatchTile;
|
||||
|
||||
if (band < HatchStripe)
|
||||
return Gold;
|
||||
if (band < HatchStripe * 2)
|
||||
return Orange;
|
||||
return new Color(0f, 0f, 0f, 0f);
|
||||
}
|
||||
|
||||
private static void DrawHatch(CanvasItem canvas, Vector2[] polygon, Color modulate)
|
||||
{
|
||||
var uvs = new Vector2[polygon.Length];
|
||||
for (var i = 0; i < polygon.Length; i++)
|
||||
uvs[i] = polygon[i] / HatchTile;
|
||||
|
||||
canvas.DrawColoredPolygon(polygon, modulate, uvs, HatchTexture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-tone dashed keyline: the orange line is drawn solid first and
|
||||
/// the gold dashes — whose gaps equal their length — are stamped on
|
||||
/// top, so the perimeter alternates the two accent colours like
|
||||
/// marching ants.
|
||||
/// </summary>
|
||||
private static void DrawDashedKeyline(CanvasItem canvas, Vector2[] polygon, Color solid, Color dash, float width)
|
||||
{
|
||||
var loop = new Vector2[polygon.Length + 1];
|
||||
polygon.CopyTo(loop, 0);
|
||||
loop[polygon.Length] = polygon[0];
|
||||
canvas.DrawPolyline(loop, solid, width, true);
|
||||
|
||||
for (var i = 0; i < polygon.Length; i++)
|
||||
canvas.DrawDashedLine(polygon[i], loop[i + 1], dash, width, OutlineDashLength, true, true);
|
||||
}
|
||||
|
||||
private static Texture2D HatchTexture => _hatchTexture ??= BuildHatchTexture();
|
||||
|
||||
private static Texture2D BuildHatchTexture()
|
||||
{
|
||||
var image = Image.CreateEmpty(HatchTile, HatchTile, false, Image.Format.Rgba8);
|
||||
for (var y = 0; y < HatchTile; y++)
|
||||
{
|
||||
for (var x = 0; x < HatchTile; x++)
|
||||
image.SetPixel(x, y, HatchPixel(x, y));
|
||||
}
|
||||
|
||||
return ImageTexture.CreateFromImage(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Invisible interactive layer of the world map that draws every zone
|
||||
/// polygon and reports pointer activity through signals. Polygons arrive as
|
||||
/// normalised [0..1] point lists — the same data <see cref="MapZone"/> reads
|
||||
/// from <c>mods/<mod>/map/*.json</c> — and are scaled by the layer's
|
||||
/// own size, which keeps the map resolution-independent and makes the
|
||||
/// polygon a much larger click target than the tiny marker icon.
|
||||
/// <para/>
|
||||
/// The layer never selects anything by itself: <see cref="MapWidget"/> owns
|
||||
/// the selected zone and controls the outline through <see cref="SetSelected"/>.
|
||||
/// Hover is deliberately split in two: <see cref="SetHovered"/> is the
|
||||
/// cross-highlight entry point used when the pointer is over the paired
|
||||
/// marker, while pointer motion inside the layer emits
|
||||
/// <see cref="ZoneHoveredEventHandler"/>. Because the setter does not emit,
|
||||
/// marker and polygon can highlight each other without signal loops.
|
||||
/// <para/>
|
||||
/// All painting is delegated to <see cref="MapZoneStyle"/>, the single
|
||||
/// style definition shared with the map editor canvas, so the game and the
|
||||
/// editor render zones identically. Closed zones stay selectable (the
|
||||
/// location card explains the block): they use the dimmed locked hatch, and
|
||||
/// unlike before the selection keyline is still drawn for them, so a picked
|
||||
/// locked zone is visible too.
|
||||
/// </summary>
|
||||
public partial class MapZonesLayer : Control
|
||||
{
|
||||
[Signal] public delegate void ZoneClickedEventHandler(string zoneId);
|
||||
[Signal] public delegate void ZoneHoveredEventHandler(string zoneId, bool hovered);
|
||||
|
||||
private sealed class ZoneShape
|
||||
{
|
||||
/// <summary>Zone id this shape highlights on hover and reports on click.</summary>
|
||||
public string ZoneId;
|
||||
/// <summary>Polygon vertices scaled to layer pixels at draw time.</summary>
|
||||
public Vector2[] Points;
|
||||
/// <summary>True when the zone's effective open state is closed; switches the hatch to the dimmed locked style.</summary>
|
||||
public bool Locked;
|
||||
}
|
||||
|
||||
private readonly List<ZoneShape> _shapes = new();
|
||||
private string _selectedId;
|
||||
private string _hoverId;
|
||||
|
||||
/// <summary>Prepares the canvas for hatch painting and subscribes pointer and resize handlers.</summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
MapZoneStyle.Prepare(this);
|
||||
GuiInput += OnGuiInput;
|
||||
MouseExited += OnMouseExited;
|
||||
Resized += OnResized;
|
||||
}
|
||||
|
||||
/// <summary>Drops every registered polygon; call before rebuilding the zone set.</summary>
|
||||
public void ClearZones()
|
||||
{
|
||||
_shapes.Clear();
|
||||
_selectedId = null;
|
||||
_hoverId = null;
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers one normalised polygon. Shapes with fewer than three points
|
||||
/// have no interior and are skipped — such a zone can still be reached
|
||||
/// through its marker.
|
||||
/// </summary>
|
||||
public void AddZone(string zoneId, IReadOnlyList<Vector2> normalizedPoints, bool locked)
|
||||
{
|
||||
if (string.IsNullOrEmpty(zoneId) || normalizedPoints == null || normalizedPoints.Count < 3)
|
||||
return;
|
||||
|
||||
var points = new Vector2[normalizedPoints.Count];
|
||||
for (var i = 0; i < points.Length; i++)
|
||||
points[i] = normalizedPoints[i];
|
||||
|
||||
_shapes.Add(new ZoneShape { ZoneId = zoneId, Points = points, Locked = locked });
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Updates the closed state of a registered zone, e.g. after the active scene changed.</summary>
|
||||
public void SetLocked(string zoneId, bool locked)
|
||||
{
|
||||
foreach (var shape in _shapes)
|
||||
{
|
||||
if (shape.ZoneId != zoneId)
|
||||
continue;
|
||||
shape.Locked = locked;
|
||||
QueueRedraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marks the zone as selected; its outline is painted above every other polygon.</summary>
|
||||
public void SetSelected(string zoneId)
|
||||
{
|
||||
_selectedId = zoneId;
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a hover state coming from the paired marker. Does not emit
|
||||
/// <see cref="ZoneHoveredEventHandler"/> on purpose, so the marker's
|
||||
/// hover and the polygon's hover never bounce off each other.
|
||||
/// </summary>
|
||||
public void SetHovered(string zoneId, bool hovered)
|
||||
{
|
||||
if (hovered)
|
||||
{
|
||||
if (_hoverId == zoneId)
|
||||
return;
|
||||
_hoverId = zoneId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_hoverId != zoneId)
|
||||
return;
|
||||
_hoverId = null;
|
||||
}
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Paints every zone polygon, redrawing the selected one last so its keyline sits on top.</summary>
|
||||
public override void _Draw()
|
||||
{
|
||||
if (_shapes.Count == 0)
|
||||
return;
|
||||
|
||||
ZoneShape selected = null;
|
||||
|
||||
foreach (var shape in _shapes)
|
||||
{
|
||||
if (shape.ZoneId == _selectedId)
|
||||
{
|
||||
selected = shape;
|
||||
continue;
|
||||
}
|
||||
DrawShape(shape, Size);
|
||||
}
|
||||
|
||||
if (selected != null)
|
||||
DrawShape(selected, Size);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paints one zone polygon through <see cref="MapZoneStyle"/>.
|
||||
/// Polygons that cannot be triangulated — a self-intersecting outline
|
||||
/// or a degenerate sliver produced while authoring — skip the fill
|
||||
/// entirely, because Godot's textured polygon draw reports a native
|
||||
/// triangulation error for them on every frame; the outline is still
|
||||
/// drawn so the author can see and repair the shape.
|
||||
/// </summary>
|
||||
private void DrawShape(ZoneShape shape, Vector2 size)
|
||||
{
|
||||
MapZoneStyle.Draw(this, ScaledPolygon(shape, size), shape.ZoneId == _selectedId, shape.ZoneId == _hoverId, shape.Locked);
|
||||
}
|
||||
|
||||
private void OnGuiInput(InputEvent @event)
|
||||
{
|
||||
switch (@event)
|
||||
{
|
||||
case InputEventMouseMotion:
|
||||
ApplyPointer(HitTest(GetLocalMousePosition()));
|
||||
break;
|
||||
case InputEventMouseButton button when button.Pressed && button.ButtonIndex == MouseButton.Left:
|
||||
var shape = HitTest(button.Position);
|
||||
if (shape == null)
|
||||
return;
|
||||
EmitSignal(SignalName.ZoneClicked, shape.ZoneId);
|
||||
AcceptEvent();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseExited()
|
||||
{
|
||||
ApplyPointer(null);
|
||||
}
|
||||
|
||||
private void OnResized()
|
||||
{
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Resolves the topmost zone under a layer-local point; polygons later in the list win overlaps.</summary>
|
||||
private ZoneShape HitTest(Vector2 point)
|
||||
{
|
||||
for (var i = _shapes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var shape = _shapes[i];
|
||||
if (Geometry2D.IsPointInPolygon(point, ScaledPolygon(shape, Size)))
|
||||
return shape;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ApplyPointer(ZoneShape shape)
|
||||
{
|
||||
var nextId = shape?.ZoneId;
|
||||
var nextCursor = shape == null ? CursorShape.Arrow : CursorShape.PointingHand;
|
||||
|
||||
if (MouseDefaultCursorShape != nextCursor)
|
||||
MouseDefaultCursorShape = nextCursor;
|
||||
|
||||
if (_hoverId == nextId)
|
||||
return;
|
||||
|
||||
var previous = _hoverId;
|
||||
_hoverId = nextId;
|
||||
QueueRedraw();
|
||||
|
||||
if (!string.IsNullOrEmpty(previous))
|
||||
EmitSignal(SignalName.ZoneHovered, previous, false);
|
||||
if (!string.IsNullOrEmpty(nextId))
|
||||
EmitSignal(SignalName.ZoneHovered, nextId, true);
|
||||
}
|
||||
|
||||
private static Vector2[] ScaledPolygon(ZoneShape shape, Vector2 size)
|
||||
{
|
||||
var result = new Vector2[shape.Points.Length];
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
result[i] = shape.Points[i] * size;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks the world map engine mod for subsystem coverage discovery: the
|
||||
/// coverage test scans loaded assemblies for this marker and requires a
|
||||
/// declared integration test for the subsystem.
|
||||
/// </summary>
|
||||
public static class SubsystemRegistration
|
||||
{
|
||||
public static string SubsystemId => "map";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user