Rendering/ArenaView.cs

A rendering component that builds a static visual representation of the arena tray, backdrop, table and a faint checkerboard sand on enable/update. It creates a visual root GameObject and constructs boxes for backdrop, table, tray rim, sand bed and optional checker tiles sized to the arena and cell size.

Native Interop
namespace Coilgarden;

/// <summary>
/// The garden tray: a shallow terracotta planter of pale sand, on a table, against a deep
/// backdrop.
/// <para>
/// The tray does most of the work of the art direction. It is also the answer to "where is
/// danger": the arena boundary is a <em>physical raised wall</em> that catches the light and
/// casts a shadow inwards, rather than an invisible rule the player has to remember. A wall
/// you can see is worth more than any warning effect.
/// </para>
/// <para>
/// Built once per arena size and then left alone - nothing here changes per frame, so it
/// costs nothing to keep on screen.
/// </para>
/// </summary>
public sealed class ArenaView : Component
{
	[Property] public GameSession Session { get; set; }

	[Property] public float CellSize { get; set; } = GameConfig.CellSize;

	/// <summary>
	/// Draw the faint checker on the sand. On by default: it is what makes a gap four cells
	/// away judgeable at a glance rather than by guesswork.
	/// </summary>
	[Property] public bool ShowGrid { get; set; } = true;

	private GameObject visualRoot;
	private int builtWidth;
	private int builtHeight;
	private float builtCellSize;

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	protected override void OnDisabled() => TearDown();

	protected override void OnUpdate()
	{
		var arena = Session?.Run?.Arena;
		if ( arena is null ) return;

		EnsureBuilt( arena );
	}

	private void EnsureBuilt( Arena arena )
	{
		// The cell size is in the condition because it is a [Property]: dragging it in the
		// editor has to rebuild, and a hot reload that adds a field must not look "already
		// built" while that field is still at its default.
		var built = visualRoot.IsValid()
			&& builtWidth == arena.Width
			&& builtHeight == arena.Height
			&& builtCellSize.AlmostEqual( CellSize );

		if ( built ) return;

		TearDown();

		builtWidth = arena.Width;
		builtHeight = arena.Height;
		builtCellSize = CellSize;

		visualRoot = new GameObject( GameObject, true, "Arena" );
		visualRoot.Flags |= GameObjectFlags.NotSaved;

		var primitives = new Primitives( visualRoot );
		var space = new ArenaSpace( arena.Width, arena.Height, CellSize );

		BuildBackdrop( primitives, space );
		BuildTray( primitives, space );

		if ( ShowGrid ) BuildSand( primitives, space );
	}

	/// <summary>
	/// The world beyond the tray: a table surface, and a much larger panel further back.
	/// <para>
	/// Two planes rather than a camera clear colour, because a flat clear colour reads as
	/// emptiness while a lit surface reads as a room. It also gives the vignette something to
	/// darken.
	/// </para>
	/// </summary>
	private void BuildBackdrop( Primitives primitives, ArenaSpace space )
	{
		var span = MathF.Max( space.SandWidth, space.SandHeight );

		// Only just larger than the widest framing the camera can produce. It used to be four
		// arena spans across, which is a ~3000 unit object built for a ~760 unit arena - and the
		// directional light's shadow cascades have to span whatever the largest caster is, so
		// that one number was spending the entire shadow map on empty backdrop and leaving a
		// sphere's shadow smaller than a pixel. The camera clears to this same colour, so
		// nothing shows if it does not quite reach the corners.
		primitives.Box( "Backdrop",
			new Vector3( CellSize * 6f, 0f, 0f ),
			new Vector3( CellSize, span * 2.1f, span * 2.1f ),
			Palette.Backdrop );

		primitives.Box( "Table",
			new Vector3( CellSize * 1.6f, 0f, 0f ),
			new Vector3( CellSize, span * 1.42f, span * 1.42f ),
			Palette.Table );
	}

