Game/GhostClip.cs
using System.Collections.Generic;
using System.Linq;

namespace BlockParty;

/// <summary>
/// One recorded tutorial-ghost pose: everything needed to redraw the player's body sprite exactly as
/// it looked on that sim step — the pixel-aligned sprite center, the animation, the flips, and (for
/// the rotated poses: surface gravity, charged wall/ceiling jumps) the sprite's angles with
/// billboarding off. POSES are recorded rather than inputs on purpose: input playback only reproduces
/// a run from a full reseed (the leaderboard replay path), while a ghost must coexist with a live
/// player whose world has long since diverged — a pose stream can never desync.
/// </summary>
public readonly record struct GhostPose( Vector2 Center, string Anim, bool FlipH, bool FlipV, bool Rotated, Angles Rot );

/// <summary>Kinds of visuals a ghost replays alongside its poses — particles and callouts only;
/// audio, hit-stop and haptics stay with the live player. Ability kinds play back next to the
/// ability that emits them (see <see cref="BlinkerAbility.PlayGhostFx"/>); <see cref="Jump"/> is
/// the tutorial "JUMP" callout (<see cref="GhostJumpLabel"/>) emitted by ground and wall jumps.</summary>
public enum GhostFxKind { BlinkPrepare = 0, Blink = 1, Jump = 2 }

/// <summary>One recorded visual, fired on pose frame <see cref="Frame"/> with world vectors
/// <see cref="A"/>/<see cref="B"/> (meaning per kind: blink = start/destination point; jump =
/// launch position/launch direction away from the surface).</summary>
public readonly record struct GhostFx( int Frame, GhostFxKind Kind, Vector2 A, Vector2 B );

/// <summary>
/// A saved tutorial-ghost recording for one level: a per-sim-step pose stream for one character,
/// stored as <c>Assets/ghosts/&lt;level-id&gt;.json</c> and looped on that level as a translucent
/// movement demo (see <see cref="GhostPlayback"/>). The frame encoding is delta-shaped to stay small:
/// each frame is a float array of length 2 (<c>[x,y]</c> — animation/flips/rotation carry over from
/// the previous frame), 4 (<c>[x,y,flags,anim]</c> — rotation resets to none), or 7
/// (<c>[x,y,flags,anim,pitch,yaw,roll]</c>). <c>anim</c> indexes <see cref="Anims"/>. Positions move
/// every frame; everything else changes rarely, so almost all frames are length 2. Ability visuals
/// (<see cref="Fx"/>) ride alongside as <c>[frame,kind,ax,ay,bx,by]</c> events.
/// </summary>
public sealed class GhostClip
{
	/// <summary>Schema version. Pre-release rule: no legacy decoding — bump and re-record.</summary>
	public const int CURRENT_VERSION = 1;

	/// <summary>Mounted folder the clips live in (<c>Assets/ghosts</c>).</summary>
	public const string DIR = "ghosts";

	public const int FLAG_FLIP_H = 1;
	public const int FLAG_FLIP_V = 2;
	public const int FLAG_ROTATED = 4;

	public int Version { get; set; } = CURRENT_VERSION;

	/// <summary>The level this ghost plays on (the file is also named after it).</summary>
	public string LevelId { get; set; }

	/// <summary>The character the clip was recorded as — the ghost always renders THIS character's
	/// sprite, regardless of who the live player picked (the demo is character-specific).</summary>
	public string CharacterId { get; set; }

	/// <summary>Hand-authored dismissal rule: once the live player's body is entirely above this
	/// level obstacle (index into <see cref="LevelDef.Obstacles"/>, the editor's numbering), the
	/// ghost finishes its current pass and never loops again for that run — the demo has been
	/// understood. Null = loop forever. <c>ghost_save</c> carries it over when a clip is re-recorded.</summary>
	public int? DismissAboveObstacle { get; set; }

	/// <summary>Animation name table; frames reference it by index.</summary>
	public List<string> Anims { get; set; } = new();

	/// <summary>The encoded per-step frames (see the class summary for the layout).</summary>
	public List<float[]> Frames { get; set; } = new();

	/// <summary>Encoded ability visuals, <c>[frame,kind,ax,ay,bx,by]</c> each (see <see cref="GhostFx"/>).</summary>
	public List<float[]> Fx { get; set; } = new();

	private static float Round( float v ) => MathF.Round( v * 100f ) / 100f;

	/// <summary>Encode a captured pose list into a clip. Null when there's nothing to encode.</summary>
	public static GhostClip FromPoses( string levelId, string characterId, IReadOnlyList<GhostPose> poses, IReadOnlyList<GhostFx> fx = null )
	{
		if ( poses is not { Count: > 0 } )
			return null;

		var clip = new GhostClip { LevelId = levelId, CharacterId = characterId };
		var animIndex = new Dictionary<string, int>();
		int prevFlags = -1, prevAnim = -1;
		Angles prevRot = Angles.Zero;
		foreach ( var pose in poses )
		{
			string animName = string.IsNullOrEmpty( pose.Anim ) ? "idle" : pose.Anim;
			if ( !animIndex.TryGetValue( animName, out int anim ) )
			{
				anim = clip.Anims.Count;
				clip.Anims.Add( animName );
				animIndex[animName] = anim;
			}

			int flags = (pose.FlipH ? FLAG_FLIP_H : 0)
				| (pose.FlipV ? FLAG_FLIP_V : 0)
				| (pose.Rotated ? FLAG_ROTATED : 0);
			float x = Round( pose.Center.x ), y = Round( pose.Center.y );
			Angles rot = pose.Rotated
				? new Angles( Round( pose.Rot.pitch ), Round( pose.Rot.yaw ), Round( pose.Rot.roll ) )
				: Angles.Zero;

			if ( flags == prevFlags && anim == prevAnim && rot == prevRot )
				clip.Frames.Add( new[] { x, y } );
			else if ( pose.Rotated )
				clip.Frames.Add( new[] { x, y, flags, anim, rot.pitch, rot.yaw, rot.roll } );
			else
				clip.Frames.Add( new[] { x, y, flags, anim } );

			prevFlags = flags;
			prevAnim = anim;
			prevRot = rot;
		}
		if ( fx is not null )
			foreach ( var e in fx )
				clip.Fx.Add( new[] { e.Frame, (float)e.Kind, Round( e.A.x ), Round( e.A.y ), Round( e.B.x ), Round( e.B.y ) } );
		return clip;
	}

