Game/SkyController.cs

A component that controls sky colors for the scene. Defines SkyPalette data (top, horizon, cloud, two hill layers), interpolates between palettes, cycles day/night over time or picks random palettes, and writes colors to a ModelRenderer's SceneObject attributes for the shader to consume.

using System;
using Sandbox;

namespace Breakout;

/// <summary>
/// A set of sky colors: the sky gradient (top and horizon), the clouds, and the two hill layers. Lerp blends smoothly between two palettes.
/// </summary>
public sealed class SkyPalette
{
	[Property] public Color Top { get; set; } = new( 0.19f, 0.56f, 0.96f );
	[Property] public Color Horizon { get; set; } = new( 0.70f, 0.88f, 1.00f );
	[Property] public Color Cloud { get; set; } = Color.White;
	[Property] public Color HillBack { get; set; } = new( 0.34f, 0.90f, 0.44f );
	[Property] public Color HillFront { get; set; } = new( 0.16f, 0.72f, 0.37f );

	/// <summary>
	/// Blends smoothly between two palettes, where t=0 gives a and t=1 gives b.
	/// </summary>
	public static SkyPalette Lerp( SkyPalette a, SkyPalette b, float t ) => new()
	{
		Top = Color.Lerp( a.Top, b.Top, t ),
		Horizon = Color.Lerp( a.Horizon, b.Horizon, t ),
		Cloud = Color.Lerp( a.Cloud, b.Cloud, t ),
		HillBack = Color.Lerp( a.HillBack, b.HillBack, t ),
		HillFront = Color.Lerp( a.HillFront, b.HillFront, t ),
	};
}

/// <summary>
/// Colors the background sky by blending palette colors and pushing them into the sky shader.
/// Classic mode slowly cycles from day into night; the other modes just pick a random palette
/// each level.
/// </summary>
[Title( "Sky Controller" ), Category( "Breakout" ), Icon( "wb_twilight" )]
public sealed class SkyController : Component
{
	[Property] public ModelRenderer Target { get; set; }

	[Property, Group( "Classic Day/Night" )] public SkyPalette Day { get; set; } = new();
	[Property, Group( "Classic Day/Night" )] public SkyPalette Night { get; set; } = NightDefault();
	[Property, Group( "Classic Day/Night" )] public float SecondsToNight { get; set; } = 120f;
	[Property, Group( "Transition" )] public float TransitionSpeed { get; set; } = 1.5f;

	private SkyPalette shown;
	private SkyPalette target;

	private bool cycling;
	private float cycleElapsed;

	protected override void OnEnabled()
	{
		Target ??= Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndDescendants );

		Day ??= new SkyPalette();
		Night ??= NightDefault();

		shown = SkyPalette.Lerp( Day, Day, 0f );
		target = shown;
		Apply( shown );
	}

	protected override void OnUpdate()
	{
		if ( shown is null || target is null )
			return;

		if ( cycling )
		{
			// Slide from day (0) to night (1), starting and ending gently and moving fastest in
			// the middle, so the change eases in and out instead of running at a constant rate.
			cycleElapsed += Time.Delta;
			float night = SecondsToNight <= 0f
				? 1f
				: 0.5f - 0.5f * MathF.Cos( (cycleElapsed / SecondsToNight) * MathF.PI );
			target = SkyPalette.Lerp( Day, Night, night );
		}

		// Always drift the shown colors toward the target, so any change (cycle or new level)
		// fades in smoothly rather than snapping.
		shown = SkyPalette.Lerp( shown, target, MathF.Min( 1f, Time.Delta * TransitionSpeed ) );
		Apply( shown );
	}

	/// <summary>
	/// Starts the slow Classic-mode day-to-night cycle from full daytime.
	/// </summary>
	public void BeginClassicCycle()
	{
		cycling = true;
		cycleElapsed = 0f;
		target = Day;
	}

	/// <summary>
	/// Picks a random sky palette and fades to it (used by non-Classic modes each level).
	/// </summary>
	public void Randomize()
	{
		cycling = false;
		target = RandomPalette( Random.Shared );
	}

	/// <summary>
	/// Fades the sky back to the daytime palette.
	/// </summary>
	public void ToDay()
	{
		cycling = false;
		target = Day;
	}

	private void Apply( SkyPalette p )
	{
		if ( !Target.IsValid() )
			return;

		var so = Target.SceneObject;
		if ( so is null )
			return;

		so.Attributes.Set( "SkyTop", p.Top );
		so.Attributes.Set( "SkyHorizon", p.Horizon );
		so.Attributes.Set( "CloudColor", p.Cloud );
		so.Attributes.Set( "HillBack", p.HillBack );
		so.Attributes.Set( "HillFront", p.HillFront );
	}

	private static SkyPalette RandomPalette( Random rng )
	{
		float skyHue = rng.NextSingle();
		float hillHue = (skyHue + 0.30f + rng.NextSingle() * 0.35f) % 1f;

		return new SkyPalette
		{
			Top = new ColorHsv( skyHue * 360f, 0.72f, 0.96f ).ToColor(),
			Horizon = new ColorHsv( skyHue * 360f, 0.34f, 1.00f ).ToColor(),
			Cloud = new ColorHsv( skyHue * 360f, 0.06f, 1.00f ).ToColor(),
			HillBack = new ColorHsv( hillHue * 360f, 0.64f, 0.92f ).ToColor(),
			HillFront = new ColorHsv( hillHue * 360f, 0.78f, 0.74f ).ToColor(),
		};
	}

	private static SkyPalette NightDefault() => new()
	{
		Top = new Color( 0.03f, 0.05f, 0.16f ),
		Horizon = new Color( 0.22f, 0.17f, 0.36f ),
		Cloud = new Color( 0.34f, 0.33f, 0.47f ),
		HillBack = new Color( 0.10f, 0.16f, 0.24f ),
		HillFront = new Color( 0.05f, 0.09f, 0.15f ),
	};
}