	/// <summary>
	/// The planter. A base slab holding the sand, four rim walls around it, and a thin lighter
	/// cap along the top of each wall so the edge catches the key light.
	/// </summary>
	private void BuildTray( Primitives primitives, ArenaSpace space )
	{
		var rimHeight = GameConfig.RimHeight * CellSize;
		var rimThickness = GameConfig.RimThickness * CellSize;
		var sandInset = GameConfig.SandInset * CellSize;

		var innerWidth = space.SandWidth;
		var innerHeight = space.SandHeight;

		// The sand sits below the top of the rim, which is what makes the tray read as holding
		// something rather than as a picture frame laid on the table.
		primitives.Box( "Sand Bed",
			new Vector3( sandInset + CellSize * 0.5f, 0f, 0f ),
			new Vector3( CellSize, innerWidth, innerHeight ),
			Palette.SoilLight );

		var railLength = innerWidth + rimThickness * 2f;
		var wallCentre = -(rimHeight * 0.5f) + sandInset;

		// Grid X runs towards -Y, so the wall the player sees on the left sits at +Y.
		BuildWall( primitives, "Rim Top",
			new Vector3( wallCentre, 0f, (innerHeight + rimThickness) * 0.5f ),
			new Vector3( rimHeight, railLength, rimThickness ), rimHeight );

		BuildWall( primitives, "Rim Bottom",
			new Vector3( wallCentre, 0f, -(innerHeight + rimThickness) * 0.5f ),
			new Vector3( rimHeight, railLength, rimThickness ), rimHeight );

		BuildWall( primitives, "Rim Left",
			new Vector3( wallCentre, (innerWidth + rimThickness) * 0.5f, 0f ),
			new Vector3( rimHeight, rimThickness, innerHeight ), rimHeight );

		BuildWall( primitives, "Rim Right",
			new Vector3( wallCentre, -(innerWidth + rimThickness) * 0.5f, 0f ),
			new Vector3( rimHeight, rimThickness, innerHeight ), rimHeight );
	}

	/// <summary>
	/// One rim wall plus its highlight cap. The cap is a thin slab sitting on top of the wall
	/// in a lighter tint - a cheap bevel that reads as a rounded edge under a warm key light,
	/// and the single detail that stops the tray looking like four grey boxes.
	/// </summary>
	private void BuildWall( Primitives primitives, string name, Vector3 position, Vector3 size, float rimHeight )
	{
		primitives.Box( name, position, size, Palette.TrayRim );

		var capThickness = rimHeight * 0.16f;

		primitives.Box( $"{name} Cap",
			position with { x = position.x - size.x * 0.5f + capThickness * 0.5f },
			size with { x = capThickness },
			Palette.TrayRimTop );
	}

	/// <summary>
	/// The checker on the sand, as one thin slab per darker cell.
	/// <para>
	/// Only the darker squares are drawn - the lighter ones are the sand bed showing through -
	/// so this is half the objects it looks like. The two tones differ by about 4%: enough to
	/// judge a distance by, quiet enough that the eye does not read it as content.
	/// </para>
	/// </summary>
	private void BuildSand( Primitives primitives, ArenaSpace space )
	{
		var tiles = new GameObject( visualRoot, true, "Checker" );
		tiles.Flags |= GameObjectFlags.NotSaved;

		var tilePrimitives = new Primitives( tiles );

		for ( var y = 0; y < space.Height; y++ )
		{
			for ( var x = 0; x < space.Width; x++ )
			{
				if ( (x + y) % 2 == 0 ) continue;

				var centre = space.Cell( x, y );

				tilePrimitives.Box( $"Tile {x},{y}",
					space.Above( centre, 0.01f ),
					new Vector3( CellSize * 0.02f, CellSize, CellSize ),
					Palette.SoilDark );
			}
		}
	}

	private void TearDown()
	{
		visualRoot?.Destroy();
		visualRoot = null;
		builtWidth = 0;
		builtHeight = 0;
		builtCellSize = 0f;
	}
}