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;
}
}
}