Code/Demo/DayNightDemoBootstrap.cs

Bootstrap component that builds a small demo scene for the DayNight kit at runtime. It ensures a DayNightClock is present and configured, spawns a cluster of dev primitives with materials, optional rain, and two UI panels (hint card and time panel), and marks DemoActive so the demo UI can render.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace FieldGuide.DayNight;

/// <summary>
/// Wires the demo scene in code so the whole kit is exercised from one component.
///
/// The scene it builds is the kit's hero case: a lit ground plane with a few shapes on it, a
/// <see cref="DayNightClock"/> running the time, and a <see cref="DayNightDriver"/> on the scene's
/// DirectionalLight. Nothing else. That is enough, because the kit's product IS the light: the sun sweeps,
/// the shadows swing across the ground, the colour grade warms into dusk and drops into a genuinely dark
/// night, and none of it touches camera exposure. The shapes exist to catch that light and throw the
/// shadows that make the sweep readable; a bare plane shows almost nothing.
///
/// Two surfaces sit on top. The hint card (left) is up from the first frame and prints the live sky
/// weights, which is the only way to SEE the sky seam in a kit that deliberately ships no sky art. The
/// time panel (right) is the kit's dev tuning surface, opened here because a demo whose point is
/// "drive the cycle" should not hide the control behind a keypress.
///
/// Everything it builds ships with the engine: the dev primitives and the default material. The kit adds
/// no art of its own.
///
/// DEMO CONTENT IS INERT BY CONSTRUCTION (library law 11). Two things make that true here rather than by
/// instruction. First, the kit ships no scanned GameResource, so there is no demo content that can load
/// itself into a consumer's game the way a stray demo asset would. Second, the demo's UI is gated on
/// <see cref="DemoActive"/>, a flag ONLY this bootstrap sets: a consumer who forgets to delete Code/Demo,
/// and who somehow ends up with the hint card component in a scene, still renders nothing.
///
/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
/// </summary>
[Title( "Day Night Demo Bootstrap" )]
[Category( "Field Guide · Day Night" )]
[Icon( "auto_awesome" )]
public sealed class DayNightDemoBootstrap : Component
{
	/// <summary>
	/// True once this bootstrap has run in this session. The demo's own UI checks it before rendering, so
	/// demo content cannot appear in a consumer's game just because Code/Demo was left in the project.
	/// Nothing outside Code/Demo reads or writes it.
	/// </summary>
	public static bool DemoActive { get; private set; }

	/// <summary>The world seed the deterministic weather roll hashes against. Any int works; this one is
	/// just a fixed number so the demo rolls the same weather every run and two people comparing notes see
	/// the same days.</summary>
	[Property] public int DemoWorldSeed { get; set; } = 20260731;

	/// <summary>Real minutes per in-game day at the night pace. Short by default: the whole point of the
	/// demo is watching a full cycle, and the shipped default of 20 minutes is a long wait for that.</summary>
	[Property] public float DemoDayLengthMinutes { get; set; } = 4f;

	/// <summary>Game-hour the demo starts at. Mid-morning, so the first thing on screen is a lit scene with
	/// a sun that is visibly climbing rather than a black frame.</summary>
	[Property] public float DemoStartHour { get; set; } = 8.5f;

	const string FallbackMaterial = "materials/default.vmat";

	/// <summary>One shape in the demo cluster, sized in ENGINE UNITS per axis. The builder divides the size
	/// by the model's own bounds to get the scale, so the table below reads as real dimensions and survives
	/// the engine changing what a dev primitive measures (the shipped box is 50 units, the sphere 64).</summary>
	readonly record struct Shape( string ModelPath, Vector3 SizeUnits, Vector3 Position, Color Tint );

	/// <summary>
	/// The cluster. A tall slab, a low wall, two blocks and a ball, spread out and at different heights.
	///
	/// The shapes are chosen for their SHADOWS, not their looks. A tall thin slab throws a long finger that
	/// swings a quarter turn across the plane over one day, which is the single clearest read on "the sun is
	/// actually moving"; the low wall gives a hard edge for the terminator to crawl along at dawn and dusk;
	/// the ball is the only curved surface, so it is where the warm key and the cool sky fill are visibly
	/// two different colours rather than one flat tone.
	/// </summary>
	static readonly Shape[] Cluster =
	{
		new( "models/dev/box.vmdl",    new Vector3(  24f,  24f, 260f ), new Vector3(   0f,    0f, 130f ), new Color( 0.78f, 0.76f, 0.72f ) ),
		new( "models/dev/box.vmdl",    new Vector3( 420f,  28f,  90f ), new Vector3( -60f, -320f,  45f ), new Color( 0.62f, 0.58f, 0.54f ) ),
		new( "models/dev/box.vmdl",    new Vector3( 110f, 110f, 110f ), new Vector3( 300f,  140f,  55f ), new Color( 0.70f, 0.55f, 0.42f ) ),
		new( "models/dev/box.vmdl",    new Vector3(  70f,  70f, 170f ), new Vector3( 190f, -220f,  85f ), new Color( 0.55f, 0.60f, 0.68f ) ),
		new( "models/dev/sphere.vmdl", new Vector3( 150f, 150f, 150f ), new Vector3( -280f,  180f,  75f ), new Color( 0.80f, 0.80f, 0.82f ) ),
	};

