This commit is contained in:
iisaev committed 2026-10-18 12:41:42 +03:00
commit 025a7aeee9
17 files changed
+2303

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
mod_map_base_name = World Map Engine
mod_map_base_desc = Engine world map subsystem: the fullscreen map overlay, polygon zones, markers, the zone list, the scenario-facing map service (open/close/show/hide actions) and the shared zone style and enterability rules.
+2
View File
@@ -0,0 +1,2 @@
mod_map_base_name = Движок карты мира
mod_map_base_desc = Подсистема карты мира: полноэкранный оверлей карты, полигональные зоны, маркеры, список зон, сценарные действия open/close/show/hide и общие правила стиля и входа в зоны.
+13
View File
@@ -0,0 +1,13 @@
{
"id": "map_base",
"name": "World Map Engine",
"version": "1.0.0",
"author": "ct",
"description": "Engine world map subsystem: the full-screen map overlay (MapWidget), polygon zones layer, markers, the zone list + runtime override singleton, the scenario-facing map service facade with the open/close/show/hide scenario actions, and the shared zone style and enterability rules. Replaces the former Core/UI/Tool Map* implementations and Core/Actions/MapZoneServiceImpl. The save-format DTO MapZoneStateData stays in the core because RuntimeSerializer owns the __core__ slot layout; the mod plugs into it through MapStateBridge.",
"category": "engine, library",
"sources": [
"src/**/*.cs"
],
"depends": ["interface>=1"],
"ui": []
}
+41
View File
@@ -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}'");
}
}
}
+40
View File
@@ -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}'");
}
}
}
+43
View File
@@ -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}'");
}
}
}
+41
View File
@@ -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}'");
}
}
}
+33
View File
@@ -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);
}
}
+258
View File
@@ -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);
}
}
}
+28
View File
@@ -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);
}
}
}
+890
View File
@@ -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
View File
@@ -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/&lt;name&gt;/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; }
}
}
+40
View File
@@ -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);
}
}
}
+333
View File
@@ -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/&lt;name&gt;/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 });
}
}
}
+175
View File
@@ -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);
}
}
}
+231
View File
@@ -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/&lt;mod&gt;/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;
}
}
}
+12
View File
@@ -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";
}
}