Rendering/GhostPlayback.cs
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Loops a level's tutorial-ghost clip (<see cref="GhostClip"/>) as a translucent movement demo:
/// a faded copy of the recorded character that fades in, performs the recorded run, fades out,
/// pauses, and repeats — until the clip's dismissal rule fires (<see cref="GhostClip.DismissAboveObstacle"/>:
/// the player has reached the place the demo teaches), after which the current pass finishes and
/// no further pass starts. Owned by <see cref="GameStage"/> and advanced once per fixed sim step, so
/// hit-stop, slow-mo, and replay scrubs all pace it consistently with the world. Purely cosmetic:
/// it touches no sim state and its recorded ability visuals (<see cref="GhostFx"/>) draw only from
/// the cosmetic Rng stream, so its presence can never perturb a run or a replay.
/// </summary>
public sealed class GhostPlayback
{
	private const float MAX_ALPHA = 0.4f;
	private const int START_DELAY_TICKS = 36;
	private const int FADE_IN_TICKS = 15;
	private const int FADE_OUT_TICKS = 30;
	private const int LOOP_GAP_TICKS = 75;

	private readonly List<GhostPose> _frames;
	private readonly List<GhostFx> _fx;
	private readonly List<GhostJumpLabel> _labels = new();
	private readonly GameStage _stage;
	private readonly int? _dismissAboveObstacle;
	private bool _dismissed;
	private bool _finished;
	private readonly GameObject _root;
	private readonly SpriteRenderer _sprite;
	private readonly float _z;
	private string _animation;
	private int _index;
	private int _fxCursor;
	private int _waitTicks;

	public GhostPlayback( GameStage stage, GhostClip clip, List<GhostPose> frames )
	{
		_frames = frames;
		_fx = clip.DecodeFx() ?? new List<GhostFx>();
		_stage = stage;
		_dismissAboveObstacle = clip.DismissAboveObstacle;
		var character = Characters.Get( clip.CharacterId );
		_root = stage.CreateChild( "TutorialGhost" );
		_sprite = SpriteLayer.Add( _root, character.SpritePath, character.ArtSize, frames[0].Anim );
		_sprite.Opaque = false;   // translucent rendering, like the Rewind echoes
		_sprite.AlphaCutoff = 0f;
		_sprite.Color = Color.White.WithAlpha( 0f );
		_sprite.Enabled = false;
		// Behind the live player (and both Rewind echoes at -1/-2 steps), above blocks.
		_z = Globals.DepthToZ( Globals.DEPTH_PLAYER ) - SpriteLayer.LAYER_Z_STEP * 3f;
		_waitTicks = START_DELAY_TICKS;
	}

	/// <summary>Advance one fixed sim step. <paramref name="player"/> is the live body the dismissal
	/// rule watches (null when there is none).</summary>
	public void Tick( Player player )
	{
		// Callouts outlive the pose that fired them and keep fading through the loop gap (and past a
		// dismissal — the last pass's callouts still finish fading).
		for ( int i = _labels.Count - 1; i >= 0; i-- )
		{
			_labels[i].Tick();
			if ( _labels[i].Done )
				_labels.RemoveAt( i );
		}

		if ( _finished )
			return;
		if ( !_dismissed && PlayerPastDemo( player ) )
			_dismissed = true;

		if ( _waitTicks > 0 )
		{
			if ( --_waitTicks > 0 )
				return;
			if ( _dismissed )
			{
				// Dismissed between passes: don't start another.
				_finished = true;
				return;
			}
			_index = 0;
			_fxCursor = 0;
			// Force the first pose's PlayAnimation even when the previous loop ended on the same
			// animation name, so every pass starts on its first animation frame.
			_animation = null;
		}

		GhostPose pose = _frames[_index];
		if ( pose.Anim != _animation )
		{
			_animation = pose.Anim;
			_sprite.PlayAnimation( pose.Anim );
		}
		_sprite.FlipHorizontal = pose.FlipH;
		_sprite.FlipVertical = pose.FlipV;
		// Rotated poses (surface gravity, charged wall/ceiling jumps) store the old non-billboard
		// LookAt rotation, whose up axis is the head direction — roll the billboard to match.
		_sprite.GameObject.LocalRotation = pose.Rotated
			? SpriteLayer.FlatRotation( (Vector2)pose.Rot.ToRotation().Up )
			: Rotation.Identity;
		_root.WorldPosition = new Vector3( pose.Center.x, pose.Center.y, _z );

		// Fade envelope: ease in over the clip's first frames, out over its last (Min covers clips
		// shorter than both windows), scaled to the ghost's low base opacity.
		float fadeIn = Math.Min( 1f, (_index + 1) / (float)FADE_IN_TICKS );
		float fadeOut = Math.Min( 1f, (_frames.Count - _index) / (float)FADE_OUT_TICKS );
		float envelope = Math.Min( fadeIn, fadeOut );
		float alpha = MAX_ALPHA * envelope;
		_sprite.Color = Color.White.WithAlpha( alpha );
		_sprite.Enabled = true;

		// Visuals recorded on this frame: ability particles at the ghost's opacity, jump callouts
		// riding only the fade envelope (they must stay legible). A new emitter adds its kind to
		// GhostFxKind and a case in PlayFx.
		while ( _fxCursor < _fx.Count && _fx[_fxCursor].Frame <= _index )
			PlayFx( _fx[_fxCursor++], alpha, envelope );

		if ( ++_index >= _frames.Count )
		{
			_sprite.Enabled = false;
			if ( _dismissed )
				_finished = true;
			else
				_waitTicks = LOOP_GAP_TICKS;
		}
	}

	/// <summary>The dismissal rule: the live player's body sits entirely above the clip's obstacle
	/// (bottom edge at or past its top — standing on it counts).</summary>
	private bool PlayerPastDemo( Player player )
	{
		if ( _dismissAboveObstacle is not int index || player is null || player.IsDead )
			return false;
		var obstacles = _stage.Level?.Obstacles;
		if ( obstacles is null || index < 0 || index >= obstacles.Count )
			return false;
		return player.Bottom >= obstacles[index].Top;
	}

	private void PlayFx( GhostFx fx, float alpha, float envelope )
	{
		switch ( fx.Kind )
		{
			case GhostFxKind.Jump:
				_labels.Add( new GhostJumpLabel( _stage, fx.A, fx.B, envelope ) );
				break;
			default:
				BlinkerAbility.PlayGhostFx( _stage, fx, alpha );
				break;
		}
	}
}