A rendering component that draws and animates an apple in the arena. It builds primitive geometry for the apple (flesh, stem, leaf), tracks the apple cell from the game simulation, and plays three presentation animations: spawn, idle (bob and spin), and eaten (pop).
namespace Coilgarden;
/// <summary>
/// Draws the apple, and animates the moment the whole game is built around.
/// <para>
/// The apple gets three animations and each has a job:
/// </para>
/// <list type="bullet">
/// <item><b>Spawn</b> - scales in with a slight overshoot. An apple that simply appears is the
/// difference between a world and a spreadsheet, and the overshoot draws the eye to the new
/// target without needing a flash or an arrow.</item>
/// <item><b>Idle</b> - a slow bob and an even slower spin. It says the apple is a thing rather
/// than a marker, and a moving object is found in peripheral vision far faster than a still
/// one.</item>
/// <item><b>Eaten</b> - swells and vanishes in about an eighth of a second. This is the reward,
/// so it has to be immediate; anything longer and the player is waiting for their prize
/// instead of already going after the next one.</item>
/// </list>
/// <para>
/// Presentation only: it reads where the apple is and never decides.
/// </para>
/// </summary>
public sealed class AppleView : Component
{
[Property] public GameSession Session { get; set; }
[Property] public float CellSize { get; set; } = GameConfig.CellSize;
/// <summary>Scales the bob, spin, spawn and pop. 0 leaves a static apple.</summary>
[Property, Range( 0f, 2f )] public float Animation { get; set; } = 1f;
private GameObject visualRoot;
private GameObject apple;
private Primitives parts;
private ArenaSpace space;
/// <summary>Where the apple is being drawn, which lags the simulation during the eat pop.</summary>
private GridPos drawnCell;
private bool hasDrawnCell;
private float spawnAge = 99f;
private float eatAge = -1f;
/// <summary>The cell the pop is playing at, kept because the apple has already moved on.</summary>
private GridPos poppingCell;
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 );
if ( !apple.IsValid() ) return;
// Zero while paused, so the apple stops bobbing and spinning with everything else.
var delta = Session.FeelDelta;
spawnAge += delta;
if ( eatAge >= 0f ) eatAge += delta;
Observe( run );
Draw( run );
}
/// <summary>
/// Notices a new apple, and notices the old one being eaten.
/// <para>
/// The eaten apple's cell has to be remembered, because by the time this runs the simulation
/// has already put the next apple somewhere else - the pop has to play where the fruit
/// actually was, not where the next one will be.
/// </para>
/// </summary>
private void Observe( SnakeGame run )
{
if ( !run.Apple.HasValue )
{
// The arena is full, which is a win. Nothing to draw and nothing to animate.
hasDrawnCell = false;
return;
}
var cell = run.Apple.Value;
if ( hasDrawnCell && cell == drawnCell ) return;
// A different cell means the previous one was eaten - unless nothing was being drawn yet,
// which is a fresh run rather than a meal.
if ( hasDrawnCell && Session.LastStep.Grew )
{
poppingCell = drawnCell;
eatAge = 0f;
}
drawnCell = cell;
hasDrawnCell = true;
spawnAge = 0f;
}
private void Draw( SnakeGame run )
{
if ( !hasDrawnCell )
{
apple.Enabled = false;
return;
}
// While the pop is playing, the object is still showing the apple that was eaten. The new
// one waits its turn, which keeps the two events from overlapping into visual mush.
var popping = eatAge >= 0f && eatAge < GameConfig.AppleEatDuration;
var cell = popping ? poppingCell : drawnCell;
var scale = popping ? PopScale() : SpawnScale();
var radius = GameConfig.AppleDiameter * 0.5f;
var bob = popping ? 0f : Bob();
apple.LocalPosition = space.Above( space.Cell( cell ), radius + bob );
apple.LocalScale = new Vector3( scale, scale, scale );
// Spun about the view axis, so from a top-down camera the stem and leaf sweep around the
// fruit rather than tumbling out of the plane.
apple.LocalRotation = Rotation.FromAxis( Vector3.Forward, Spin() );
apple.Enabled = scale > 0.01f;
}
/// <summary>
/// Swells, then collapses to nothing. The swell is what makes it read as bursting rather
/// than blinking out, and the collapse accelerates so the last thing the eye registers is
/// the apple being gone rather than a small apple lingering.
/// </summary>
private float PopScale()
{
var t = Ease.Progress( eatAge, GameConfig.AppleEatDuration );
var swell = 1f + GameConfig.AppleEatSwell * Animation;
const float rise = 0.35f;
if ( t < rise ) return MathX.Lerp( 1f, swell, Ease.OutCubic( t / rise ) );
return MathX.Lerp( swell, 0f, Ease.InQuad( (t - rise) / (1f - rise) ) );
}
private float SpawnScale()
{
var t = Ease.Progress( spawnAge, GameConfig.AppleSpawnDuration );
if ( t >= 1f || Animation <= 0f ) return 1f;
return MathX.Lerp( 0f, 1f, Ease.OutBack( t ) );
}
private float Bob() =>
MathF.Sin( Session.FeelTime * GameConfig.AppleBobSpeed ) * GameConfig.AppleBobAmount * Animation;
private float Spin() => Animation <= 0f ? 0f : Session.FeelTime * GameConfig.AppleSpinSpeed * Animation;
private void EnsureBuilt( Arena arena )
{
var built = visualRoot.IsValid()
&& builtWidth == arena.Width
&& builtHeight == arena.Height
&& builtCellSize.AlmostEqual( CellSize )
&& apple.IsValid();
if ( built ) return;
TearDown();
builtWidth = arena.Width;
builtHeight = arena.Height;
builtCellSize = CellSize;
visualRoot = new GameObject( GameObject, true, "Apple" );
visualRoot.Flags |= GameObjectFlags.NotSaved;
space = new ArenaSpace( arena.Width, arena.Height, CellSize );
// The parts hang off one object so the whole apple can be moved, scaled, spun and popped
// as a single thing.
apple = new GameObject( visualRoot, true, "Apple Body" );
apple.Flags |= GameObjectFlags.NotSaved;
parts = new Primitives( apple );
var diameter = GameConfig.AppleDiameter * CellSize;
parts.Sphere( "Flesh", Vector3.Zero, diameter, Palette.AppleFlesh );
var stemLength = GameConfig.AppleStemLength * CellSize;
// The stem leans towards the top of the screen (+Z) rather than straight out at the
// camera (-X). Pointing it along the view axis hid it inside the apple's own silhouette,
// where it read as a stray dot in the middle of the fruit instead of a stem - from a
// top-down camera, anything that matters has to have screen-space extent.
parts.Box( "Stem",
new Vector3( -diameter * 0.30f, 0f, diameter * 0.40f ),
new Vector3( diameter * 0.11f, diameter * 0.11f, stemLength ),
Palette.AppleStem );
parts.Sphere( "Leaf",
new Vector3( -diameter * 0.34f, -diameter * 0.26f, diameter * 0.50f ),
diameter * 0.26f,
Palette.AppleLeaf );
apple.Enabled = false;
hasDrawnCell = false;
spawnAge = 99f;
eatAge = -1f;
}
private void TearDown()
{
visualRoot?.Destroy();
visualRoot = null;
apple = null;
parts = null;
hasDrawnCell = false;
builtWidth = 0;
builtHeight = 0;
builtCellSize = 0f;
}
}