Component that observes the game run and issues camera and particle feedback. It watches tick and state changes to trigger camera punch on eating, burst effects when the snake grows, and camera shake or punch on run end.
namespace Coilgarden;
/// <summary>
/// Watches the run for the moments worth reacting to, and is the single place that asks for
/// camera feedback and particles.
/// <para>
/// Two jobs, and they belong together. It is the <b>one</b> observer of the simulation's
/// events - it detects an apple or a death by watching the tick counter, so no view has to
/// subscribe to anything and there is no lifecycle to get wrong. And it is the <b>one</b>
/// request point for shake and punch, which is what lets overlapping requests resolve to the
/// strongest rather than the sum.
/// </para>
/// <para>
/// <b>Strongest wins, never the sum.</b> Two things happening in one tick is one impact, not a
/// double-length one. Summing gives a busy moment unbounded escalation, which is exactly
/// backwards from what reads well - the screen would shake hardest when the most is happening
/// and the player can least afford to lose the board.
/// </para>
/// <para>
/// There is deliberately <b>no hitstop</b>. Freezing time on eating would fight the one thing
/// this game must never compromise, which is that input is answered on the next tick; and
/// death already stops the clock by entering its own state, so a freeze there would buy
/// nothing but a delay before the restart.
/// </para>
/// </summary>
public sealed class Juice : Component
{
[Property] public GameSession Session { get; set; }
[Property] public ArenaCamera Camera { get; set; }
[Property] public Effects Effects { get; set; }
/// <summary>Scales every effect this class asks for. 0 turns the whole feel layer off.</summary>
[Property, Range( 0f, 2f )] public float Intensity { get; set; } = 1f;
/// <summary>The tick this component last reacted to, so each event fires exactly once.</summary>
private int seenTick = -1;
private bool seenGameOver;
protected override void OnEnabled()
{
Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
Camera ??= Scene.GetAllComponents<ArenaCamera>().FirstOrDefault();
Effects ??= Scene.GetAllComponents<Effects>().FirstOrDefault();
}
protected override void OnUpdate()
{
var run = Session?.Run;
if ( run is null ) return;
// A restart winds the tick counter back to zero. Resyncing rather than comparing for
// inequality stops a restart being mistaken for a tick and firing a phantom event.
if ( run.Ticks < seenTick )
{
seenTick = run.Ticks;
seenGameOver = false;
return;
}
if ( Session.State != GameState.GameOver ) seenGameOver = false;
if ( run.Ticks != seenTick )
{
seenTick = run.Ticks;
OnTick( Session.LastStep );
}
// Death is caught off the state rather than the step, so it still fires if the run ends
// on a tick this component happened not to observe.
if ( Session.State == GameState.GameOver && !seenGameOver )
{
seenGameOver = true;
OnRunEnded( run );
}
}
private void OnTick( StepResult step )
{
if ( !step.Grew ) return;
// A zoom punch rather than a shake. Eating is a good thing, and shake reads as damage
// however small it is - the board briefly coming closer reads as a reward.
Camera?.Punch( GameConfig.CameraPunchAmount * Intensity );
Effects?.Burst( step.To );
}
private void OnRunEnded( SnakeGame run )
{
// Filling the arena is a win. Shaking the screen for it would be telling the player
// off for the best thing they have ever done.
if ( run.EndedBy == StepOutcome.FilledArena )
{
Camera?.Punch( GameConfig.CameraPunchAmount * 2f * Intensity );
return;
}
Camera?.Shake( GameConfig.CameraShakeAmount * Intensity );
}
}