using UnityEngine; namespace Ashwild.Environment { /// /// Drives the day/night cycle for the "GAME/StylizedSky" skybox. Rotates the sun light and /// pushes every time-dependent value (gradient colors, sun/moon direction, cloud params, /// star opacity) into shader globals — mirroring how SeasonManager broadcasts "_Season". /// The skybox shader itself is dumb: it only renders whatever globals this manager writes. /// Runs in edit mode (ExecuteAlways) so the sky updates live while scrubbing the time slider. /// Purely visual and local; when co-op time-sync is added, only the driving time value needs /// to come from the server (see the "not yet networked" note in CLAUDE.md, like SeasonManager). /// [ExecuteAlways] [DisallowMultipleComponent] [AddComponentMenu("GAME/Environment/Time Of Day Manager")] public class TimeOfDayManager : MonoBehaviour { #region Singleton public static TimeOfDayManager Instance { get; private set; } #endregion #region Serialized Fields [Header("References")] [Tooltip("Directional light rotated to act as the sun. Optional but recommended.")] [SerializeField] private Light sunLight; [Tooltip("Skybox material using GAME/StylizedSky. Assigned to RenderSettings on enable when set.")] [SerializeField] private Material skyboxMaterial; [Header("Time")] [Tooltip("0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset.")] [Range(0f, 1f)] [SerializeField] private float timeOfDay = 0.5f; [SerializeField] private bool autoAdvance = true; [Tooltip("Real seconds for one full 24h cycle. Only advances in Play.")] [SerializeField] private float dayLengthSeconds = 300f; [Tooltip("Compass offset of the sun's arc, in degrees.")] [Range(0f, 360f)] [SerializeField] private float sunYaw = 20f; [Header("Sun Light")] [SerializeField] private Gradient sunColorOverDay = new Gradient(); [SerializeField] private AnimationCurve sunIntensityOverDay = AnimationCurve.Constant(0f, 1f, 1f); [Tooltip("Peak directional-light intensity reached around noon.")] [SerializeField] private float maxSunIntensity = 1.3f; [Header("Sky Gradient")] [SerializeField] private Gradient zenithColorOverDay = new Gradient(); [SerializeField] private Gradient horizonColorOverDay = new Gradient(); [SerializeField] private Gradient groundColorOverDay = new Gradient(); [Header("Moon")] [SerializeField] private Color moonColor = new Color(0.85f, 0.88f, 0.96f); [Header("Clouds")] [SerializeField] private Gradient cloudColorOverDay = new Gradient(); [SerializeField] private Gradient cloudShadowColorOverDay = new Gradient(); [Tooltip("Coverage threshold over the day. Higher = fewer clouds.")] [SerializeField] private AnimationCurve cloudCoverageOverDay = AnimationCurve.Constant(0f, 1f, 0.48f); [Range(0f, 1f)] [SerializeField] private float cloudOpacity = 1f; [SerializeField] private Vector2 windDirection = new Vector2(1f, 0.35f); [SerializeField] private float windSpeed = 0.01f; [Header("Stars")] [Tooltip("Sun elevation at which stars start fading in (upper) and are fully visible (lower).")] [SerializeField] private float starFadeInElevation = 0.05f; [SerializeField] private float starFullElevation = -0.12f; #endregion #region State private Vector2 cloudOffset; #endregion #region Shader Property IDs private static readonly int ZenithColorId = Shader.PropertyToID("_SkyZenithColor"); private static readonly int HorizonColorId = Shader.PropertyToID("_SkyHorizonColor"); private static readonly int GroundColorId = Shader.PropertyToID("_SkyGroundColor"); private static readonly int SunDirectionId = Shader.PropertyToID("_SkySunDirection"); private static readonly int SunColorId = Shader.PropertyToID("_SkySunColor"); private static readonly int MoonDirectionId = Shader.PropertyToID("_SkyMoonDirection"); private static readonly int MoonColorId = Shader.PropertyToID("_SkyMoonColor"); private static readonly int CloudColorId = Shader.PropertyToID("_SkyCloudColor"); private static readonly int CloudShadowColorId = Shader.PropertyToID("_SkyCloudShadowColor"); private static readonly int CloudParamsId = Shader.PropertyToID("_SkyCloudParams"); private static readonly int StarOpacityId = Shader.PropertyToID("_SkyStarOpacity"); #endregion #region Unity Lifecycle /// /// Registers the instance, assigns the skybox and pushes an initial state so the sky is /// correct the moment the component becomes active (including in the editor). /// private void OnEnable() { Instance = this; if (skyboxMaterial != null) RenderSettings.skybox = skyboxMaterial; Apply(); } /// /// Clears the shared instance so a disabled manager never keeps answering as the current one. /// private void OnDisable() { if (Instance == this) Instance = null; } /// /// Re-clamps tuning and refreshes the sky whenever a value is edited in the inspector. /// private void OnValidate() { dayLengthSeconds = Mathf.Max(1f, dayLengthSeconds); Apply(); } /// /// Advances time (Play only) and scrolls the clouds, then re-applies every frame. /// private void Update() { if (Application.isPlaying && autoAdvance) timeOfDay = Mathf.Repeat(timeOfDay + Time.deltaTime / dayLengthSeconds, 1f); cloudOffset += windDirection.normalized * (windSpeed * Time.deltaTime); Apply(); } #endregion #region Apply /// /// Resolves the current time of day into sun orientation and shader globals, and mirrors /// the resolved sun color/intensity onto the directional light so scene lighting matches /// the sky. This is the single place every time-dependent value is broadcast. /// private void Apply() { Vector3 sunDir = OrientSun(); Vector3 moonDir = -sunDir; Color sunColor = sunColorOverDay.Evaluate(timeOfDay); DriveSunLight(sunColor); Shader.SetGlobalColor(ZenithColorId, ToRendered(zenithColorOverDay.Evaluate(timeOfDay))); Shader.SetGlobalColor(HorizonColorId, ToRendered(horizonColorOverDay.Evaluate(timeOfDay))); Shader.SetGlobalColor(GroundColorId, ToRendered(groundColorOverDay.Evaluate(timeOfDay))); Shader.SetGlobalVector(SunDirectionId, sunDir); Shader.SetGlobalColor(SunColorId, ToRendered(sunColor)); Shader.SetGlobalVector(MoonDirectionId, moonDir); Shader.SetGlobalColor(MoonColorId, ToRendered(moonColor)); Shader.SetGlobalColor(CloudColorId, ToRendered(cloudColorOverDay.Evaluate(timeOfDay))); Shader.SetGlobalColor(CloudShadowColorId, ToRendered(cloudShadowColorOverDay.Evaluate(timeOfDay))); Shader.SetGlobalVector(CloudParamsId, new Vector4( cloudCoverageOverDay.Evaluate(timeOfDay), cloudOpacity, cloudOffset.x, cloudOffset.y)); Shader.SetGlobalFloat(StarOpacityId, Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(starFadeInElevation, starFullElevation, sunDir.y))); RepaintEditor(); } /// /// Rotates the sun light so noon sits overhead and sunrise/sunset sit on the horizon, /// and returns the world-space direction pointing toward the sun (what the shader wants). /// Works from the current time even when no light is assigned. /// private Vector3 OrientSun() { float pitch = timeOfDay * 360f - 90f; Quaternion rotation = Quaternion.Euler(pitch, sunYaw, 0f); if (sunLight != null) sunLight.transform.rotation = rotation; return -(rotation * Vector3.forward); } /// /// Tints the directional light and fades its intensity to zero at night so the world /// darkens together with the sky. No-op when no light is wired up. /// private void DriveSunLight(Color sunColor) { if (sunLight == null) return; sunLight.color = sunColor; sunLight.intensity = sunIntensityOverDay.Evaluate(timeOfDay) * maxSunIntensity; } /// /// Converts an authored sRGB color to the value the shader must receive. Colors pushed via /// Shader.SetGlobalColor are NOT auto-gamma-corrected (unlike material properties), so in a /// linear project they must be pre-converted or the whole sky washes out to white. /// private static Color ToRendered(Color c) { return QualitySettings.activeColorSpace == ColorSpace.Linear ? c.linear : c; } /// /// Forces the Scene view to redraw while scrubbing values outside Play, matching SeasonManager. /// private void RepaintEditor() { #if UNITY_EDITOR if (!Application.isPlaying) UnityEditor.SceneView.RepaintAll(); #endif } #endregion #region Public API /// /// Current normalized time of day (0 = midnight, 0.5 = noon). /// public float TimeOfDay => timeOfDay; /// /// Sets the time of day directly (wrapped into [0,1)) and refreshes the sky immediately. /// public void SetTimeOfDay(float normalized) { timeOfDay = Mathf.Repeat(normalized, 1f); Apply(); } #endregion #region Defaults /// /// Seeds all gradients/curves with a bright, soft daytime palette matched to the Raygeas /// stylized skybox reference, so a freshly added manager looks right without hand-authoring. /// Invoked by the editor when the component is added or reset. /// private void Reset() { timeOfDay = 0.5f; sunYaw = 20f; dayLengthSeconds = 300f; SeedStylizedPreset(); } /// /// Re-applies the tuned color palette WITHOUT clearing the wired references (Sun Light, /// Skybox Material) or the current time — unlike Reset, which nulls everything. Exposed on /// the component's context menu so the palette can be re-seeded during iteration. /// [ContextMenu("Apply Stylized Sky Preset")] private void SeedStylizedPreset() { zenithColorOverDay = BuildGradient( (0.00f, new Color(0.02f, 0.03f, 0.07f)), (0.23f, new Color(0.18f, 0.28f, 0.40f)), (0.30f, new Color(0.14f, 0.40f, 0.56f)), (0.50f, new Color(0.13f, 0.42f, 0.58f)), (0.70f, new Color(0.14f, 0.38f, 0.53f)), (0.78f, new Color(0.22f, 0.26f, 0.40f)), (1.00f, new Color(0.02f, 0.03f, 0.07f))); horizonColorOverDay = BuildGradient( (0.00f, new Color(0.04f, 0.05f, 0.10f)), (0.23f, new Color(0.86f, 0.50f, 0.36f)), (0.32f, new Color(0.32f, 0.56f, 0.68f)), (0.50f, new Color(0.26f, 0.52f, 0.66f)), (0.68f, new Color(0.30f, 0.50f, 0.60f)), (0.78f, new Color(0.86f, 0.48f, 0.32f)), (1.00f, new Color(0.04f, 0.05f, 0.10f))); groundColorOverDay = BuildGradient( (0.00f, new Color(0.03f, 0.04f, 0.08f)), (0.25f, new Color(0.26f, 0.30f, 0.34f)), (0.50f, new Color(0.22f, 0.44f, 0.56f)), (0.75f, new Color(0.28f, 0.28f, 0.30f)), (1.00f, new Color(0.03f, 0.04f, 0.08f))); sunColorOverDay = BuildGradient( (0.00f, new Color(0.40f, 0.40f, 0.50f)), (0.23f, new Color(1.00f, 0.55f, 0.30f)), (0.50f, new Color(1.00f, 0.95f, 0.85f)), (0.77f, new Color(1.00f, 0.50f, 0.28f)), (1.00f, new Color(0.40f, 0.40f, 0.50f))); cloudColorOverDay = BuildGradient( (0.00f, new Color(0.10f, 0.11f, 0.18f)), (0.25f, new Color(0.98f, 0.78f, 0.66f)), (0.50f, new Color(1.00f, 1.00f, 1.00f)), (0.75f, new Color(0.98f, 0.76f, 0.64f)), (1.00f, new Color(0.10f, 0.11f, 0.18f))); cloudShadowColorOverDay = BuildGradient( (0.00f, new Color(0.05f, 0.06f, 0.10f)), (0.25f, new Color(0.55f, 0.45f, 0.50f)), (0.50f, new Color(0.70f, 0.76f, 0.84f)), (0.75f, new Color(0.56f, 0.44f, 0.48f)), (1.00f, new Color(0.05f, 0.06f, 0.10f))); sunIntensityOverDay = new AnimationCurve( new Keyframe(0.00f, 0f), new Keyframe(0.23f, 0.05f), new Keyframe(0.30f, 0.8f), new Keyframe(0.50f, 1f), new Keyframe(0.70f, 0.8f), new Keyframe(0.78f, 0.05f), new Keyframe(1.00f, 0f)); cloudCoverageOverDay = AnimationCurve.Constant(0f, 1f, 0.55f); } /// /// Builds a Gradient from (time, color) tuples with full alpha, keeping color authoring /// in one compact place. Alpha is unused by the sky (every color is opaque). /// private static Gradient BuildGradient(params (float time, Color color)[] stops) { var colorKeys = new GradientColorKey[stops.Length]; for (int i = 0; i < stops.Length; i++) colorKeys[i] = new GradientColorKey(stops[i].color, stops[i].time); var gradient = new Gradient(); gradient.SetKeys(colorKeys, new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }); return gradient; } #endregion } }