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