	/// <summary>Expand the encoded frames back into render-ready poses. Null when the clip is a shape
	/// this build doesn't understand (wrong version, malformed frame, bad anim index) — the caller
	/// then just plays no ghost, mirroring how an unreplayable run greys out instead of desyncing.</summary>
	public List<GhostPose> DecodePoses()
	{
		if ( Version != CURRENT_VERSION || Frames is not { Count: > 0 } || Anims is not { Count: > 0 } )
			return null;

		var poses = new List<GhostPose>( Frames.Count );
		int flags = -1, anim = -1;
		Angles rot = Angles.Zero;
		foreach ( var f in Frames )
		{
			if ( f is not { Length: 2 or 4 or 7 } )
				return null;
			foreach ( var v in f )
				if ( !float.IsFinite( v ) )
					return null;
			if ( f.Length == 2 && flags < 0 )
				return null; // the first frame must carry full state

			if ( f.Length >= 4 )
			{
				flags = (int)f[2];
				anim = (int)f[3];
				if ( anim < 0 || anim >= Anims.Count )
					return null;
				rot = f.Length == 7 ? new Angles( f[4], f[5], f[6] ) : Angles.Zero;
			}

			poses.Add( new GhostPose( new Vector2( f[0], f[1] ), Anims[anim],
				(flags & FLAG_FLIP_H) != 0, (flags & FLAG_FLIP_V) != 0, (flags & FLAG_ROTATED) != 0, rot ) );
		}
		return poses;
	}

	/// <summary>Expand the encoded ability visuals, in frame order (a clip without any yields an empty
	/// list). Null on a malformed event or one aimed past the last frame — same contract as
	/// <see cref="DecodePoses"/>.</summary>
	public List<GhostFx> DecodeFx()
	{
		var fx = new List<GhostFx>( Fx?.Count ?? 0 );
		if ( Fx is null )
			return fx;
		int frameCount = Frames?.Count ?? 0;
		foreach ( var f in Fx )
		{
			if ( f is not { Length: 6 } )
				return null;
			foreach ( var v in f )
				if ( !float.IsFinite( v ) )
					return null;
			int frame = (int)f[0], kind = (int)f[1];
			if ( frame < 0 || frame >= frameCount || !Enum.IsDefined( typeof( GhostFxKind ), kind ) )
				return null;
			fx.Add( new GhostFx( frame, (GhostFxKind)kind, new Vector2( f[2], f[3] ), new Vector2( f[4], f[5] ) ) );
		}
		return fx.OrderBy( e => e.Frame ).ToList();
	}
}

/// <summary>
/// Registry of the shipped tutorial-ghost clips, loaded lazily per level id from the mounted
/// <c>ghosts/</c> folder and cached (nulls too, so levels without a ghost don't re-stat the file
/// system every stage entry). <c>ghost_save</c> primes a just-recorded clip via <see cref="Apply"/>
/// so it plays immediately, without waiting for the asset mount to see the new physical file
/// (the same trick <c>level_save</c> uses via <c>Levels.ApplySavedLevel</c>).
/// </summary>
public static class Ghosts
{
	private static readonly Dictionary<string, GhostClip> _cache = new();

	/// <summary>The ghost clip for this level, or null when it has none (or its file is invalid).</summary>
	public static GhostClip Get( string levelId )
	{
		if ( string.IsNullOrEmpty( levelId ) )
			return null;
		if ( _cache.TryGetValue( levelId, out var cached ) )
			return cached;

		GhostClip clip = null;
		string path = $"{GhostClip.DIR}/{levelId}.json";
		try
		{
			if ( FileSystem.Mounted.FileExists( path ) )
			{
				clip = Json.Deserialize<GhostClip>( FileSystem.Mounted.ReadAllText( path ) );
				if ( clip is not null && (clip.DecodePoses() is null || clip.DecodeFx() is null) )
				{
					Log.Warning( $"[BlockParty] ghost '{path}' is invalid (wrong version or malformed frames) — ignored. Re-record it." );
					clip = null;
				}
			}
		}
		catch ( System.Exception e )
		{
			Log.Warning( $"[BlockParty] ghost '{path}' failed to load: {e.Message}" );
			clip = null;
		}

		_cache[levelId] = clip;
		return clip;
	}

	/// <summary>Prime the cache with a clip already in memory (editor save path).</summary>
	public static void Apply( GhostClip clip )
	{
		if ( string.IsNullOrEmpty( clip?.LevelId ) )
			return;
		_cache[clip.LevelId] = clip;
	}

	/// <summary>Console command: forget every cached clip so the next stage entry re-reads the
	/// <c>Assets/ghosts</c> files (after hand-deleting one, or once the mount catches up).</summary>
	[ConCmd( "reload_ghosts" )]
	public static void ReloadGhostsCmd()
	{
		if ( !Game.IsEditor ) return;
		_cache.Clear();
		Log.Info( "[BlockParty] ghost clips cleared; they reload from Assets/ghosts on next use." );
	}
}