A presentation-only component that renders the snake each frame. It reads the game state (GameSession and SnakeGame) and draws sphere segments, head squash, breathing, growth and death animations without modifying simulation state.
namespace Coilgarden;
/// <summary>
/// Draws the snake, and is where most of the game's feel lives: smooth travel between cells, a
/// head that leans into its turns, a body that breathes, growth that arrives rather than
/// appears, and a death that reads.
/// <para>
/// <b>Presentation only.</b> It reads the simulation every frame and never writes to it. That
/// separation is what makes all of the below safe: collision is resolved on integer cells at a
/// fixed tick, and everything in this file is a lie told to the eye between those ticks. No
/// amount of smoothing here can change whether the snake lives.
/// </para>
/// <para>
/// The three visual decisions from the visual phase still stand - spheres so the body reads as
/// soft, a head made unmistakable three ways over, and a taper so a line of circles reads as a
/// creature.
/// </para>
/// </summary>
public sealed class SnakeView : Component
{
[Property] public GameSession Session { get; set; }
[Property] public float CellSize { get; set; } = GameConfig.CellSize;
[Property] public bool ShowEyes { get; set; } = true;
/// <summary>Interpolate between cells. Off snaps to the grid, which is what Phase 1 looked like.</summary>
[Property] public bool Smooth { get; set; } = true;
/// <summary>Scales the squash, breathing and death animation. 0 leaves plain movement.</summary>
[Property, Range( 0f, 2f )] public float Animation { get; set; } = 1f;
private GameObject visualRoot;
private Primitives primitives;
private ArenaSpace space;
private readonly List<ModelRenderer> segments = new();
private ModelRenderer leftEye;
private ModelRenderer rightEye;
/// <summary>
/// Where the body is now and where it was on the previous tick. Interpolating needs both,
/// and the simulation only knows "now" - so the view remembers the last tick itself rather
/// than asking the rules to keep history they have no use for.
/// </summary>
private GridPos[] current;
private GridPos[] previous;
private int currentCount;
private int previousCount;
private int seenTick = -1;
private Direction seenDirection;
/// <summary>Seconds since the head last changed direction, and since the snake last grew.</summary>
private float turnAge = 99f;
private float growAge = 99f;
private float deathAge = -1f;
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 run = Session?.Run;
if ( run is null ) return;
EnsureBuilt( run.Arena );
Observe( run );
// The feel layer's own delta, which is zero while paused - so a pause freezes the tray
// rather than leaving it animating behind the card.
var delta = Session.FeelDelta;
turnAge += delta;
growAge += delta;
if ( deathAge >= 0f ) deathAge += delta;
DrawBody( run );
DrawEyes( run.Snake );
}
// ---------------------------------------------------------------------- observing
/// <summary>
/// Notices the events worth animating by watching the tick counter, rather than by
/// subscribing to anything. Polling is the right call here: the view already runs every
/// frame, and an event subscription is a lifecycle to get wrong for no benefit.
/// </summary>
private void Observe( SnakeGame run )
{
// The first observation establishes a baseline rather than firing anything. Compared
// against the default field values it would decide the snake had just turned and punch
// the head on frame one, before the player had touched a key.
if ( seenTick < 0 )
{
seenTick = run.Ticks;
seenDirection = run.Snake.Direction;
currentCount = run.Snake.CopyTo( current );
previousCount = currentCount;
Array.Copy( current, previous, currentCount );
return;
}
// A restart winds the counter back. Snapping rather than interpolating from a stale body
// stops the snake visibly flying across the tray to its new starting position.
if ( run.Ticks < seenTick )
{
seenTick = run.Ticks;
currentCount = run.Snake.CopyTo( current );
previousCount = currentCount;
Array.Copy( current, previous, currentCount );
seenDirection = run.Snake.Direction;
turnAge = 99f;
growAge = 99f;
deathAge = -1f;
return;
}
if ( run.Ticks != seenTick )
{
seenTick = run.Ticks;
Array.Copy( current, previous, currentCount );
previousCount = currentCount;
currentCount = run.Snake.CopyTo( current );
if ( run.Snake.Direction != seenDirection )
{
seenDirection = run.Snake.Direction;
turnAge = 0f;
}
if ( Session.LastStep.Grew ) growAge = 0f;
}
// Death is taken off the state, not the step, so it cannot be missed on a frame where
// several ticks were observed at once.
if ( Session.State == GameState.GameOver && deathAge < 0f && Session.LastStep.IsDeath )
{
deathAge = 0f;
}
}
/// <summary>
/// How far through the current tick the visuals are.
/// <para>
/// Pinned to 1 whenever the run is not advancing. Left at the session's fraction, a paused
/// or finished run would render its body at the <em>previous</em> tick's positions, which
/// puts the snake half a cell behind where the rules say it is - and on a death, half a cell
/// away from the thing that killed it.
/// </para>
/// </summary>
private float MoveFraction()
{
if ( !Smooth ) return 1f;
if ( Session.State != GameState.Playing ) return 1f;
return Session.TickFraction;
}
// ---------------------------------------------------------------------- drawing
private void DrawBody( SnakeGame run )
{
var t = MoveFraction();
var count = currentCount;
for ( var i = 0; i < count; i++ )
{
var renderer = SegmentAt( i );
renderer.GameObject.LocalPosition = SegmentPosition( i, t );
renderer.Tint = SegmentTint( i, count );
primitives.Resize( renderer, SegmentSize( i, count, run ) );
renderer.Enabled = true;
}
// The pool only grows. A snake that shrinks on restart hides the surplus rather than
// paying to rebuild it.
for ( var i = count; i < segments.Count; i++ )
{
segments[i].Enabled = false;
}
}
/// <summary>
/// A segment's position, interpolated from where it was to where it is.
/// <para>
/// Linear, deliberately. Easing each step would make the whole snake accelerate and brake
/// once per tick - a limp rather than travel. The character lives in the squash and the
/// breathing, which sit on top of constant-speed motion.
/// </para>
/// </summary>
private Vector3 SegmentPosition( int index, float t )
{
var to = current[index];
// A segment that did not exist last tick is the one just grown: it has nowhere to come
// from, so it stays put and scales in instead.
var from = index < previousCount ? previous[index] : to;
var lift = SegmentDiameter( index, currentCount ) * 0.5f;
if ( from == to ) return space.Above( space.Cell( to ), lift );
var x = MathX.Lerp( from.X, to.X, t );
var y = MathX.Lerp( from.Y, to.Y, t );
return space.Above( space.Cell( x, y ), lift );
}
private Color SegmentTint( int index, int count )
{
var tint = Palette.Segment( index, count );
if ( deathAge < 0f ) return tint;
// The body dries out to a dull green as it wilts, staggered like the shrink, so the
// change travels down the snake rather than switching all at once.
var delay = index * GameConfig.DeathStagger;
var wilt = Ease.Progress( deathAge - delay, GameConfig.DeathDuration - delay );
tint = Color.Lerp( tint, Palette.SnakeWilt, Ease.OutCubic( wilt ) * Animation );
if ( index != 0 ) return tint;
// The head also flashes near-white at the instant of impact. Two channels say "you
// died" - this and the wilt - because a single channel fails for somebody.
var flash = 1f - Ease.Progress( deathAge, GameConfig.DeathFlashDuration );
return Color.Lerp( tint, Palette.DeathFlash, flash * Animation );
}
/// <summary>
/// Base diameter by distance from the head, before any animation.
/// <para>
/// The curve matters more than it looks. Consecutive segments sit exactly one cell apart, so
/// a pair only touches while their average diameter is at least a cell - and the taper is
/// the only thing that can pull them below it. On a squared curve the thinning reached that
/// point around two thirds of the way down the body, so a long snake stopped being a
/// creature and became a string of beads from the middle back. That is the worst possible
/// place for it to happen: the snake is only long when the player is doing well.
/// </para>
/// <para>
/// A fourth power keeps the body full for most of its length and spends the whole taper on
/// the last few segments, which is also what a tail actually looks like. Only the final
/// joint or two separate now, and there it reads as a thinning point rather than a gap.
/// </para>
/// </summary>
private static float SegmentDiameter( int index, int count )
{
if ( index == 0 ) return GameConfig.HeadDiameter;
if ( count <= 2 ) return GameConfig.BodyDiameter;
var t = (index - 1) / (float)(count - 2);
var pinch = t * t * t * t;
return MathX.Lerp( GameConfig.BodyDiameter, GameConfig.TailDiameter, pinch );
}
/// <summary>
/// A segment's size on each axis, with everything animated folded in: the death collapse,
/// the growth scale-in, the idle breathing, and the head's squash into a turn.
/// </summary>
private Vector3 SegmentSize( int index, int count, SnakeGame run )
{
var diameter = SegmentDiameter( index, count ) * CellSize;
diameter *= Collapse( index );
diameter *= GrowIn( index, count );
diameter *= Breath( index );
if ( index != 0 ) return new Vector3( diameter, diameter, diameter );
return HeadSquash( diameter, run.Snake.Direction );
}
/// <summary>
/// The death wilt: segments shrink, staggered from the head down the body, so the deflation
/// travels from the point of impact rather than happening all at once.
/// <para>
/// It settles at a bit over half size rather than shrinking away to nothing. Two reasons,
/// and the second is the important one. Shrinking to zero left a row of specks on the tray
/// that read as debris. And a snake that is still there means the player can see the shape
/// of the run they just made while they read their score - the board becomes the record of
/// the attempt instead of going blank the moment it ends.
/// </para>
/// </summary>
private float Collapse( int index )
{
if ( deathAge < 0f ) return 1f;
var delay = index * GameConfig.DeathStagger;
var t = Ease.Progress( deathAge - delay, GameConfig.DeathDuration - delay );
// Held at full size until its turn comes, then eased down to its resting size.
return MathX.Lerp( 1f, GameConfig.DeathRestScale, Ease.OutCubic( t ) * Animation );
}
/// <summary>The newest segment swells in from nothing over its first fraction of a second.</summary>
private float GrowIn( int index, int count )
{
if ( index != count - 1 ) return 1f;
var t = Ease.Progress( growAge, GameConfig.GrowDuration );
if ( t >= 1f ) return 1f;
return MathX.Lerp( 0.15f, 1f, Ease.OutBack( t ) );
}
/// <summary>
/// A slow swell travelling down the body. Secondary motion with exactly one job: saying the
/// creature is alive while it is standing still, which is most of what stops a grid game
/// feeling like sliding tiles.
/// </summary>
private float Breath( int index )
{
if ( deathAge >= 0f ) return 1f;
var phase = Session.FeelTime * GameConfig.BreathSpeed - index / GameConfig.BreathWavelength;
return 1f + MathF.Sin( phase ) * GameConfig.BreathAmount * Animation;
}
/// <summary>
/// Squashes the head along its direction of travel and stretches it across, briefly, when it
/// turns.
/// <para>
/// The purpose is confirmation. A turn is the only input this game has, and at speed the
/// difference between "my turn registered" and "it did not" has to be visible in the frame
/// the tick lands on. The squash is also the reason it reads as the snake <em>deciding</em>
/// to turn rather than the sprite being repositioned.
/// </para>
/// </summary>
private Vector3 HeadSquash( float diameter, Direction direction )
{
var t = Ease.Progress( turnAge, GameConfig.TurnPunchDuration );
if ( t >= 1f || Animation <= 0f ) return new Vector3( diameter, diameter, diameter );
var punch = Ease.Pulse( t ) * GameConfig.TurnSquash * Animation;
// Shorter along travel, taller across it, and volume roughly preserved so it reads as
// squash rather than as the head changing size.
var along = 1f - punch;
var across = 1f + punch * 0.5f;
var travel = ArenaSpace.Facing( direction );
// The travelling axis compresses and the other two expand. The depth axis expanding is
// what sells it from a top-down camera: the head visibly bulges towards the viewer as it
// squeezes, where a purely in-plane squash would be nearly invisible from here.
var y = MathF.Abs( travel.y ) > 0.5f ? along : across;
var z = MathF.Abs( travel.z ) > 0.5f ? along : across;
return new Vector3( diameter * across, diameter * y, diameter * z );
}
/// <summary>
/// Two dark dots on the head, forward of centre and lifted towards the viewer so they are
/// visible from a camera looking straight down at the tray.
/// <para>
/// They face the heading the snake is <em>about to</em> take rather than the one it is
/// travelling, so the snake appears to look where it is going on the frame the player asked
/// for the turn instead of a tick later. It is a small thing that makes the creature seem to
/// intend its movement.
/// </para>
/// </summary>
private void DrawEyes( Snake snake )
{
if ( !ShowEyes || currentCount <= 0 || !leftEye.IsValid() ) return;
// Hidden through the collapse: eyes on a deflating head read as a cartoon, and death
// should land softly rather than comically.
if ( deathAge >= 0f )
{
leftEye.Enabled = false;
rightEye.Enabled = false;
return;
}
var head = SegmentPosition( 0, MoveFraction() );
var size = GameConfig.HeadDiameter * CellSize;
var facing = ArenaSpace.Facing( snake.NextDirection ) * (size * GameConfig.EyeForward);
var sideways = ArenaSpace.Sideways( snake.NextDirection ) * (size * GameConfig.EyeSpread);
// Towards the camera, so the eyes sit on the visible face of the sphere.
var lift = new Vector3( -size * 0.34f, 0f, 0f );
leftEye.GameObject.LocalPosition = head + facing + sideways + lift;
rightEye.GameObject.LocalPosition = head + facing - sideways + lift;
leftEye.Enabled = true;
rightEye.Enabled = true;
}
private ModelRenderer SegmentAt( int index )
{
while ( segments.Count <= index )
{
segments.Add( primitives.Sphere( $"Segment {segments.Count}",
Vector3.Zero, GameConfig.BodyDiameter * CellSize, Palette.SnakeBody ) );
}
return segments[index];
}
// ---------------------------------------------------------------------- construction
private void EnsureBuilt( Arena arena )
{
var built = visualRoot.IsValid()
&& builtWidth == arena.Width
&& builtHeight == arena.Height
&& builtCellSize.AlmostEqual( CellSize )
&& current is not null
&& previous is not null;
if ( built ) return;
TearDown();
builtWidth = arena.Width;
builtHeight = arena.Height;
builtCellSize = CellSize;
visualRoot = new GameObject( GameObject, true, "Snake" );
visualRoot.Flags |= GameObjectFlags.NotSaved;
primitives = new Primitives( visualRoot );
space = new ArenaSpace( arena.Width, arena.Height, CellSize );
current = new GridPos[arena.CellCount];
previous = new GridPos[arena.CellCount];
seenTick = -1;
var eyeSize = GameConfig.HeadDiameter * GameConfig.EyeDiameter * CellSize;
leftEye = primitives.Sphere( "Eye Left", Vector3.Zero, eyeSize, Palette.SnakeEye );
rightEye = primitives.Sphere( "Eye Right", Vector3.Zero, eyeSize, Palette.SnakeEye );
}
private void TearDown()
{
visualRoot?.Destroy();
visualRoot = null;
primitives = null;
segments.Clear();
leftEye = null;
rightEye = null;
current = null;
previous = null;
currentCount = 0;
previousCount = 0;
builtWidth = 0;
builtHeight = 0;
builtCellSize = 0f;
}
}