This commit is contained in:
iisaev committed 2026-10-18 12:41:41 +03:00
commit 575a068bf5
13 files changed
+1082

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
using Cthangover.Core.Actions;
namespace Cthangover.Mods.Time
{
/// <summary>
/// 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.
/// </summary>
public class LightingClearMapAction : IScenarioAction
{
/// <summary>
/// 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.
/// </summary>
public string Name => "lighting.clear_map";
/// <summary>
/// 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).
/// </summary>
public void Run(IActionContext ctx)
{
ctx.Lighting.ClearDepthMap();
ctx.Log("SHADER", "LightingClearMapAction: cleared depth and albedo maps");
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using Cthangover.Core.Actions;
namespace Cthangover.Mods.Time
{
/// <summary>
/// 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.
/// </summary>
public class LightingServiceImpl : ILightingService
{
/// <summary>
/// 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).
/// </summary>
public void ClearDepthMap()
{
var controller = UiLightController.Instance;
controller?.SetupDepthMap(null);
controller?.SetupAlbedoMap(null);
}
/// <summary>
/// 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.
/// </summary>
public void SetUseTime(bool useTime)
{
var controller = UiLightController.Instance;
if (controller != null)
controller.IsUseLight = useTime;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using Cthangover.Core.Actions;
using Cthangover.Core.Scenes;
using Godot;
namespace Cthangover.Mods.Time
{
/// <summary>
/// Static adapters bound into the kernel <see cref="LightingBridge"/> on
/// <c>OnModResourcesReady</c>. 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.
/// </summary>
public static class LightingHooks
{
/// <summary>Clears static lights and both lighting maps on the scene's controller; no-op when the scene has none.</summary>
public static void ClearSceneLights()
{
var lightController = SceneContextNode.FindNode<UiLightController>("Lights");
if (lightController == null)
return;
lightController.ClearStaticLights();
lightController.SetupDepthMap(null);
lightController.SetupAlbedoMap(null);
}
/// <summary>Pushes background depth/albedo textures onto the controller singleton; skipped when it is not in the tree.</summary>
public static void SetupBackgroundMaps(Texture2D depth, Texture2D albedo)
{
var controller = UiLightController.Instance;
controller?.SetupDepthMap(depth);
controller?.SetupAlbedoMap(albedo);
}
}
}
+147
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public partial class LampBehaviour : TextureRectModIconLoader
{
private UiLightController controller;
private Vector2 dragPosition;
private bool isPlaced;
private bool pressingOnLamp;
/// <summary>
/// Queries lamp parameters (color, radius, influence) from
/// <see cref="GameData.Instance.Runtime.LampData"/>. Static so the
/// <see cref="UiLightController"/> can query without a direct reference.
/// </summary>
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);
}
/// <summary>
/// Visibility toggle for the lamp. Setting to <c>false</c> resets the
/// placed state and sends the light position offscreen to disable
/// the lamp light in the shader. Uses <c>new</c> to log transitions.
/// </summary>
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));
}
}
}
}
/// <summary>Finds the shared <see cref="UiLightController"/> this lamp pushes positions to.</summary>
public override void _Ready()
{
base._Ready();
MouseFilter = MouseFilterEnum.Stop;
controller = SceneContextNode.FindNode<UiLightController>("Lights");
GameLogger.Log("LIGHT", $"Lamp._Ready: controller={(controller != null ? "OK" : "NULL")}", LogLevel.Debug);
}
/// <summary>Click to place the light at the cursor, drag to move it, and click a placed lamp to pick it back up.</summary>
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();
}
}
}
/// <summary>Keeps dragging the placed light while the pointer moves outside the lamp's own control rect.</summary>
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;
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using System.Text.Json.Serialization;
using Godot;
namespace Cthangover.Mods.Time
{
/// <summary>
/// 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.
/// </summary>
public class LightDef
{
/// <summary>
/// Normalized horizontal position (0.0–1.0) relative to viewport width.
/// </summary>
[JsonPropertyName("x")]
public float X { get; set; }
/// <summary>
/// Normalized vertical position (0.0–1.0) relative to viewport height.
/// </summary>
[JsonPropertyName("y")]
public float Y { get; set; }
/// <summary>
/// Light radius in pixel units. Determines the falloff distance in the
/// lighting shader.
/// </summary>
[JsonPropertyName("radius")]
public float Radius { get; set; }
/// <summary>
/// Light intensity (0.0–1.0). At <c>1.0</c>, the light fully brightens
/// pixels within its radius. Lower values create subtler effects.
/// </summary>
[JsonPropertyName("influence")]
public float Influence { get; set; } = 1f;
/// <summary>
/// Light color as a hex string (e.g. "#ff8800"). Parsed by
/// <see cref="ToColor"/> for shader upload.
/// </summary>
[JsonPropertyName("color")]
public string ColorHex { get; set; } = "#ffff00";
/// <summary>
/// Converts <see cref="ColorHex"/> to a Godot <c>Color</c> struct.
/// </summary>
public Color ToColor()
{
return new Color(ColorHex);
}
/// <summary>
/// Converts normalized coordinates (<see cref="X"/>, <see cref="Y"/>) to
/// pixel coordinates based on <paramref name="viewportSize"/>.
/// </summary>
/// <param name="viewportSize">Current viewport dimensions in pixels.</param>
public Vector2 ToPixelPos(Vector2 viewportSize)
{
return new Vector2(X * viewportSize.X, Y * viewportSize.Y);
}
/// <summary>
/// Computes normalized coordinates from pixel coordinates by dividing by
/// <paramref name="referenceSize"/>. Enables saving light positions
/// across different resolutions.
/// </summary>
/// <param name="pixelPos">Light position in pixel space.</param>
/// <param name="referenceSize">Viewport dimensions used as reference.</param>
public void FromPixelPos(Vector2 pixelPos, Vector2 referenceSize)
{
X = pixelPos.X / referenceSize.X;
Y = pixelPos.Y / referenceSize.Y;
}
}
}
+519
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public partial class UiLightController : Control, IOnTimeEvent
{
/// <summary>
/// Singleton accessor for the active controller. Set in <see cref="_EnterTree"/>,
/// cleared in <see cref="_ExitTree"/>. Duplicate instances log an error but
/// overwrite the reference.
/// </summary>
public static UiLightController Instance { get; private set; }
/// <summary>
/// Default depth map texture applied on startup for parallax lighting effects.
/// </summary>
[Export] public Texture2D DefaultDepthMap { get; set; }
/// <summary>
/// Default albedo (color) texture applied on startup for lighting blending.
/// </summary>
[Export] public Texture2D DefaultAlbedoMap { get; set; }
/// <summary>
/// When <c>true</c>, forces full-night time-of-day weights regardless of
/// in-game time. Set from game settings or scenario commands.
/// </summary>
[Export] public bool DarkMode { get; set; }
/// <summary>
/// Master toggle for time-of-day lighting. When <c>false</c>, time blending
/// is disabled on all shader materials.
/// </summary>
[Export] public bool UseLight { get; set; } = true;
private int staticLightCount;
/// <summary>
/// The currently active depth map texture, pushed to all shader materials.
/// </summary>
public Texture2D CurrentDepthMap { get; private set; }
/// <summary>
/// The currently active albedo texture, pushed to all shader materials.
/// </summary>
public Texture2D CurrentAlbedoMap { get; private set; }
private SceneEventController eventController;
private ShaderMaterial viewBgMaterial;
private ShaderMaterial viewFgMaterial;
private readonly List<ShaderMaterial> 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;
/// <summary>
/// Priority <c>0</c> — the lighting controller runs before most other timer-tick
/// subscribers to ensure shader params are updated first.
/// </summary>
public int Priority => 0;
/// <summary>
/// Runtime dark mode toggle. Updates <see cref="DarkMode"/> and immediately
/// recalculates time-of-day weights.
/// </summary>
public bool IsDarkMode
{
get => DarkMode;
set
{
DarkMode = value;
SetTime(GameData.Instance?.Runtime?.Time?.Normalized ?? 0f);
}
}
/// <summary>
/// Runtime toggle for time-of-day blending. Pushes the new value to all
/// registered shader materials.
/// </summary>
public bool IsUseLight
{
get => UseLight;
set
{
UseLight = value;
ChangeUseTime(UseLight);
}
}
/// <summary>Sets the singleton with a diagnostic log when a duplicate controller enters the tree first.</summary>
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;
}
/// <summary>Clears the singleton and detaches from the timer ticks when the controller leaves the tree.</summary>
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
eventController?.RemoveTimerTickEventListener(this);
}
/// <summary>Initializes shader maps, parks the lamp offscreen and subscribes to timer ticks for time-of-day blending.</summary>
public override void _Ready()
{
MouseFilter = MouseFilterEnum.Ignore;
UpdateLampLightParams();
lightPositions[0] = new Vector2(-1000f, -1000f);
SetupDepthMap(DefaultDepthMap);
SetupAlbedoMap(DefaultAlbedoMap);
eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddTimerTickEventListener(this);
GameLogger.Log("LIGHT", $"LightsCtrl._Ready: eventCtrl={(eventController != null ? "OK" : "NULL")}, DarkMode={DarkMode}, UseLight={UseLight}", LogLevel.Debug);
CallDeferred(nameof(SetupTime));
}
/// <summary>
/// Reads lamp parameters from <see cref="LampBehaviour.GetLightParams"/>
/// and updates the player light slot (index 0) in the uniform arrays.
/// </summary>
public void UpdateLampLightParams()
{
var lightParams = LampBehaviour.GetLightParams();
lightColors[0] = lightParams.Item1;
lightRadii[0] = lightParams.Item2;
lightInfluence[0] = lightParams.Item3;
}
/// <summary>
/// Resets all static light slots (indices 1–10) to off-screen positions
/// with zero radius and influence, then pushes to shader materials.
/// </summary>
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");
}
/// <summary>
/// Deserializes a JSON array of <see cref="LightDef"/> objects and populates
/// static light slots 1–10. Supports up to 10 static lights.
/// </summary>
/// <param name="json">JSON string containing an array of <see cref="LightDef"/> objects.</param>
public void SetStaticLights(string json)
{
ClearStaticLights();
if (string.IsNullOrEmpty(json))
return;
try
{
var lights = JsonSerializer.Deserialize<List<LightDef>>(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);
}
/// <summary>
/// Timer-tick callback (~1/sec). Updates lamp visibility based on time of
/// day and recalculates time-of-day weights for shaders.
/// </summary>
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);
}
/// <summary>
/// Pushes the <c>use_time</c> shader parameter to all registered materials.
/// </summary>
/// <param name="value">The new <c>use_time</c> uniform value.</param>
public void ChangeUseTime(bool value)
{
ForEachMaterial(m => m.SetShaderParameter("use_time", value));
}
/// <summary>
/// Adds a <see cref="ShaderMaterial"/> to receive lighting uniform updates
/// alongside the ViewBox background/foreground. Duplicate registrations
/// are silently ignored.
/// </summary>
/// <param name="material">The material to register for lighting updates.</param>
public void RegisterMaterial(ShaderMaterial material)
{
if (material == null || extraMaterials.Contains(material))
return;
extraMaterials.Add(material);
PushAllParams(material);
}
/// <summary>
/// Removes a previously registered material from the lighting update list.
/// No-op if the material is not currently registered.
/// </summary>
/// <param name="material">The material to unregister.</param>
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<Vector2>();
var colArray = new Godot.Collections.Array<Color>();
var radArray = new Godot.Collections.Array<float>();
var infArray = new Godot.Collections.Array<float>();
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);
}
/// <summary>
/// Sets the depth map texture and pushes it to all shader materials via
/// the <c>depth_mask</c> uniform.
/// </summary>
/// <param name="depthMap">The new depth map texture.</param>
public void SetupDepthMap(Texture2D depthMap)
{
CurrentDepthMap = depthMap;
ForEachMaterial(m => m.SetShaderParameter("depth_mask", CurrentDepthMap));
}
/// <summary>
/// Sets the albedo map texture and pushes it to all shader materials via
/// the <c>albedo_map</c> uniform.
/// </summary>
/// <param name="albedoMap">The new albedo texture.</param>
public void SetupAlbedoMap(Texture2D albedoMap)
{
CurrentAlbedoMap = albedoMap;
ForEachMaterial(m => m.SetShaderParameter("albedo_map", CurrentAlbedoMap));
}
/// <summary>
/// Rebuilds and pushes all lighting uniform arrays to every registered
/// shader material.
/// </summary>
public void UpdateShaders()
{
int count = 0;
if (lightPositions[0].X > -100)
count++;
count += staticLightCount;
SetShaderArrays(count);
ChangeUseTime(UseLight);
}
/// <summary>
/// Updates the player lamp position (slot 0) and pushes to all shaders.
/// If the position is off-screen (X &lt; -100), the lamp is excluded
/// from the active light count.
/// </summary>
/// <param name="position">New lamp position in screen-space pixels.</param>
public void UpdateLightPos(Vector2 position)
{
lightPositions[0] = position;
int count = 0;
if (lightPositions[0].X > -100)
count++;
count += staticLightCount;
SetShaderArrays(count);
ChangeUseTime(UseLight);
}
/// <summary>
/// Directly sets the player light position, radius, and influence without
/// triggering a shader push. Call <see cref="UpdateShaders"/> afterward.
/// </summary>
/// <param name="position">Screen-space pixel position.</param>
/// <param name="radius">Light radius in pixels.</param>
/// <param name="influence">Light influence factor (0.0–1.0).</param>
public void SetPlayerLight(Vector2 position, float radius, float influence)
{
lightPositions[0] = position;
lightRadii[0] = radius;
lightInfluence[0] = influence;
}
/// <summary>
/// Calculates time-of-day blend weights and pushes to all shader materials.
/// Respects <see cref="DarkMode"/> — when active, full-night weights are used
/// regardless of <paramref name="normalizedHour"/>.
/// </summary>
/// <param name="normalizedHour">Hour in 0–24 range (may be fractional).</param>
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<Vector2>();
var colArray = new Godot.Collections.Array<Color>();
var radArray = new Godot.Collections.Array<float>();
var infArray = new Godot.Collections.Array<float>();
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<ShaderMaterial> action)
{
if (viewBgMaterial == null || viewFgMaterial == null)
{
var viewBox = SceneContextNode.FindNode<ViewBox>("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<LampBehaviour>("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);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Cthangover.Mods.Time
{
/// <summary>
/// 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.
/// </summary>
public static class SubsystemRegistration
{
public static string SubsystemId => "time";
}
}
+78
View File
@@ -0,0 +1,78 @@
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Event;
using Godot;
namespace Cthangover.Mods.Time
{
/// <summary>
/// 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.
/// </summary>
public partial class TimeController : Control, IOnTimeEvent
{
[Export] private float timeScale = 1f;
private Label timerField;
private SceneEventController eventController;
private PhaseType lastPhase;
/// <summary>Finds the clock label, subscribes to timer ticks and renders the initial time.</summary>
public override void _Ready()
{
timerField = GetNodeOrNull<Label>("TimeLabel");
eventController = GetTree()?.Root?.GetNodeOrNull<SceneEventController>("EventController")
?? GetNodeOrNull<SceneEventController>("/root/EventController");
eventController?.AddTimerTickEventListener(this);
OnTimerTick();
}
/// <summary>Detaches from the timer ticks when the clock leaves the scene.</summary>
public override void _ExitTree()
{
eventController?.RemoveTimerTickEventListener(this);
}
/// <summary>
/// Priority in the <see cref="SceneEventController"/> listener chain. Returns 0 (default).
/// </summary>
public int Priority => 0;
/// <summary>
/// Called by <see cref="SceneEventController"/> on each timer tick. Delegates to
/// <see cref="UpdateRenderedTime"/> to refresh the on-screen clock label.
/// </summary>
public void OnTimerTick()
{
UpdateRenderedTime();
}
/// <summary>
/// Applies <c>timeScale</c> to <c>Engine.TimeScale</c> (global time speed), reads the
/// current in-game time from <see cref="GameData.Runtime.Time"/>, and writes its text
/// representation to the <c>TimeLabel</c> node.
/// </summary>
public void UpdateRenderedTime()
{
Godot.Engine.TimeScale = timeScale;
var time = GameData.Instance.Runtime.Time;
var text = time?.Text ?? string.Empty;
if (timerField != null)
timerField.Text = text;
}
#if TOOLS
/// <summary>Refreshes the live time preview whenever an exported property changes in the editor inspector.</summary>
public override void _ValidateProperty(Godot.Collections.Dictionary property)
{
base._ValidateProperty(property);
OnTimerTick();
}
#endif
}
}
+42
View File
@@ -0,0 +1,42 @@
using Cthangover.Core.Actions;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Time
{
/// <summary>
/// Wires the time subsystem into the kernel. On
/// <c>OnModResourcesReady</c> it installs the lighting adapters into
/// <see cref="LightingBridge"/> (first registration wins) and registers
/// the <see cref="TimeTickController"/> with the scene event system, so
/// in-game time advances each timer pulse. The controller lives in a
/// static field: <c>EnsureRegistered</c> is idempotent per instance, and
/// a process-wide singleton guarantees a mod reload cannot stack a second
/// timer-tick subscription onto <c>EventController</c>.
/// </summary>
public class TimeModInitializer : IModInitializer
{
private static readonly TimeTickController TickController = new();
/// <summary>No per-mod work: all wiring happens once the whole mod set is ready.</summary>
public void OnModLoaded(string modId)
{
}
/// <summary>Installs the lighting bridge adapters and subscribes the clock tick.</summary>
public void OnModResourcesReady()
{
LightingBridge.Register(LightingHooks.ClearSceneLights, LightingHooks.SetupBackgroundMaps);
var root = (Godot.Engine.GetMainLoop() as SceneTree)?.Root;
if (root == null)
{
GameLogger.Log("TIME", "TimeModInitializer: no scene tree available; time tick registration skipped", LogLevel.Warning);
return;
}
TickController.EnsureRegistered(root);
}
}
}
+73
View File
@@ -0,0 +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
{
/// <summary>
/// Bridges the scene-event timer system to the in-game clock.
/// Implements <see cref="Cthangover.Core.UI.Event.IOnTimeEvent"/>
/// with priority 0 so that the tick is processed before most
/// other listeners. The controller must be explicitly registered
/// via <see cref="EnsureRegistered"/>, which finds the
/// <see cref="Cthangover.Core.UI.Event.SceneEventController"/> in
/// the scene tree and subscribes <see cref="OnTimerTick"/> to it.
/// Registration is idempotent — subsequent calls are no-ops.
/// </summary>
public class TimeTickController : IOnTimeEvent
{
/// <summary>Executes before default-priority listeners so time
/// updates are visible to all other tick handlers.</summary>
public int Priority => 0;
private bool _registered;
/// <summary>
/// Locates the <see cref="Cthangover.Core.UI.Event.SceneEventController"/>
/// in the scene tree and registers this controller as a timer-tick
/// listener. Safe to call multiple times — only the first call
/// does work.
/// </summary>
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;
}
/// <summary>
/// Called by the event system on each timer pulse. Advances
/// the in-game clock by one minute via
/// <see cref="TimeData.AddTick"/>.
/// </summary>
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,
});
}
}
}