From 575a068bf54775f0c2f5d0ecadd0d7c086513ede Mon Sep 17 00:00:00 2001 From: iisaev Date: Sun, 18 Oct 2026 12:41:41 +0300 Subject: [PATCH] init mod --- locale/en.properties | 2 + locale/ru.properties | 2 + manifest.json | 13 + src/Actions/LightingClearMapAction.cs | 37 ++ src/Actions/LightingServiceImpl.cs | 41 ++ src/LightingHooks.cs | 37 ++ src/Lights/LampBehaviour.cs | 147 ++++++++ src/Lights/LightDef.cs | 79 ++++ src/Lights/UiLightController.cs | 519 ++++++++++++++++++++++++++ src/SubsystemRegistration.cs | 12 + src/TimeController.cs | 78 ++++ src/TimeModInitializer.cs | 42 +++ src/TimeTickController.cs | 73 ++++ 13 files changed, 1082 insertions(+) create mode 100644 locale/en.properties create mode 100644 locale/ru.properties create mode 100644 manifest.json create mode 100644 src/Actions/LightingClearMapAction.cs create mode 100644 src/Actions/LightingServiceImpl.cs create mode 100644 src/LightingHooks.cs create mode 100644 src/Lights/LampBehaviour.cs create mode 100644 src/Lights/LightDef.cs create mode 100644 src/Lights/UiLightController.cs create mode 100644 src/SubsystemRegistration.cs create mode 100644 src/TimeController.cs create mode 100644 src/TimeModInitializer.cs create mode 100644 src/TimeTickController.cs diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..1255743 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_time_base_name = Time Engine +mod_time_base_desc = Engine time subsystem: the in-game clock tick controller, the clock HUD widget, the 2D lighting stack (UiLightController, LampBehaviour, LightDef) and the lighting.clear_map scenario action. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..1ee3d10 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_time_base_name = Движок времени +mod_time_base_desc = Подсистема игрового времени: контроллер тиков часов, HUD-виджет часов, стек 2D-освещения (UiLightController, LampBehaviour, LightDef) и сценарное действие lighting.clear_map. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..54acb0d --- /dev/null +++ b/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "time_base", + "name": "Time Engine", + "version": "1.0.0", + "author": "ct", + "description": "Engine time subsystem: the in-game clock tick controller bridging SceneEventController timer pulses to RuntimeData.Time, the clock HUD widget, the 2D lighting stack (UiLightController, LampBehaviour, LightDef) installed into the kernel through LightingBridge, and the lighting.clear_map scenario action. Replaces the former Core/Settings/TimeTickController, Core/UI/Lights, Core/UI/Tool/TimeController and Core/Actions/LightingServiceImpl.", + "category": "engine, library", + "sources": [ + "src/**/*.cs" + ], + "depends": ["interface>=1"], + "ui": [] +} diff --git a/src/Actions/LightingClearMapAction.cs b/src/Actions/LightingClearMapAction.cs new file mode 100644 index 0000000..df080d9 --- /dev/null +++ b/src/Actions/LightingClearMapAction.cs @@ -0,0 +1,37 @@ +using Cthangover.Core.Actions; + +namespace Cthangover.Mods.Time +{ + /// + /// Resets the lighting depth and albedo maps to null via the lighting + /// service. This effectively disables scene-specific lighting masks, + /// reverting to default flat lighting. Used when transitioning between + /// scenes that have different lighting setups or when entering a scene + /// that doesn't use depth-based lighting. + /// + public class LightingClearMapAction : IScenarioAction + { + /// + /// Registered as "lighting.clear_map" — resets the depth and albedo + /// lighting maps to null via UiLightController. Both maps are + /// cleared simultaneously because the lighting shader requires + /// either both present or neither — partial clearing produces + /// visual artifacts. Use when transitioning between scenes that + /// have different lighting setups, or when entering a scene that + /// doesn't use depth-based lighting. + /// + public string Name => "lighting.clear_map"; + + /// + /// Delegates to ctx.Lighting.ClearDepthMap which nulls out both + /// depth and albedo textures on the UiLightController singleton. + /// Safe to call when the controller hasn't been initialized + /// (null-conditional access in the implementation). + /// + public void Run(IActionContext ctx) + { + ctx.Lighting.ClearDepthMap(); + ctx.Log("SHADER", "LightingClearMapAction: cleared depth and albedo maps"); + } + } +} diff --git a/src/Actions/LightingServiceImpl.cs b/src/Actions/LightingServiceImpl.cs new file mode 100644 index 0000000..8eb314b --- /dev/null +++ b/src/Actions/LightingServiceImpl.cs @@ -0,0 +1,41 @@ +using Cthangover.Core.Actions; + +namespace Cthangover.Mods.Time +{ + /// + /// Thin wrapper around UiLightController.Instance. ClearDepthMap sets both + /// depth and albedo to null simultaneously — partial clearing would leave + /// the shader in an inconsistent state. SetUseTime delegates to the + /// controller's IsUseLight property. + /// + public class LightingServiceImpl : ILightingService + { + /// + /// Sets both depth and albedo maps to null on UiLightController. + /// Both are cleared together because the lighting shader requires + /// either both textures present or neither — partial clearing + /// produces visual artifacts. Safe to call when the controller + /// hasn't been initialized yet (null-conditional access). + /// + public void ClearDepthMap() + { + var controller = UiLightController.Instance; + controller?.SetupDepthMap(null); + controller?.SetupAlbedoMap(null); + } + + /// + /// Toggles the IsUseLight property on UiLightController. When + /// true, the controller applies time-of-day lighting adjustments; + /// when false, lighting remains static. Null-safe: if the + /// controller singleton is absent (pre-initialization), the call + /// is silently skipped. + /// + public void SetUseTime(bool useTime) + { + var controller = UiLightController.Instance; + if (controller != null) + controller.IsUseLight = useTime; + } + } +} diff --git a/src/LightingHooks.cs b/src/LightingHooks.cs new file mode 100644 index 0000000..6995b96 --- /dev/null +++ b/src/LightingHooks.cs @@ -0,0 +1,37 @@ +using Cthangover.Core.Actions; +using Cthangover.Core.Scenes; +using Godot; + +namespace Cthangover.Mods.Time +{ + /// + /// Static adapters bound into the kernel on + /// OnModResourcesReady. They reproduce the two lighting touch + /// points the kernel SceneManager used to own directly: resetting the + /// per-scene static lights and lighting maps when a scene switches, and + /// pushing background depth/albedo textures onto the scene controller. + /// Both degrade to no-ops when the lighting controller is absent, so the + /// calls stay safe in scenes without a "Lights" node. + /// + public static class LightingHooks + { + /// Clears static lights and both lighting maps on the scene's controller; no-op when the scene has none. + public static void ClearSceneLights() + { + var lightController = SceneContextNode.FindNode("Lights"); + if (lightController == null) + return; + lightController.ClearStaticLights(); + lightController.SetupDepthMap(null); + lightController.SetupAlbedoMap(null); + } + + /// Pushes background depth/albedo textures onto the controller singleton; skipped when it is not in the tree. + public static void SetupBackgroundMaps(Texture2D depth, Texture2D albedo) + { + var controller = UiLightController.Instance; + controller?.SetupDepthMap(depth); + controller?.SetupAlbedoMap(albedo); + } + } +} diff --git a/src/Lights/LampBehaviour.cs b/src/Lights/LampBehaviour.cs new file mode 100644 index 0000000..82bf188 --- /dev/null +++ b/src/Lights/LampBehaviour.cs @@ -0,0 +1,147 @@ +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; + } + } + } +} diff --git a/src/Lights/LightDef.cs b/src/Lights/LightDef.cs new file mode 100644 index 0000000..e5177c5 --- /dev/null +++ b/src/Lights/LightDef.cs @@ -0,0 +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; + } + } +} diff --git a/src/Lights/UiLightController.cs b/src/Lights/UiLightController.cs new file mode 100644 index 0000000..3c130b1 --- /dev/null +++ b/src/Lights/UiLightController.cs @@ -0,0 +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); + } + } +} diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..de54126 --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +1,12 @@ +namespace Cthangover.Mods.Time +{ + /// + /// Marks the time engine mod for subsystem coverage discovery: the + /// coverage test scans loaded assemblies for this marker and requires a + /// declared integration test for the subsystem. + /// + public static class SubsystemRegistration + { + public static string SubsystemId => "time"; + } +} diff --git a/src/TimeController.cs b/src/TimeController.cs new file mode 100644 index 0000000..d85cfe9 --- /dev/null +++ b/src/TimeController.cs @@ -0,0 +1,78 @@ +using Cthangover.Core.Settings; +using Cthangover.Core.UI.Event; +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 + /// 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. + /// + public partial class TimeController : Control, IOnTimeEvent + { + + [Export] private float timeScale = 1f; + private Label timerField; + private SceneEventController eventController; + + private PhaseType lastPhase; + + /// Finds the clock label, subscribes to timer ticks and renders the initial time. + public override void _Ready() + { + timerField = GetNodeOrNull