Utils/DayNightController.cs
using System;
using HC3.Persistence;
using Sandbox.Diagnostics;

public interface ITimeOfDayEvents : ISceneEvent<ITimeOfDayEvents>
{
	/// <summary>
	/// Called when there's a new day (this is only called on the host.)
	/// </summary>
	public void OnNewDay();
}

[EditorHandle( Icon = "🌞" )]
public sealed class DayNightController : Component, ISaveDataProperty<DayNightController.SaveData>
{
	public static DayNightController Instance { get; private set; }

	[ConVar( "hc3.locktod", ConVarFlags.Cheat | ConVarFlags.Replicated )]
	public static bool LockTimeOfDay { get; set; } = false;

	[Property, Sync( SyncFlags.FromHost )]
	public float TimeOfDay { get; private set; } = 12f;

	[Sync( SyncFlags.FromHost )]
	public float CurrentTimeScale { get; private set; } = 1f;

	[Sync( SyncFlags.FromHost )]
	public int Day { get; private set; } = 1;

	[Sync( SyncFlags.FromHost )]
	public int Month { get; private set; } = 1;

	[Sync( SyncFlags.FromHost )]
	public int Year { get; private set; } = 2025;

	public bool IsPaused => CurrentTimeScale.AlmostEqual( 0 );

	[Property] private float TimeScale { get; set; } = 0.05f;
	[Property] Gradient ColorGradient { get; set; } = new Gradient();
	[Property] Gradient FogGradient { get; set; } = new Gradient();
	[Property] Curve FogDensity { get; set; } = new Curve();

	DirectionalLight SunLight { get; set; }
	[RequireComponent] GradientFog Fog { get; set; }

	/// <summary>
	/// When does the park close?
	/// </summary>
	[ConVar( "hc3.park.close_time" )]
	public static float ParkCloseTime { get; set; } = 20f;

	/// <summary>
	/// When does the park open?
	/// </summary>
	[ConVar( "hc3.park.open_time" )]
	public static float ParkOpenTime { get; set; } = 6f;

	/// <summary>
	/// Is the park open?
	/// </summary>
	/// <returns></returns>
	public static bool IsParkOpen()
	{
		if ( Instance?.TimeOfDay < ParkOpenTime ) return false;
		if ( Instance?.TimeOfDay > ParkCloseTime ) return false;

		return true;
	}

	/// <summary>
	/// Are we in peak?
	/// </summary>
	/// <returns></returns>
	public static bool IsPeakSeason()
	{
		// April -> September
		return Instance?.Month is < 9 and > 4;
	}

	private static readonly int[] DaysInMonth =
	[
		31, 28, 31, 30, 31, 30,
		31, 31, 30, 31, 30, 31
	];

	private static readonly string[] MonthNames =
	[
		"Jan", "Feb", "Mar", "Apr", "May", "Jun",
		"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
	];

	private static bool IsLeapYear( int year )
	{
		return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
	}

	private void AdvanceDate()
	{
		Day++;

		var daysThisMonth = DaysInMonth[Month - 1];

		if ( Month == 2 && IsLeapYear( Year ) )
		{
			daysThisMonth = 29;
		}

		if ( Day > daysThisMonth )
		{
			Day = 1;
			Month++;

			if ( Month > 12 )
			{
				Month = 1;
				Year++;
			}
		}
	}

	[Rpc.Host]
	public void TogglePause()
	{
		if ( Scene.TimeScale > 0f )
		{
			CurrentTimeScale = 0f;
		}
		else
		{
			CurrentTimeScale = 1;
		}
	}

	[Rpc.Host]
	public void ResetSpeed()
	{
		CurrentTimeScale = 1f;
	}


	[Rpc.Host]
	public void SetSpeed( float newSpeed )
	{
		CurrentTimeScale = newSpeed;

		if ( CurrentTimeScale < 0 )
			CurrentTimeScale = 0;

		if ( CurrentTimeScale > 5 )
			CurrentTimeScale = 5;
	}

	protected override void OnAwake()
	{
		Instance = this;
	}

	protected override void OnStart()
	{
		SunLight = Scene.GetAll<DirectionalLight>().FirstOrDefault();
	}

	float GetTimeScale()
	{
		if ( LockTimeOfDay ) return 0;
		return TimeScale;
	}

	[Property]
	public float LightPower { get; set; } = 1f;

	protected override void OnUpdate()
	{
		if ( Networking.IsHost )
		{
			TimeOfDay += GetTimeScale() * Time.Delta;

			if ( TimeOfDay >= 24f )
			{
				StartNewDay();
			}
		}

		Scene.TimeScale = CurrentTimeScale;

		var sunrise = 6f;
		var sunset = 20f;

		float targetAngle;
		if ( TimeOfDay >= sunrise && TimeOfDay <= sunset )
		{
			float t = (TimeOfDay - sunrise) / (sunset - sunrise);
			targetAngle = t * 180f;
		}
		else
		{
			float t = (TimeOfDay < sunrise ? TimeOfDay + 24f : TimeOfDay - sunset) / (24f - (sunset - sunrise));
			targetAngle = 180f + t * 180f;
		}

		float duration = 1f;
		float fadeIn = Math.Clamp( (TimeOfDay - sunrise) / duration, 0f, 1f );
		float fadeOut = Math.Clamp( (sunset - TimeOfDay) / duration, 0f, 1f );
		// day = 1, night = 0 fades across [duration] hours before sunrise and after sunset (so sunrise/sunset is 0.5)
		float frac = MathF.Min( fadeIn, fadeOut );

		var color = ColorGradient.Evaluate( frac );

		// sun / ambient
		SunLight.LightColor = Color.Lerp( Color.Black, color, frac ) * LightPower;
		SunLight.SkyColor = color * 0.4f;
		SunLight.GameObject.WorldRotation = Rotation.From( targetAngle, 60f, 0f );

		// fog
		var fogColor = FogGradient.Evaluate( frac );
		Fog.Color = fogColor;
		Fog.Height = FogDensity.Evaluate( frac );
	}

	public string GetTimeString()
	{
		var hours = (int)TimeOfDay;
		var minutes = (int)((TimeOfDay - hours) * 60);

		return $"{hours:D2}:{minutes:D2}";
	}

	public string GetDateString()
	{
		return $"{Day:D2}/{Month:D2}/{Year}";
	}

	public string GetLongDateString()
	{
		return $"{Day:D2} {MonthNames[Month - 1]}, {Year}";
	}

	private void StartNewDay()
	{
		Assert.True( Networking.IsHost );

		TimeOfDay = 0f;
		AdvanceDate();

		ITimeOfDayEvents.Post( x => x.OnNewDay() );
	}

	string ISaveDataProperty.PropertyName => "DateTime";
	int ISaveDataProperty.PropertyOrder => -6_000;

	private sealed record SaveData( float TimeOfDay, int Day, int Month, int Year );

	SaveData ISaveDataProperty<SaveData>.WriteValue( Scene scene ) =>
		new( TimeOfDay, Day, Month, Year );

	void ISaveDataProperty<SaveData>.ReadValue( Scene scene, SaveData model )
	{
		TimeOfDay = model.TimeOfDay;
		Day = model.Day;
		Month = model.Month;
		Year = model.Year;
	}
}