	DayNightClock _clock;
	RainStreaks _rain;
	CameraComponent _camera;

	protected override void OnStart()
	{
		DemoActive = true;

		_camera = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();
		_clock = EnsureClock();
		BuildCluster();
		BuildRain();
		BuildUi();

		Log.Info( $"[daynight] demo ready. Day length {DemoDayLengthMinutes:0.#} real minutes, seed {DemoWorldSeed}. "
			+ "N opens the time panel, H hides the hint card." );
	}

	/// <summary>
	/// The clock, configured for a demo rather than for a game.
	///
	/// The one non-default value is the day length. Everything else is <see cref="DayNightConfig.Default"/>
	/// verbatim, on purpose: a demo that tunes the grade is showing you ITS look, not the kit's, and the
	/// shipped default is the reference grade a consumer gets on install.
	/// </summary>
	DayNightClock EnsureClock()
	{
		var clock = DayNightClock.For( Scene ) ?? Components.GetOrCreate<DayNightClock>();

		var cfg = DayNightConfig.Default;
		cfg.DayLengthMinutes = MathF.Max( 0.25f, DemoDayLengthMinutes );
		cfg.StartHours = Math.Clamp( DemoStartHour, 0f, 24f );
		clock.Config = cfg;
		clock.WorldSeed = DemoWorldSeed;

		// The driver has to agree with the clock: same config on both, which is exactly what the README
		// tells a consumer to do. Set it here rather than in the scene file so there is one source of truth.
		foreach ( var driver in Scene.GetAllComponents<DayNightDriver>() )
			driver.Config = cfg;

		return clock;
	}

	// ---- the shapes that catch the light ----

	void BuildCluster()
	{
		var root = Scene.CreateObject();
		root.Name = "Demo Shapes";

		for ( int i = 0; i < Cluster.Length; i++ )
			BuildShape( root, $"Shape {i + 1}", Cluster[i] );
	}

	void BuildShape( GameObject parent, string name, Shape shape )
	{
		var go = Scene.CreateObject();
		go.Name = name;
		go.SetParent( parent, false );
		go.LocalPosition = shape.Position;
		go.LocalRotation = Rotation.Identity;

		var renderer = go.Components.Create<ModelRenderer>();
		var model = Model.Load( shape.ModelPath );
		if ( model is null || model.IsError )
		{
			Log.Warning( $"[daynight] demo model '{shape.ModelPath}' did not load; '{name}' will be invisible." );
			return;
		}
		renderer.Model = model;

		var bounds = model.Bounds.Size;
		go.LocalScale = new Vector3(
			bounds.x > 0.001f ? shape.SizeUnits.x / bounds.x : 1f,
			bounds.y > 0.001f ? shape.SizeUnits.y / bounds.y : 1f,
			bounds.z > 0.001f ? shape.SizeUnits.z / bounds.z : 1f );

		// The engine's models/dev primitives render as missing-material magenta unless a real material is
		// forced on, which would swallow the grade this whole demo exists to show.
		var mat = Material.Load( FallbackMaterial );
		if ( mat is not null ) renderer.MaterialOverride = mat;
		renderer.Tint = shape.Tint;
	}

	// ---- the optional rain module, so a Rain day is visible ----

	void BuildRain()
	{
		var go = Scene.CreateObject();
		go.Name = "Demo Rain";

		_rain = go.Components.Create<RainStreaks>();

		// The shower centres on the camera, which is the seam's whole point: the kit never reaches for your
		// player or camera type, you hand it a position.
		_rain.Center = () => _camera.IsValid() ? _camera.WorldPosition : Vector3.Up * 200f;
	}

	protected override void OnUpdate()
	{
		// Drive the optional rain module from the clock's weather, which is the two-line wiring the README
		// describes. Cheap to call every frame.
		if ( _rain.IsValid() && _clock.IsValid() )
			_rain.SetRaining( _clock.CurrentWeather == WeatherKind.Rain );
	}

	// ---- screen UI ----

	void BuildUi()
	{
		// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene
		// needs no razor wiring.
		var hintHost = Scene.CreateObject();
		hintHost.Name = "Day Night Hint";
		hintHost.Components.Create<ScreenPanel>();
		hintHost.Components.Create<DayNightHintCard>();

		var panelHost = Scene.CreateObject();
		panelHost.Name = "Day Night UI";
		panelHost.Components.Create<ScreenPanel>();

		// Open on arrival. Driving the cycle is what this scene is FOR, so making the visitor find the key
		// first is a toll booth on the way to the point. N and the header x still close it. The panel reads
		// this on its first update, after every OnStart in the frame, so setting it here always lands.
		var panel = panelHost.Components.Create<DayNightPanel>();
		panel.OpenOnStart = true;
	}
}