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/<level-id>.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." );
}
}