diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/.gitignore b/.gitignore index bb88804..c301ca8 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 c56d858..82bf188 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 02f8f39..e5177c5 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 526d3fe..3c130b1 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/TimeTickController.cs b/src/TimeTickController.cs index 6df4cd4..f99de33 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, + }); + } + } + +}