Rendering/ReplayReactionSprites.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>Draw reaction art as point-filtered world sprites, like the tutorial JUMP callout.
/// UI background sizing can resample narrow glyph strokes before the CRT quantizes them.
/// Avatars share the sprite pass so whole pairs overlap in order; UI retains the hit targets.</summary>
public sealed class ReplayReactionSprites : Component
{
	public GameStage Stage { get; set; }
	private readonly Dictionary<long, SpriteRenderer> _sprites = new();
	private readonly Dictionary<long, SpriteRenderer> _avatars = new();
	private readonly Dictionary<long, float> _opacityCaps = new();
	private readonly HashSet<long> _active = new();
	private readonly List<long> _expired = new();

	protected override void OnPreRender()
	{
		_active.Clear();
		int order = 0;
		var manager = GameManager.Instance;
		if ( ReplayReactionDisplay.IsVisible( manager, Stage ) )
		{
			if ( manager.CurrentReplayReactions is { } reactions )
				foreach ( var (id, reaction) in reactions.OrderedEntries )
				{
					if ( reaction.IsVisibleAt( manager.ReplayFrame ) )
						Draw( id, ReplayReactionEffects.Find( reaction.Effect ), new Vector2( reaction.X, reaction.Y ),
							manager.ReplayFrame - reaction.Frame, order );
					order++;
				}
			if ( manager.IsPlacingReaction && manager.CanReact && manager.ReactionCursorPosition( out var cursor ) )
				Draw( 0, ReplayReactionEffects.Find( manager.ReactionEffect ), cursor, 0, order, preview: true );
		}

		_expired.Clear();
		foreach ( var (id, sprite) in _sprites )
			if ( !_active.Contains( id ) )
			{
				if ( sprite.IsValid() ) sprite.GameObject.Destroy();
				if ( _avatars.TryGetValue( id, out var avatar ) && avatar.IsValid() ) avatar.GameObject.Destroy();
				_expired.Add( id );
			}
		foreach ( var id in _expired ) { _sprites.Remove( id ); _avatars.Remove( id ); _opacityCaps.Remove( id ); }
	}

	private void Draw( long id, ReplayReactionEffect effect, Vector2 origin, int age, int order, bool preview = false )
	{
		if ( effect is null ) return;
		_active.Add( id );
		var resource = ResourceLibrary.Get<Sprite>( effect.SpritePath );
		float size = Math.Max( effect.Width, effect.Height );
		if ( !_sprites.TryGetValue( id, out var sprite ) || !sprite.IsValid() )
		{
			// SpriteRenderer aspect-fits into a square: a width-by-height quad would scale
			// non-square word art a second time. This matches GhostJumpLabel's 1:1 sizing.
			sprite = SpriteLayer.Add( GameObject, effect.SpritePath, new Vector2( size, size ) );
			sprite.Opaque = false;
			sprite.AlphaCutoff = 0f;
			_sprites[id] = sprite;
		}
		if ( sprite.Sprite != resource )
		{
			sprite.Sprite = resource;
			sprite.Size = new Vector2( size, size );
			sprite.PlayAnimation( "idle" );
		}
		var layout = ReplayReactionDisplay.At( effect, origin, age, preview );
		// Smooth only the overlap cap; the replay-timed appearance/disappearance keeps
		// its original fade. Exponential lerp reaches 95% of the target in ~0.125s.
		float targetCap = ReplayReactionDisplay.OccludedOpacity( layout with { Alpha = 1f }, Stage );
		float cap = _opacityCaps.TryGetValue( id, out var previousCap ) ? previousCap : targetCap;
		cap += (targetCap - cap) * (1f - MathF.Exp( -24f * Time.Delta ));
		_opacityCaps[id] = cap;
		float alpha = Math.Min( layout.Alpha, cap );
		var center = ReplayReactionDisplay.EffectCenter( layout, effect );
		// Separate depths avoid equal-depth sorting ties; orthographic projection preserves XY.
		float depth = Globals.DepthToZ( Globals.DEPTH_TEXT ) + order * SpriteLayer.LAYER_Z_STEP;
		sprite.GameObject.WorldPosition = new Vector3( center.x, center.y, depth );
		sprite.Color = Color.White.WithAlpha( alpha );

		if ( !_avatars.TryGetValue( id, out var avatar ) || !avatar.IsValid() )
		{
			var child = new GameObject();
			child.Name = "ReactionAvatar";
			child.SetParent( GameObject );
			avatar = child.Components.Create<SpriteRenderer>();
			avatar.Sprite = Sprite.FromTexture( Texture.LoadAvatar( preview ? (long)Game.SteamId : id ) );
			avatar.Size = new Vector2( ReplayReactionDisplay.AvatarSize, ReplayReactionDisplay.AvatarSize );
			avatar.TextureFilter = Sandbox.Rendering.FilterMode.Point;
			avatar.IsSorted = true;
			avatar.Opaque = false;
			avatar.AlphaCutoff = 0f;
			_avatars[id] = avatar;
		}
		avatar.GameObject.WorldPosition = new Vector3( layout.Left + ReplayReactionDisplay.AvatarSize * 0.5f,
			layout.Top - layout.Height * 0.5f, depth );
		avatar.Color = Color.White.WithAlpha( alpha );
	}
}