Game/GhostRecorder.cs
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Records the live player's on-screen pose each sim step into a tutorial-ghost clip (see
/// <see cref="GhostClip"/> for why poses are recorded instead of inputs). Static, one recording at a
/// time (like <see cref="RunRecorder"/>); <see cref="GameStage.Tick"/> feeds it the frame's final
/// pose via <see cref="CaptureStep"/>.
///
/// <para>Authoring flow: enter the level (live run, editor test-play, or even a replay of a blessed
/// demo run — the pose capture works in all of them), type <c>ghost_record</c>, perform the movement
/// demo, then <c>ghost_save</c> (editor assembly) to write <c>Assets/ghosts/&lt;level-id&gt;.json</c>.
/// Recording auto-stops on death, on the run/stage ending, or at the length cap, and the captured
/// buffer is HELD until saved, discarded, or a new recording starts — so dying right after a good
/// take doesn't lose it. Leading/trailing idle is trimmed on save (a short pad is kept).</para>
/// </summary>
public static class GhostRecorder
{
	/// <summary>Hard cap on recorded length — a tutorial loop should be seconds, not minutes.</summary>
	public const int MAX_FRAMES = Arena.TICK_RATE * 45;

	/// <summary>The only level whose ghost records <see cref="GhostFxKind.Jump"/> callouts.</summary>
	public const string JUMP_CALLOUT_LEVEL = "high-jump";

	private const int LEAD_PAD_FRAMES = 18;
	private const int TAIL_PAD_FRAMES = 30;

	private static readonly List<GhostPose> _poses = new();
	private static readonly List<GhostFx> _fx = new();
	private static GameStage _stage;
	private static string _levelId;
	private static string _characterId;

	public static bool IsRecording { get; private set; }
	public static int FrameCount => _poses.Count;

	/// <summary>Console command: toggle ghost recording for the current run.</summary>
	[ConCmd( "ghost_record" )]
	public static void GhostRecordCmd()
	{
		if ( !Game.IsEditor ) return;
		if ( IsRecording )
		{
			Stop( "stopped" );
			return;
		}

		if ( GameManager.Instance?.Stage is not GameStage stage || stage.Player is null || stage.Player.IsDead )
		{
			Log.Warning( "ghost_record: start it during a run (live, test-play or replay), with the player alive." );
			return;
		}

		_poses.Clear();
		_fx.Clear();
		_stage = stage;
		_levelId = stage.Level?.Id;
		_characterId = stage.Player.Character?.Id;
		IsRecording = true;
		Log.Info( $"[BlockParty] ghost recording started on '{_levelId}' as '{_characterId}' — " +
			$"ghost_record again (or ghost_save) to finish; cap {MAX_FRAMES / Arena.TICK_RATE}s." );
	}

	/// <summary>Console command: drop the held recording (and stop, if still recording).</summary>
	[ConCmd( "ghost_discard" )]
	public static void GhostDiscardCmd()
	{
		if ( !Game.IsEditor ) return;
		IsRecording = false;
		_stage = null;
		_poses.Clear();
		_fx.Clear();
		Log.Info( "[BlockParty] ghost recording discarded." );
	}

	/// <summary>Capture the pose this sim step ends on. Called at the end of every
	/// <see cref="GameStage.Tick"/>; a no-op unless a recording is active on that exact stage
	/// (a restart, replay rebuild, or scrub re-sim swaps the stage object and cleanly ends the take).</summary>
	public static void CaptureStep( GameStage stage )
	{
		if ( !IsRecording )
			return;
		if ( stage != _stage || stage.Player is null || stage.Player.IsDead )
		{
			Stop( "run ended" );
			return;
		}
		if ( _poses.Count >= MAX_FRAMES )
		{
			Stop( "length cap reached" );
			return;
		}
		_poses.Add( stage.Player.CaptureGhostPose() );
	}

	/// <summary>Record an ability visual (blink burst, etc.) fired by <paramref name="player"/> this
	/// sim step. Abilities call this next to their particle spawns; it's a no-op unless a recording
	/// is active on that player's stage, for that player (impostors and twins never record).</summary>
	public static void Emit( Player player, GhostFxKind kind, Vector2 a, Vector2 b = default )
	{
		if ( !IsRecording || player is null || _stage is null || player.Stage != _stage || player != _stage.Player )
			return;
		// "JUMP" callouts are a high-jump-only teaching aid: other levels' clips never record them, so
		// re-recording those ghosts can't grow labels by accident.
		if ( kind == GhostFxKind.Jump && _levelId != JUMP_CALLOUT_LEVEL )
			return;
		// CaptureStep appends this step's pose after the abilities have run, so the event lands on the
		// frame about to be recorded.
		_fx.Add( new GhostFx( _poses.Count, kind, a, b ) );
	}

	/// <summary>Finish the recording (if still running), trim the idle lead-in/tail, and encode the
	/// clip. Null when nothing was recorded or the player never moved. The buffer is kept, so a
	/// failed save can retry.</summary>
	public static GhostClip BuildClip()
	{
		if ( IsRecording )
			Stop( "finishing for save" );
		if ( _poses.Count == 0 )
			return null;

		// Trim: find the first and last frames that differ from the resting start/end pose, then keep
		// a short pad around them so the loop still breathes instead of starting mid-motion.
		int first = 0;
		while ( first < _poses.Count && _poses[first] == _poses[0] )
			first++;
		if ( first >= _poses.Count && _fx.Count == 0 )
		{
			Log.Warning( "[BlockParty] ghost recording never moved — nothing to save." );
			return null;
		}
		int last = _poses.Count - 1;
		while ( last >= 0 && _poses[last] == _poses[^1] )
			last--;
		// Ability visuals count as activity too, so a burst fired from a standstill isn't trimmed off.
		if ( _fx.Count > 0 )
		{
			first = Math.Min( first, _fx[0].Frame );
			last = Math.Max( last, _fx[^1].Frame );
		}

		first = Math.Max( 0, first - LEAD_PAD_FRAMES );
		last = Math.Min( _poses.Count - 1, last + TAIL_PAD_FRAMES );
		var fx = new List<GhostFx>();
		foreach ( var e in _fx )
			if ( e.Frame >= first && e.Frame <= last )
				fx.Add( e with { Frame = e.Frame - first } );
		return GhostClip.FromPoses( _levelId, _characterId, _poses.GetRange( first, last - first + 1 ), fx );
	}

	private static void Stop( string reason )
	{
		if ( !IsRecording )
			return;
		IsRecording = false;
		_stage = null;
		Log.Info( $"[BlockParty] ghost recording {reason}: {_poses.Count} frames " +
			$"({_poses.Count / (float)Arena.TICK_RATE:0.0}s) held — ghost_save writes it, ghost_discard drops it." );
	}
}