diff --git a/.gitignore b/.gitignore
index c301ca8..bb88804 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,37 +1,37 @@
-# Godot 4.x
-.godot/
-.idea/
-sdk/
-bin/
-obj/
-
-#ai
-.omo/
-.opencode/
-
-gen/
-obj/
-bin/
-
-*.tres~
-*.tscn~
-
-# GDScript LSP
-.godot/
-
-# Export
-*.exe
-*.dmg
-*.apk
-*.aab
-*.pck
-*.zip
-*.log
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Editor
-*.import
-*.godot.uid
+# Godot 4.x
+.godot/
+.idea/
+sdk/
+bin/
+obj/
+
+#ai
+.omo/
+.opencode/
+
+gen/
+obj/
+bin/
+
+*.tres~
+*.tscn~
+
+# GDScript LSP
+.godot/
+
+# Export
+*.exe
+*.dmg
+*.apk
+*.aab
+*.pck
+*.zip
+*.log
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Editor
+*.import
+*.godot.uid
diff --git a/src/Lights/LampBehaviour.cs b/src/Lights/LampBehaviour.cs
index 82bf188..c56d858 100644
--- a/src/Lights/LampBehaviour.cs
+++ b/src/Lights/LampBehaviour.cs
@@ -1,147 +1,147 @@
-using Cthangover.Core.Scenes;
-using Cthangover.Core.Settings;
-using Cthangover.Core.UI.Tool;
-using Cthangover.Core.Utils;
-using Godot;
-
+using Cthangover.Core.Scenes;
+using Cthangover.Core.Settings;
+using Cthangover.Core.UI.Tool;
+using Cthangover.Core.Utils;
+using Godot;
+
using Cthangover.Mods.Interface.Tool;
-namespace Cthangover.Mods.Time
-{
- ///
- /// Draggable lamp UI element for the player's portable light source.
- /// Input handling exists in both _GuiInput (bubbled) and _Input (global)
- /// to catch drags that start on the lamp but continue outside its rect.
- /// When the lamp is hidden, it resets position to offscreen (-1000,-1000)
- /// to disable the light in the shader. GetLightParams() is a static helper
- /// so the light controller can query lamp parameters without coupling to
- /// the behaviour instance. Inherits from TextureRectModIconLoader for
- /// mod-provided lamp icon textures.
- ///
- public partial class LampBehaviour : TextureRectModIconLoader
- {
- private UiLightController controller;
- private Vector2 dragPosition;
- private bool isPlaced;
-
- private bool pressingOnLamp;
-
- ///
- /// Queries lamp parameters (color, radius, influence) from
- /// . Static so the
- /// can query without a direct reference.
- ///
- public static (Color, float, float) GetLightParams()
- {
- var lampData = GameData.Instance.Runtime.LampData;
- var radius = lampData.Radius;
- var influence = lampData.Influence;
- return (Colors.Yellow, radius, influence);
- }
-
- ///
- /// Visibility toggle for the lamp. Setting to false resets the
- /// placed state and sends the light position offscreen to disable
- /// the lamp light in the shader. Uses new to log transitions.
- ///
- public new bool Visible
- {
- get => base.Visible;
- set
- {
- if (base.Visible != value)
- GameLogger.Log("LIGHT", $"Lamp.Visible: {base.Visible} -> {value}, isPlaced={isPlaced}", LogLevel.Debug);
- base.Visible = value;
- if (!value)
- {
- pressingOnLamp = false;
- if (isPlaced)
- {
- isPlaced = false;
- controller?.UpdateLightPos(new Vector2(-1000f, -1000f));
- }
- }
- }
- }
-
- /// Finds the shared this lamp pushes positions to.
- public override void _Ready()
- {
- base._Ready();
- MouseFilter = MouseFilterEnum.Stop;
- controller = SceneContextNode.FindNode("Lights");
- GameLogger.Log("LIGHT", $"Lamp._Ready: controller={(controller != null ? "OK" : "NULL")}", LogLevel.Debug);
- }
-
- /// Click to place the light at the cursor, drag to move it, and click a placed lamp to pick it back up.
- public override void _GuiInput(InputEvent @event)
- {
- if (@event is InputEventMouseButton mouseButton)
- {
- if (mouseButton.ButtonIndex == MouseButton.Left)
- {
- if (mouseButton.Pressed)
- {
- pressingOnLamp = true;
- dragPosition = GetGlobalMousePosition();
-
- if (isPlaced)
- {
- dragPosition = new Vector2(-1000f, -1000f);
- controller?.UpdateLightPos(dragPosition);
- isPlaced = false;
- }
- }
- else
- {
- if (pressingOnLamp && !isPlaced)
- {
- isPlaced = true;
- controller?.UpdateLightPos(dragPosition);
- }
- pressingOnLamp = false;
- }
- }
- AcceptEvent();
- }
- else if (@event is InputEventMouseMotion mouseMotion)
- {
- if (Input.IsMouseButtonPressed(MouseButton.Left))
- {
- dragPosition = GetGlobalMousePosition();
- controller?.UpdateLightPos(dragPosition);
- isPlaced = false;
- AcceptEvent();
- }
- }
- }
-
- /// Keeps dragging the placed light while the pointer moves outside the lamp's own control rect.
- public override void _Input(InputEvent @event)
- {
- if (!Visible)
- return;
-
- if (@event is InputEventMouseMotion mouseMotion
- && Input.IsMouseButtonPressed(MouseButton.Left)
- && pressingOnLamp)
- {
- dragPosition = GetGlobalMousePosition();
- controller?.UpdateLightPos(dragPosition);
- isPlaced = false;
- }
-
- if (@event is InputEventMouseButton mouseButton
- && mouseButton.ButtonIndex == MouseButton.Left
- && !mouseButton.Pressed
- && pressingOnLamp)
- {
- if (!isPlaced)
- {
- isPlaced = true;
- controller?.UpdateLightPos(dragPosition);
- }
- pressingOnLamp = false;
- }
- }
- }
-}
+namespace Cthangover.Mods.Time
+{
+ ///
+ /// Draggable lamp UI element for the player's portable light source.
+ /// Input handling exists in both _GuiInput (bubbled) and _Input (global)
+ /// to catch drags that start on the lamp but continue outside its rect.
+ /// When the lamp is hidden, it resets position to offscreen (-1000,-1000)
+ /// to disable the light in the shader. GetLightParams() is a static helper
+ /// so the light controller can query lamp parameters without coupling to
+ /// the behaviour instance. Inherits from TextureRectModIconLoader for
+ /// mod-provided lamp icon textures.
+ ///
+ public partial class LampBehaviour : TextureRectModIconLoader
+ {
+ private UiLightController controller;
+ private Vector2 dragPosition;
+ private bool isPlaced;
+
+ private bool pressingOnLamp;
+
+ ///
+ /// Queries lamp parameters (color, radius, influence) from
+ /// . Static so the
+ /// can query without a direct reference.
+ ///
+ public static (Color, float, float) GetLightParams()
+ {
+ var lampData = GameData.Instance.Runtime.LampData;
+ var radius = lampData.Radius;
+ var influence = lampData.Influence;
+ return (Colors.Yellow, radius, influence);
+ }
+
+ ///
+ /// Visibility toggle for the lamp. Setting to false resets the
+ /// placed state and sends the light position offscreen to disable
+ /// the lamp light in the shader. Uses new to log transitions.
+ ///
+ public new bool Visible
+ {
+ get => base.Visible;
+ set
+ {
+ if (base.Visible != value)
+ GameLogger.Log("LIGHT", $"Lamp.Visible: {base.Visible} -> {value}, isPlaced={isPlaced}", LogLevel.Debug);
+ base.Visible = value;
+ if (!value)
+ {
+ pressingOnLamp = false;
+ if (isPlaced)
+ {
+ isPlaced = false;
+ controller?.UpdateLightPos(new Vector2(-1000f, -1000f));
+ }
+ }
+ }
+ }
+
+ /// Finds the shared this lamp pushes positions to.
+ public override void _Ready()
+ {
+ base._Ready();
+ MouseFilter = MouseFilterEnum.Stop;
+ controller = SceneContextNode.FindNode("Lights");
+ GameLogger.Log("LIGHT", $"Lamp._Ready: controller={(controller != null ? "OK" : "NULL")}", LogLevel.Debug);
+ }
+
+ /// Click to place the light at the cursor, drag to move it, and click a placed lamp to pick it back up.
+ public override void _GuiInput(InputEvent @event)
+ {
+ if (@event is InputEventMouseButton mouseButton)
+ {
+ if (mouseButton.ButtonIndex == MouseButton.Left)
+ {
+ if (mouseButton.Pressed)
+ {
+ pressingOnLamp = true;
+ dragPosition = GetGlobalMousePosition();
+
+ if (isPlaced)
+ {
+ dragPosition = new Vector2(-1000f, -1000f);
+ controller?.UpdateLightPos(dragPosition);
+ isPlaced = false;
+ }
+ }
+ else
+ {
+ if (pressingOnLamp && !isPlaced)
+ {
+ isPlaced = true;
+ controller?.UpdateLightPos(dragPosition);
+ }
+ pressingOnLamp = false;
+ }
+ }
+ AcceptEvent();
+ }
+ else if (@event is InputEventMouseMotion mouseMotion)
+ {
+ if (Input.IsMouseButtonPressed(MouseButton.Left))
+ {
+ dragPosition = GetGlobalMousePosition();
+ controller?.UpdateLightPos(dragPosition);
+ isPlaced = false;
+ AcceptEvent();
+ }
+ }
+ }
+
+ /// Keeps dragging the placed light while the pointer moves outside the lamp's own control rect.
+ public override void _Input(InputEvent @event)
+ {
+ if (!Visible)
+ return;
+
+ if (@event is InputEventMouseMotion mouseMotion
+ && Input.IsMouseButtonPressed(MouseButton.Left)
+ && pressingOnLamp)
+ {
+ dragPosition = GetGlobalMousePosition();
+ controller?.UpdateLightPos(dragPosition);
+ isPlaced = false;
+ }
+
+ if (@event is InputEventMouseButton mouseButton
+ && mouseButton.ButtonIndex == MouseButton.Left
+ && !mouseButton.Pressed
+ && pressingOnLamp)
+ {
+ if (!isPlaced)
+ {
+ isPlaced = true;
+ controller?.UpdateLightPos(dragPosition);
+ }
+ pressingOnLamp = false;
+ }
+ }
+ }
+}
diff --git a/src/Lights/LightDef.cs b/src/Lights/LightDef.cs
index e5177c5..02f8f39 100644
--- a/src/Lights/LightDef.cs
+++ b/src/Lights/LightDef.cs
@@ -1,79 +1,79 @@
-using System.Text.Json.Serialization;
-using Godot;
-
-namespace Cthangover.Mods.Time
-{
- ///
- /// Serializable light definition for JSON scene lighting. Coordinates are
- /// viewport-relative (0.0–1.0) so scenes adapt to different resolutions.
- /// ToPixelPos/FromPixelPos convert between normalized space and pixel space
- /// using a reference viewport size. Color is stored as a hex string for
- /// human-readable JSON editing.
- ///
- public class LightDef
- {
- ///
- /// Normalized horizontal position (0.0–1.0) relative to viewport width.
- ///
- [JsonPropertyName("x")]
- public float X { get; set; }
-
- ///
- /// Normalized vertical position (0.0–1.0) relative to viewport height.
- ///
- [JsonPropertyName("y")]
- public float Y { get; set; }
-
- ///
- /// Light radius in pixel units. Determines the falloff distance in the
- /// lighting shader.
- ///
- [JsonPropertyName("radius")]
- public float Radius { get; set; }
-
- ///
- /// Light intensity (0.0–1.0). At 1.0, the light fully brightens
- /// pixels within its radius. Lower values create subtler effects.
- ///
- [JsonPropertyName("influence")]
- public float Influence { get; set; } = 1f;
-
- ///
- /// Light color as a hex string (e.g. "#ff8800"). Parsed by
- /// for shader upload.
- ///
- [JsonPropertyName("color")]
- public string ColorHex { get; set; } = "#ffff00";
-
- ///
- /// Converts to a Godot Color struct.
- ///
- public Color ToColor()
- {
- return new Color(ColorHex);
- }
-
- ///
- /// Converts normalized coordinates (, ) to
- /// pixel coordinates based on .
- ///
- /// Current viewport dimensions in pixels.
- public Vector2 ToPixelPos(Vector2 viewportSize)
- {
- return new Vector2(X * viewportSize.X, Y * viewportSize.Y);
- }
-
- ///
- /// Computes normalized coordinates from pixel coordinates by dividing by
- /// . Enables saving light positions
- /// across different resolutions.
- ///
- /// Light position in pixel space.
- /// Viewport dimensions used as reference.
- public void FromPixelPos(Vector2 pixelPos, Vector2 referenceSize)
- {
- X = pixelPos.X / referenceSize.X;
- Y = pixelPos.Y / referenceSize.Y;
- }
- }
-}
+using System.Text.Json.Serialization;
+using Godot;
+
+namespace Cthangover.Mods.Time
+{
+ ///
+ /// Serializable light definition for JSON scene lighting. Coordinates are
+ /// viewport-relative (0.0–1.0) so scenes adapt to different resolutions.
+ /// ToPixelPos/FromPixelPos convert between normalized space and pixel space
+ /// using a reference viewport size. Color is stored as a hex string for
+ /// human-readable JSON editing.
+ ///
+ public class LightDef
+ {
+ ///
+ /// Normalized horizontal position (0.0–1.0) relative to viewport width.
+ ///
+ [JsonPropertyName("x")]
+ public float X { get; set; }
+
+ ///
+ /// Normalized vertical position (0.0–1.0) relative to viewport height.
+ ///
+ [JsonPropertyName("y")]
+ public float Y { get; set; }
+
+ ///
+ /// Light radius in pixel units. Determines the falloff distance in the
+ /// lighting shader.
+ ///
+ [JsonPropertyName("radius")]
+ public float Radius { get; set; }
+
+ ///
+ /// Light intensity (0.0–1.0). At 1.0, the light fully brightens
+ /// pixels within its radius. Lower values create subtler effects.
+ ///
+ [JsonPropertyName("influence")]
+ public float Influence { get; set; } = 1f;
+
+ ///
+ /// Light color as a hex string (e.g. "#ff8800"). Parsed by
+ /// for shader upload.
+ ///
+ [JsonPropertyName("color")]
+ public string ColorHex { get; set; } = "#ffff00";
+
+ ///
+ /// Converts to a Godot Color struct.
+ ///
+ public Color ToColor()
+ {
+ return new Color(ColorHex);
+ }
+
+ ///
+ /// Converts normalized coordinates (, ) to
+ /// pixel coordinates based on .
+ ///
+ /// Current viewport dimensions in pixels.
+ public Vector2 ToPixelPos(Vector2 viewportSize)
+ {
+ return new Vector2(X * viewportSize.X, Y * viewportSize.Y);
+ }
+
+ ///
+ /// Computes normalized coordinates from pixel coordinates by dividing by
+ /// . Enables saving light positions
+ /// across different resolutions.
+ ///
+ /// Light position in pixel space.
+ /// Viewport dimensions used as reference.
+ public void FromPixelPos(Vector2 pixelPos, Vector2 referenceSize)
+ {
+ X = pixelPos.X / referenceSize.X;
+ Y = pixelPos.Y / referenceSize.Y;
+ }
+ }
+}
diff --git a/src/Lights/UiLightController.cs b/src/Lights/UiLightController.cs
index 3c130b1..526d3fe 100644
--- a/src/Lights/UiLightController.cs
+++ b/src/Lights/UiLightController.cs
@@ -1,519 +1,519 @@
-using System.Collections.Generic;
-using System.Text.Json;
-using Cthangover.Core.Scenes;
-using Cthangover.Core.Settings;
-using Cthangover.Core.UI.Event;
-using Cthangover.Mods.Interface.View;
-using Cthangover.Core.Utils;
-using Godot;
-
-namespace Cthangover.Mods.Time
-{
- ///
- /// Singleton 2D lighting controller that drives a multi-light shader system.
- /// Maintains up to 11 lights: index 0 is the player's lamp, indices 1-10 are
- /// static scene lights. Pushes uniform arrays (positions, colors, radii,
- /// influence) to all registered ShaderMaterials — the ViewBox background/
- /// foreground plus any externally registered materials. The time-of-day
- /// system blends four phases (morning/day/evening/night) via a Vector4 weights
- /// uniform, with smooth transitions between phases. DarkMode overrides time
- /// with full-night weights. Materials are discovered lazily via SceneContextNode
- /// and cached, with a registration system for extra materials (e.g. mod-added
- /// sprites that need lighting). _EnterTree manages singleton enforcement
- /// with duplicate warnings, not assertions, to avoid blocking scene reloads.
- ///
- public partial class UiLightController : Control, IOnTimeEvent
- {
- ///
- /// Singleton accessor for the active controller. Set in ,
- /// cleared in . Duplicate instances log an error but
- /// overwrite the reference.
- ///
- public static UiLightController Instance { get; private set; }
-
- ///
- /// Default depth map texture applied on startup for parallax lighting effects.
- ///
- [Export] public Texture2D DefaultDepthMap { get; set; }
-
- ///
- /// Default albedo (color) texture applied on startup for lighting blending.
- ///
- [Export] public Texture2D DefaultAlbedoMap { get; set; }
-
- ///
- /// When true, forces full-night time-of-day weights regardless of
- /// in-game time. Set from game settings or scenario commands.
- ///
- [Export] public bool DarkMode { get; set; }
-
- ///
- /// Master toggle for time-of-day lighting. When false, time blending
- /// is disabled on all shader materials.
- ///
- [Export] public bool UseLight { get; set; } = true;
-
- private int staticLightCount;
-
- ///
- /// The currently active depth map texture, pushed to all shader materials.
- ///
- public Texture2D CurrentDepthMap { get; private set; }
-
- ///
- /// The currently active albedo texture, pushed to all shader materials.
- ///
- public Texture2D CurrentAlbedoMap { get; private set; }
-
- private SceneEventController eventController;
- private ShaderMaterial viewBgMaterial;
- private ShaderMaterial viewFgMaterial;
- private readonly List extraMaterials = new();
-
- private readonly Vector2[] lightPositions = new Vector2[11];
- private readonly Color[] lightColors = new Color[11];
- private readonly float[] lightRadii = new float[11];
- private readonly float[] lightInfluence = new float[11];
-
- private LampBehaviour lampBehaviour;
-
- ///
- /// Priority 0 — the lighting controller runs before most other timer-tick
- /// subscribers to ensure shader params are updated first.
- ///
- public int Priority => 0;
-
- ///
- /// Runtime dark mode toggle. Updates and immediately
- /// recalculates time-of-day weights.
- ///
- public bool IsDarkMode
- {
- get => DarkMode;
- set
- {
- DarkMode = value;
- SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
- }
- }
-
- ///
- /// Runtime toggle for time-of-day blending. Pushes the new value to all
- /// registered shader materials.
- ///
- public bool IsUseLight
- {
- get => UseLight;
- set
- {
- UseLight = value;
- ChangeUseTime(UseLight);
- }
- }
-
- /// Sets the singleton with a diagnostic log when a duplicate controller enters the tree first.
- public override void _EnterTree()
- {
- if (Instance != null && GodotObject.IsInstanceValid(Instance))
- {
- var scene = GetTree()?.CurrentScene?.Name ?? "?";
- var existingPath = Instance.GetPath().ToString();
- var myPath = GetPath().ToString();
- GameLogger.Log("DUPLICATE", $"UiLightController._EnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'", LogLevel.Error);
- }
- Instance = this;
- }
-
- /// Clears the singleton and detaches from the timer ticks when the controller leaves the tree.
- public override void _ExitTree()
- {
- if (Instance == this)
- Instance = null;
- eventController?.RemoveTimerTickEventListener(this);
- }
-
- /// Initializes shader maps, parks the lamp offscreen and subscribes to timer ticks for time-of-day blending.
- public override void _Ready()
- {
- MouseFilter = MouseFilterEnum.Ignore;
-
- UpdateLampLightParams();
- lightPositions[0] = new Vector2(-1000f, -1000f);
-
- SetupDepthMap(DefaultDepthMap);
- SetupAlbedoMap(DefaultAlbedoMap);
-
- eventController = SceneContextNode.FindNode("EventController");
- eventController?.AddTimerTickEventListener(this);
-
- GameLogger.Log("LIGHT", $"LightsCtrl._Ready: eventCtrl={(eventController != null ? "OK" : "NULL")}, DarkMode={DarkMode}, UseLight={UseLight}", LogLevel.Debug);
-
- CallDeferred(nameof(SetupTime));
- }
-
- ///
- /// Reads lamp parameters from
- /// and updates the player light slot (index 0) in the uniform arrays.
- ///
- public void UpdateLampLightParams()
- {
- var lightParams = LampBehaviour.GetLightParams();
- lightColors[0] = lightParams.Item1;
- lightRadii[0] = lightParams.Item2;
- lightInfluence[0] = lightParams.Item3;
- }
-
- ///
- /// Resets all static light slots (indices 1–10) to off-screen positions
- /// with zero radius and influence, then pushes to shader materials.
- ///
- public void ClearStaticLights()
- {
- for (int i = 1; i < 11; i++)
- {
- lightPositions[i] = new Vector2(-1000f, -1000f);
- lightRadii[i] = 0f;
- lightInfluence[i] = 0f;
- lightColors[i] = Colors.White;
- }
-
- staticLightCount = 0;
- UpdateShaders();
-
- GameLogger.Log("LIGHT", "LightsCtrl.ClearStaticLights: all static lights cleared");
- }
-
- ///
- /// Deserializes a JSON array of objects and populates
- /// static light slots 1–10. Supports up to 10 static lights.
- ///
- /// JSON string containing an array of objects.
- public void SetStaticLights(string json)
- {
- ClearStaticLights();
-
- if (string.IsNullOrEmpty(json))
- return;
-
- try
- {
- var lights = JsonSerializer.Deserialize>(json, JsonOptionsProvider.ReadOptions);
-
- if (lights == null || lights.Count == 0)
- return;
-
- int count = Mathf.Min(lights.Count, 10);
- var vpSize = GetViewport()?.GetVisibleRect().Size ?? new Vector2(1920, 1024);
- for (int i = 0; i < count; i++)
- {
- lightPositions[i + 1] = lights[i].ToPixelPos(vpSize);
- lightRadii[i + 1] = lights[i].Radius;
- lightInfluence[i + 1] = lights[i].Influence;
- lightColors[i + 1] = lights[i].ToColor();
- }
-
- staticLightCount = count+1;
- UpdateShaders();
-
- GameLogger.Log("LIGHT", $"LightsCtrl.SetStaticLights: parsed {lights.Count} light(s), applied {count} static lights");
- }
-
- catch (System.Exception ex)
- {
- GameLogger.Log("LIGHT", $"LightsCtrl.SetStaticLights: JSON parse error: {ex.Message}", LogLevel.Error);
- }
- }
-
- private void SetupTime()
- {
- SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
- }
-
- ///
- /// Timer-tick callback (~1/sec). Updates lamp visibility based on time of
- /// day and recalculates time-of-day weights for shaders.
- ///
- public void OnTimerTick()
- {
- lampBehaviour ??= FindLampChild();
-
- if (lampBehaviour != null)
- {
- var time = GameData.Instance?.Runtime?.Time;
- var hours = time?.Hours ?? 0;
- var newVis = DarkMode || !(hours >= 6 && hours < 22);
- lampBehaviour.Visible = newVis;
- }
-
- SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
- }
-
- ///
- /// Pushes the use_time shader parameter to all registered materials.
- ///
- /// The new use_time uniform value.
- public void ChangeUseTime(bool value)
- {
- ForEachMaterial(m => m.SetShaderParameter("use_time", value));
- }
-
- ///
- /// Adds a to receive lighting uniform updates
- /// alongside the ViewBox background/foreground. Duplicate registrations
- /// are silently ignored.
- ///
- /// The material to register for lighting updates.
- public void RegisterMaterial(ShaderMaterial material)
- {
- if (material == null || extraMaterials.Contains(material))
- return;
- extraMaterials.Add(material);
- PushAllParams(material);
- }
-
- ///
- /// Removes a previously registered material from the lighting update list.
- /// No-op if the material is not currently registered.
- ///
- /// The material to unregister.
- public void UnregisterMaterial(ShaderMaterial material)
- {
- extraMaterials.Remove(material);
- }
-
- private void PushAllParams(ShaderMaterial material)
- {
- var weights = DarkMode
- ? new Vector4(0, 0, 0, 1)
- : CalculateTimeWeights(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
- material.SetShaderParameter("time_weights", weights);
- material.SetShaderParameter("use_time", UseLight);
-
- var posArray = new Godot.Collections.Array();
- var colArray = new Godot.Collections.Array();
- var radArray = new Godot.Collections.Array();
- var infArray = new Godot.Collections.Array();
- for (int i = 0; i < 11; i++)
- {
- posArray.Add(lightPositions[i]);
- colArray.Add(lightColors[i]);
- radArray.Add(lightRadii[i]);
- infArray.Add(lightInfluence[i]);
- }
-
- int count = 0;
- if (lightPositions[0].X > -100)
- count++;
- count += staticLightCount;
-
- material.SetShaderParameter("light_count", count);
- material.SetShaderParameter("light_positions", posArray);
- material.SetShaderParameter("light_colors", colArray);
- material.SetShaderParameter("light_radii", radArray);
- material.SetShaderParameter("light_influence", infArray);
- material.SetShaderParameter("light_radius_scale", 1.0f);
- }
-
- ///
- /// Sets the depth map texture and pushes it to all shader materials via
- /// the depth_mask uniform.
- ///
- /// The new depth map texture.
- public void SetupDepthMap(Texture2D depthMap)
- {
- CurrentDepthMap = depthMap;
- ForEachMaterial(m => m.SetShaderParameter("depth_mask", CurrentDepthMap));
- }
-
- ///
- /// Sets the albedo map texture and pushes it to all shader materials via
- /// the albedo_map uniform.
- ///
- /// The new albedo texture.
- public void SetupAlbedoMap(Texture2D albedoMap)
- {
- CurrentAlbedoMap = albedoMap;
- ForEachMaterial(m => m.SetShaderParameter("albedo_map", CurrentAlbedoMap));
- }
-
- ///
- /// Rebuilds and pushes all lighting uniform arrays to every registered
- /// shader material.
- ///
- public void UpdateShaders()
- {
- int count = 0;
- if (lightPositions[0].X > -100)
- count++;
- count += staticLightCount;
-
- SetShaderArrays(count);
- ChangeUseTime(UseLight);
- }
-
- ///
- /// Updates the player lamp position (slot 0) and pushes to all shaders.
- /// If the position is off-screen (X < -100), the lamp is excluded
- /// from the active light count.
- ///
- /// New lamp position in screen-space pixels.
- public void UpdateLightPos(Vector2 position)
- {
- lightPositions[0] = position;
- int count = 0;
- if (lightPositions[0].X > -100)
- count++;
- count += staticLightCount;
-
- SetShaderArrays(count);
- ChangeUseTime(UseLight);
- }
-
- ///
- /// Directly sets the player light position, radius, and influence without
- /// triggering a shader push. Call afterward.
- ///
- /// Screen-space pixel position.
- /// Light radius in pixels.
- /// Light influence factor (0.0–1.0).
- public void SetPlayerLight(Vector2 position, float radius, float influence)
- {
- lightPositions[0] = position;
- lightRadii[0] = radius;
- lightInfluence[0] = influence;
- }
-
- ///
- /// Calculates time-of-day blend weights and pushes to all shader materials.
- /// Respects — when active, full-night weights are used
- /// regardless of .
- ///
- /// Hour in 0–24 range (may be fractional).
- public void SetTime(float normalizedHour)
- {
- if (DarkMode)
- {
- var night = new Vector4(0, 0, 0, 1);
- ForEachMaterial(m => m.SetShaderParameter("time_weights", night));
- }
- else
- {
- var weights = CalculateTimeWeights(normalizedHour);
- ForEachMaterial(m => m.SetShaderParameter("time_weights", weights));
- }
- UpdateShaders();
- }
-
- private void SetShaderArrays(int count)
- {
- var posArray = new Godot.Collections.Array();
- var colArray = new Godot.Collections.Array();
- var radArray = new Godot.Collections.Array();
- var infArray = new Godot.Collections.Array();
-
- for (int i = 0; i < 11; i++)
- {
- posArray.Add(lightPositions[i]);
- colArray.Add(lightColors[i]);
- radArray.Add(lightRadii[i]);
- infArray.Add(lightInfluence[i]);
- }
-
- ForEachMaterial(m =>
- {
- m.SetShaderParameter("light_count", count);
- m.SetShaderParameter("light_positions", posArray);
- m.SetShaderParameter("light_colors", colArray);
- m.SetShaderParameter("light_radii", radArray);
- m.SetShaderParameter("light_influence", infArray);
- m.SetShaderParameter("light_radius_scale", 1.0f);
- });
- }
-
- private void ForEachMaterial(System.Action action)
- {
- if (viewBgMaterial == null || viewFgMaterial == null)
- {
- var viewBox = SceneContextNode.FindNode("ViewBox");
- if (viewBox != null)
- {
- viewBgMaterial ??= viewBox.Background?.Material as ShaderMaterial;
- viewFgMaterial ??= viewBox.Foreground?.Material as ShaderMaterial;
- }
- }
-
- if (viewBgMaterial != null) action(viewBgMaterial);
- if (viewFgMaterial != null) action(viewFgMaterial);
-
- for (int i = extraMaterials.Count - 1; i >= 0; i--)
- {
- if (extraMaterials[i] == null)
- extraMaterials.RemoveAt(i);
- else
- action(extraMaterials[i]);
- }
- }
-
- private LampBehaviour FindLampChild()
- {
- return SceneContextNode.FindNode("Lamp");
- }
-
- //
- // 06:00-10:00 - Morning
- // 10:00-18:00 - Day
- // 18:00-22:00 - Evening
- // 22:00-06:00 - Night
- //
- private static Vector4 CalculateTimeWeights(float hour)
- {
- float night = 0.0f;
- float morning = 0.0f;
- float day = 0.0f;
- float evening = 0.0f;
-
- if (hour >= 22.0f || hour < 4.0f)
- {
- night = 1.0f;
- }
- else if (hour >= 4.0f && hour < 6.0f)
- {
- float t = (hour - 4.0f) / 2.0f;
- night = 1.0f - t;
- morning = t;
- }
- else if (hour >= 6.0f && hour < 9.0f)
- {
- morning = 1.0f;
- }
- else if (hour >= 9.0f && hour < 11.0f)
- {
- float t = (hour - 9.0f) / 2.0f;
- morning = 1.0f - t;
- day = t;
- }
- else if (hour >= 11.0f && hour < 17.0f)
- {
- day = 1.0f;
- }
- else if (hour >= 17.0f && hour < 19.0f)
- {
- float t = (hour - 17.0f) / 2.0f;
- day = 1.0f - t;
- evening = t;
- }
- else if (hour >= 19.0f && hour < 20.0f)
- {
- evening = 1.0f;
- }
- else if (hour >= 20.0f && hour < 22.0f)
- {
- float t = (hour - 20.0f) / 2.0f;
- evening = 1.0f - t;
- night = t;
- }
-
- return new Vector4(morning, day, evening, night);
- }
- }
-}
+using System.Collections.Generic;
+using System.Text.Json;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.Settings;
+using Cthangover.Core.UI.Event;
+using Cthangover.Mods.Interface.View;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.Mods.Time
+{
+ ///
+ /// Singleton 2D lighting controller that drives a multi-light shader system.
+ /// Maintains up to 11 lights: index 0 is the player's lamp, indices 1-10 are
+ /// static scene lights. Pushes uniform arrays (positions, colors, radii,
+ /// influence) to all registered ShaderMaterials — the ViewBox background/
+ /// foreground plus any externally registered materials. The time-of-day
+ /// system blends four phases (morning/day/evening/night) via a Vector4 weights
+ /// uniform, with smooth transitions between phases. DarkMode overrides time
+ /// with full-night weights. Materials are discovered lazily via SceneContextNode
+ /// and cached, with a registration system for extra materials (e.g. mod-added
+ /// sprites that need lighting). _EnterTree manages singleton enforcement
+ /// with duplicate warnings, not assertions, to avoid blocking scene reloads.
+ ///
+ public partial class UiLightController : Control, IOnTimeEvent
+ {
+ ///
+ /// Singleton accessor for the active controller. Set in ,
+ /// cleared in . Duplicate instances log an error but
+ /// overwrite the reference.
+ ///
+ public static UiLightController Instance { get; private set; }
+
+ ///
+ /// Default depth map texture applied on startup for parallax lighting effects.
+ ///
+ [Export] public Texture2D DefaultDepthMap { get; set; }
+
+ ///
+ /// Default albedo (color) texture applied on startup for lighting blending.
+ ///
+ [Export] public Texture2D DefaultAlbedoMap { get; set; }
+
+ ///
+ /// When true, forces full-night time-of-day weights regardless of
+ /// in-game time. Set from game settings or scenario commands.
+ ///
+ [Export] public bool DarkMode { get; set; }
+
+ ///
+ /// Master toggle for time-of-day lighting. When false, time blending
+ /// is disabled on all shader materials.
+ ///
+ [Export] public bool UseLight { get; set; } = true;
+
+ private int staticLightCount;
+
+ ///
+ /// The currently active depth map texture, pushed to all shader materials.
+ ///
+ public Texture2D CurrentDepthMap { get; private set; }
+
+ ///
+ /// The currently active albedo texture, pushed to all shader materials.
+ ///
+ public Texture2D CurrentAlbedoMap { get; private set; }
+
+ private SceneEventController eventController;
+ private ShaderMaterial viewBgMaterial;
+ private ShaderMaterial viewFgMaterial;
+ private readonly List extraMaterials = new();
+
+ private readonly Vector2[] lightPositions = new Vector2[11];
+ private readonly Color[] lightColors = new Color[11];
+ private readonly float[] lightRadii = new float[11];
+ private readonly float[] lightInfluence = new float[11];
+
+ private LampBehaviour lampBehaviour;
+
+ ///
+ /// Priority 0 — the lighting controller runs before most other timer-tick
+ /// subscribers to ensure shader params are updated first.
+ ///
+ public int Priority => 0;
+
+ ///
+ /// Runtime dark mode toggle. Updates and immediately
+ /// recalculates time-of-day weights.
+ ///
+ public bool IsDarkMode
+ {
+ get => DarkMode;
+ set
+ {
+ DarkMode = value;
+ SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
+ }
+ }
+
+ ///
+ /// Runtime toggle for time-of-day blending. Pushes the new value to all
+ /// registered shader materials.
+ ///
+ public bool IsUseLight
+ {
+ get => UseLight;
+ set
+ {
+ UseLight = value;
+ ChangeUseTime(UseLight);
+ }
+ }
+
+ /// Sets the singleton with a diagnostic log when a duplicate controller enters the tree first.
+ public override void _EnterTree()
+ {
+ if (Instance != null && GodotObject.IsInstanceValid(Instance))
+ {
+ var scene = GetTree()?.CurrentScene?.Name ?? "?";
+ var existingPath = Instance.GetPath().ToString();
+ var myPath = GetPath().ToString();
+ GameLogger.Log("DUPLICATE", $"UiLightController._EnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'", LogLevel.Error);
+ }
+ Instance = this;
+ }
+
+ /// Clears the singleton and detaches from the timer ticks when the controller leaves the tree.
+ public override void _ExitTree()
+ {
+ if (Instance == this)
+ Instance = null;
+ eventController?.RemoveTimerTickEventListener(this);
+ }
+
+ /// Initializes shader maps, parks the lamp offscreen and subscribes to timer ticks for time-of-day blending.
+ public override void _Ready()
+ {
+ MouseFilter = MouseFilterEnum.Ignore;
+
+ UpdateLampLightParams();
+ lightPositions[0] = new Vector2(-1000f, -1000f);
+
+ SetupDepthMap(DefaultDepthMap);
+ SetupAlbedoMap(DefaultAlbedoMap);
+
+ eventController = SceneContextNode.FindNode("EventController");
+ eventController?.AddTimerTickEventListener(this);
+
+ GameLogger.Log("LIGHT", $"LightsCtrl._Ready: eventCtrl={(eventController != null ? "OK" : "NULL")}, DarkMode={DarkMode}, UseLight={UseLight}", LogLevel.Debug);
+
+ CallDeferred(nameof(SetupTime));
+ }
+
+ ///
+ /// Reads lamp parameters from
+ /// and updates the player light slot (index 0) in the uniform arrays.
+ ///
+ public void UpdateLampLightParams()
+ {
+ var lightParams = LampBehaviour.GetLightParams();
+ lightColors[0] = lightParams.Item1;
+ lightRadii[0] = lightParams.Item2;
+ lightInfluence[0] = lightParams.Item3;
+ }
+
+ ///
+ /// Resets all static light slots (indices 1–10) to off-screen positions
+ /// with zero radius and influence, then pushes to shader materials.
+ ///
+ public void ClearStaticLights()
+ {
+ for (int i = 1; i < 11; i++)
+ {
+ lightPositions[i] = new Vector2(-1000f, -1000f);
+ lightRadii[i] = 0f;
+ lightInfluence[i] = 0f;
+ lightColors[i] = Colors.White;
+ }
+
+ staticLightCount = 0;
+ UpdateShaders();
+
+ GameLogger.Log("LIGHT", "LightsCtrl.ClearStaticLights: all static lights cleared");
+ }
+
+ ///
+ /// Deserializes a JSON array of objects and populates
+ /// static light slots 1–10. Supports up to 10 static lights.
+ ///
+ /// JSON string containing an array of objects.
+ public void SetStaticLights(string json)
+ {
+ ClearStaticLights();
+
+ if (string.IsNullOrEmpty(json))
+ return;
+
+ try
+ {
+ var lights = JsonSerializer.Deserialize>(json, JsonOptionsProvider.ReadOptions);
+
+ if (lights == null || lights.Count == 0)
+ return;
+
+ int count = Mathf.Min(lights.Count, 10);
+ var vpSize = GetViewport()?.GetVisibleRect().Size ?? new Vector2(1920, 1024);
+ for (int i = 0; i < count; i++)
+ {
+ lightPositions[i + 1] = lights[i].ToPixelPos(vpSize);
+ lightRadii[i + 1] = lights[i].Radius;
+ lightInfluence[i + 1] = lights[i].Influence;
+ lightColors[i + 1] = lights[i].ToColor();
+ }
+
+ staticLightCount = count+1;
+ UpdateShaders();
+
+ GameLogger.Log("LIGHT", $"LightsCtrl.SetStaticLights: parsed {lights.Count} light(s), applied {count} static lights");
+ }
+
+ catch (System.Exception ex)
+ {
+ GameLogger.Log("LIGHT", $"LightsCtrl.SetStaticLights: JSON parse error: {ex.Message}", LogLevel.Error);
+ }
+ }
+
+ private void SetupTime()
+ {
+ SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
+ }
+
+ ///
+ /// Timer-tick callback (~1/sec). Updates lamp visibility based on time of
+ /// day and recalculates time-of-day weights for shaders.
+ ///
+ public void OnTimerTick()
+ {
+ lampBehaviour ??= FindLampChild();
+
+ if (lampBehaviour != null)
+ {
+ var time = GameData.Instance?.Runtime?.Time;
+ var hours = time?.Hours ?? 0;
+ var newVis = DarkMode || !(hours >= 6 && hours < 22);
+ lampBehaviour.Visible = newVis;
+ }
+
+ SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
+ }
+
+ ///
+ /// Pushes the use_time shader parameter to all registered materials.
+ ///
+ /// The new use_time uniform value.
+ public void ChangeUseTime(bool value)
+ {
+ ForEachMaterial(m => m.SetShaderParameter("use_time", value));
+ }
+
+ ///
+ /// Adds a to receive lighting uniform updates
+ /// alongside the ViewBox background/foreground. Duplicate registrations
+ /// are silently ignored.
+ ///
+ /// The material to register for lighting updates.
+ public void RegisterMaterial(ShaderMaterial material)
+ {
+ if (material == null || extraMaterials.Contains(material))
+ return;
+ extraMaterials.Add(material);
+ PushAllParams(material);
+ }
+
+ ///
+ /// Removes a previously registered material from the lighting update list.
+ /// No-op if the material is not currently registered.
+ ///
+ /// The material to unregister.
+ public void UnregisterMaterial(ShaderMaterial material)
+ {
+ extraMaterials.Remove(material);
+ }
+
+ private void PushAllParams(ShaderMaterial material)
+ {
+ var weights = DarkMode
+ ? new Vector4(0, 0, 0, 1)
+ : CalculateTimeWeights(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
+ material.SetShaderParameter("time_weights", weights);
+ material.SetShaderParameter("use_time", UseLight);
+
+ var posArray = new Godot.Collections.Array();
+ var colArray = new Godot.Collections.Array();
+ var radArray = new Godot.Collections.Array();
+ var infArray = new Godot.Collections.Array();
+ for (int i = 0; i < 11; i++)
+ {
+ posArray.Add(lightPositions[i]);
+ colArray.Add(lightColors[i]);
+ radArray.Add(lightRadii[i]);
+ infArray.Add(lightInfluence[i]);
+ }
+
+ int count = 0;
+ if (lightPositions[0].X > -100)
+ count++;
+ count += staticLightCount;
+
+ material.SetShaderParameter("light_count", count);
+ material.SetShaderParameter("light_positions", posArray);
+ material.SetShaderParameter("light_colors", colArray);
+ material.SetShaderParameter("light_radii", radArray);
+ material.SetShaderParameter("light_influence", infArray);
+ material.SetShaderParameter("light_radius_scale", 1.0f);
+ }
+
+ ///
+ /// Sets the depth map texture and pushes it to all shader materials via
+ /// the depth_mask uniform.
+ ///
+ /// The new depth map texture.
+ public void SetupDepthMap(Texture2D depthMap)
+ {
+ CurrentDepthMap = depthMap;
+ ForEachMaterial(m => m.SetShaderParameter("depth_mask", CurrentDepthMap));
+ }
+
+ ///
+ /// Sets the albedo map texture and pushes it to all shader materials via
+ /// the albedo_map uniform.
+ ///
+ /// The new albedo texture.
+ public void SetupAlbedoMap(Texture2D albedoMap)
+ {
+ CurrentAlbedoMap = albedoMap;
+ ForEachMaterial(m => m.SetShaderParameter("albedo_map", CurrentAlbedoMap));
+ }
+
+ ///
+ /// Rebuilds and pushes all lighting uniform arrays to every registered
+ /// shader material.
+ ///
+ public void UpdateShaders()
+ {
+ int count = 0;
+ if (lightPositions[0].X > -100)
+ count++;
+ count += staticLightCount;
+
+ SetShaderArrays(count);
+ ChangeUseTime(UseLight);
+ }
+
+ ///
+ /// Updates the player lamp position (slot 0) and pushes to all shaders.
+ /// If the position is off-screen (X < -100), the lamp is excluded
+ /// from the active light count.
+ ///
+ /// New lamp position in screen-space pixels.
+ public void UpdateLightPos(Vector2 position)
+ {
+ lightPositions[0] = position;
+ int count = 0;
+ if (lightPositions[0].X > -100)
+ count++;
+ count += staticLightCount;
+
+ SetShaderArrays(count);
+ ChangeUseTime(UseLight);
+ }
+
+ ///
+ /// Directly sets the player light position, radius, and influence without
+ /// triggering a shader push. Call afterward.
+ ///
+ /// Screen-space pixel position.
+ /// Light radius in pixels.
+ /// Light influence factor (0.0–1.0).
+ public void SetPlayerLight(Vector2 position, float radius, float influence)
+ {
+ lightPositions[0] = position;
+ lightRadii[0] = radius;
+ lightInfluence[0] = influence;
+ }
+
+ ///
+ /// Calculates time-of-day blend weights and pushes to all shader materials.
+ /// Respects — when active, full-night weights are used
+ /// regardless of .
+ ///
+ /// Hour in 0–24 range (may be fractional).
+ public void SetTime(float normalizedHour)
+ {
+ if (DarkMode)
+ {
+ var night = new Vector4(0, 0, 0, 1);
+ ForEachMaterial(m => m.SetShaderParameter("time_weights", night));
+ }
+ else
+ {
+ var weights = CalculateTimeWeights(normalizedHour);
+ ForEachMaterial(m => m.SetShaderParameter("time_weights", weights));
+ }
+ UpdateShaders();
+ }
+
+ private void SetShaderArrays(int count)
+ {
+ var posArray = new Godot.Collections.Array();
+ var colArray = new Godot.Collections.Array();
+ var radArray = new Godot.Collections.Array();
+ var infArray = new Godot.Collections.Array();
+
+ for (int i = 0; i < 11; i++)
+ {
+ posArray.Add(lightPositions[i]);
+ colArray.Add(lightColors[i]);
+ radArray.Add(lightRadii[i]);
+ infArray.Add(lightInfluence[i]);
+ }
+
+ ForEachMaterial(m =>
+ {
+ m.SetShaderParameter("light_count", count);
+ m.SetShaderParameter("light_positions", posArray);
+ m.SetShaderParameter("light_colors", colArray);
+ m.SetShaderParameter("light_radii", radArray);
+ m.SetShaderParameter("light_influence", infArray);
+ m.SetShaderParameter("light_radius_scale", 1.0f);
+ });
+ }
+
+ private void ForEachMaterial(System.Action action)
+ {
+ if (viewBgMaterial == null || viewFgMaterial == null)
+ {
+ var viewBox = SceneContextNode.FindNode("ViewBox");
+ if (viewBox != null)
+ {
+ viewBgMaterial ??= viewBox.Background?.Material as ShaderMaterial;
+ viewFgMaterial ??= viewBox.Foreground?.Material as ShaderMaterial;
+ }
+ }
+
+ if (viewBgMaterial != null) action(viewBgMaterial);
+ if (viewFgMaterial != null) action(viewFgMaterial);
+
+ for (int i = extraMaterials.Count - 1; i >= 0; i--)
+ {
+ if (extraMaterials[i] == null)
+ extraMaterials.RemoveAt(i);
+ else
+ action(extraMaterials[i]);
+ }
+ }
+
+ private LampBehaviour FindLampChild()
+ {
+ return SceneContextNode.FindNode("Lamp");
+ }
+
+ //
+ // 06:00-10:00 - Morning
+ // 10:00-18:00 - Day
+ // 18:00-22:00 - Evening
+ // 22:00-06:00 - Night
+ //
+ private static Vector4 CalculateTimeWeights(float hour)
+ {
+ float night = 0.0f;
+ float morning = 0.0f;
+ float day = 0.0f;
+ float evening = 0.0f;
+
+ if (hour >= 22.0f || hour < 4.0f)
+ {
+ night = 1.0f;
+ }
+ else if (hour >= 4.0f && hour < 6.0f)
+ {
+ float t = (hour - 4.0f) / 2.0f;
+ night = 1.0f - t;
+ morning = t;
+ }
+ else if (hour >= 6.0f && hour < 9.0f)
+ {
+ morning = 1.0f;
+ }
+ else if (hour >= 9.0f && hour < 11.0f)
+ {
+ float t = (hour - 9.0f) / 2.0f;
+ morning = 1.0f - t;
+ day = t;
+ }
+ else if (hour >= 11.0f && hour < 17.0f)
+ {
+ day = 1.0f;
+ }
+ else if (hour >= 17.0f && hour < 19.0f)
+ {
+ float t = (hour - 17.0f) / 2.0f;
+ day = 1.0f - t;
+ evening = t;
+ }
+ else if (hour >= 19.0f && hour < 20.0f)
+ {
+ evening = 1.0f;
+ }
+ else if (hour >= 20.0f && hour < 22.0f)
+ {
+ float t = (hour - 20.0f) / 2.0f;
+ evening = 1.0f - t;
+ night = t;
+ }
+
+ return new Vector4(morning, day, evening, night);
+ }
+ }
+}
diff --git a/src/TimeController.cs b/src/TimeController.cs
index d85cfe9..61af5b6 100644
--- a/src/TimeController.cs
+++ b/src/TimeController.cs
@@ -5,8 +5,8 @@ using Godot;
namespace Cthangover.Mods.Time
{
///
- /// In-game clock: subscribes to SceneEventController timer ticks and advances
- /// GameData.Runtime.Time. Renders the time text on a Label and applies
+ /// In-game clock: subscribes to SceneEventController timer ticks and redraws
+ /// GameData.Runtime.Time (TimeTickController advances it). Renders the time text on a Label and applies
/// Godot.Engine.TimeScale from the exported timeScale field for global
/// time acceleration. The #if TOOLS block updates the rendered time in the
/// editor inspector when properties change, giving designers live time preview.
diff --git a/src/TimeTickController.cs b/src/TimeTickController.cs
index f99de33..6df4cd4 100644
--- a/src/TimeTickController.cs
+++ b/src/TimeTickController.cs
@@ -1,73 +1,73 @@
-using Cthangover.Core.Bootstrap;
-using Cthangover.Core.Settings;
-using Cthangover.Core.Events;
-using Cthangover.Core.Mods;
-using Cthangover.Core.UI.Event;
-using Godot;
-
-namespace Cthangover.Mods.Time
-{
- ///
- /// Bridges the scene-event timer system to the in-game clock.
- /// Implements
- /// with priority 0 so that the tick is processed before most
- /// other listeners. The controller must be explicitly registered
- /// via , which finds the
- /// in
- /// the scene tree and subscribes to it.
- /// Registration is idempotent — subsequent calls are no-ops.
- ///
- public class TimeTickController : IOnTimeEvent
- {
- /// Executes before default-priority listeners so time
- /// updates are visible to all other tick handlers.
- public int Priority => 0;
-
- private bool _registered;
-
- ///
- /// Locates the
- /// in the scene tree and registers this controller as a timer-tick
- /// listener. Safe to call multiple times — only the first call
- /// does work.
- ///
- public void EnsureRegistered(Node node)
- {
- if (_registered)
- return;
- var eventController = node.GetTree()?.Root?.FindChild("EventController", true, false) as SceneEventController;
- if (eventController == null)
- return;
- eventController.AddTimerTickEventListener(this);
- _registered = true;
- }
-
- ///
- /// Called by the event system on each timer pulse. Advances
- /// the in-game clock by one minute via
- /// .
- ///
- public void OnTimerTick()
- {
- // The SceneEventController keeps ticking (1s interval, ProcessMode
- // Always) while the launcher boot compiles mods in the background —
- // registration in GameData._Ready succeeds because the engine puts
- // every autoload into the tree before any _Ready fires. Touching
- // Runtime here would create it before the starter inventory can be
- // seeded through loaded mod factories: the seed then runs against an
- // empty registry, logs "Initialize refused" spam and permanently
- // skips the starter items. Skip boot-window ticks instead — the same
- // readiness contract GameData.Runtime enforces.
- if (!Boot.ModsReady)
- return;
-
- GameData.Instance.Runtime.Time.AddTick();
- ModEventBus.Publish(new TimeTickEvent
- {
- TimeTick = GameData.Instance.Runtime.Time.Tick,
- NormalizedTime = GameData.Instance.Runtime.Time.Normalized,
- });
- }
- }
-
-}
+using Cthangover.Core.Bootstrap;
+using Cthangover.Core.Settings;
+using Cthangover.Core.Events;
+using Cthangover.Core.Mods;
+using Cthangover.Core.UI.Event;
+using Godot;
+
+namespace Cthangover.Mods.Time
+{
+ ///
+ /// Bridges the scene-event timer system to the in-game clock.
+ /// Implements
+ /// with priority 0 so that the tick is processed before most
+ /// other listeners. The controller must be explicitly registered
+ /// via , which finds the
+ /// in
+ /// the scene tree and subscribes to it.
+ /// Registration is idempotent — subsequent calls are no-ops.
+ ///
+ public class TimeTickController : IOnTimeEvent
+ {
+ /// Executes before default-priority listeners so time
+ /// updates are visible to all other tick handlers.
+ public int Priority => 0;
+
+ private bool _registered;
+
+ ///
+ /// Locates the
+ /// in the scene tree and registers this controller as a timer-tick
+ /// listener. Safe to call multiple times — only the first call
+ /// does work.
+ ///
+ public void EnsureRegistered(Node node)
+ {
+ if (_registered)
+ return;
+ var eventController = node.GetTree()?.Root?.FindChild("EventController", true, false) as SceneEventController;
+ if (eventController == null)
+ return;
+ eventController.AddTimerTickEventListener(this);
+ _registered = true;
+ }
+
+ ///
+ /// Called by the event system on each timer pulse. Advances
+ /// the in-game clock by one minute via
+ /// .
+ ///
+ public void OnTimerTick()
+ {
+ // The SceneEventController keeps ticking (1s interval, ProcessMode
+ // Always) while the launcher boot compiles mods in the background —
+ // registration in GameData._Ready succeeds because the engine puts
+ // every autoload into the tree before any _Ready fires. Touching
+ // Runtime here would create it before the starter inventory can be
+ // seeded through loaded mod factories: the seed then runs against an
+ // empty registry, logs "Initialize refused" spam and permanently
+ // skips the starter items. Skip boot-window ticks instead — the same
+ // readiness contract GameData.Runtime enforces.
+ if (!Boot.ModsReady)
+ return;
+
+ GameData.Instance.Runtime.Time.AddTick();
+ ModEventBus.Publish(new TimeTickEvent
+ {
+ TimeTick = GameData.Instance.Runtime.Time.Tick,
+ NormalizedTime = GameData.Instance.Runtime.Time.Normalized,
+ });
+ }
+ }
+
+}