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.
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;
}
}