Rendering/ReplayReactionDisplay.cs
using System;
using Sandbox;

namespace BlockParty;

/// <summary>Presentation math shared by the reaction and its avatar. Absolute replay time keeps
/// fading, drift and hit targets together across pauses, speed changes and backward seeks.</summary>
public static class ReplayReactionDisplay
{
	public const int Lifetime = Arena.TICK_RATE * 2;
	public const int FadeInFrames = 6;
	public const int AvatarSize = 11;
	public const int Gap = 2;
	public readonly record struct Layout( float Left, float Top, int Width, int Height, float Alpha );

	public static bool IsVisible( GameManager manager, GameStage stage ) =>
		manager is { IsReplaying: true, IsHijacked: false, IsGifPreviewActive: false }
		&& manager.Stage == stage && manager.Camera.IsValid()
		&& stage is { MenuOpen: false, IsCharacterHelpVisible: false, LeaveConfirmOpen: false, ReplayImportOpen: false };

	public static Vector2 EffectCenter( Layout layout, ReplayReactionEffect effect ) => new(
		layout.Left + AvatarSize + Gap + effect.Width * 0.5f,
		layout.Top - MathF.Floor( (layout.Height - effect.Height) * 0.5f ) - effect.Height * 0.5f );

	public static float Opacity( int age ) => Math.Clamp( age / (float)FadeInFrames, 0f, 1f )
		* Math.Clamp( (Lifetime - age) / (Lifetime * 0.5f), 0f, 1f );

	/// <summary>Keep overlapping bodies readable beneath the whole reaction/avatar pair.</summary>
	public static float OccludedOpacity( Layout layout, GameStage stage )
	{
		if ( stage is null || layout.Alpha <= 0.3f ) return layout.Alpha;
		var bounds = new RectF( layout.Left, layout.Top - layout.Height, layout.Left + layout.Width, layout.Top );
		// Players includes both twins; Impostors includes friendly copies and hostile clones.
		foreach ( var player in stage.Players )
			if ( Overlaps( player, bounds ) ) return 0.3f;
		foreach ( var clone in stage.Impostors )
			if ( Overlaps( clone, bounds ) ) return 0.3f;
		return layout.Alpha;
	}

	private static bool Overlaps( Player player, RectF bounds )
	{
		if ( player is null || player.IsDead ) return false;
		// Use the displayed position, including attachment pixel snapping, without
		// altering simulation state. Re-evaluate every render and after replay seeks.
		var position = player.WorldPosition;
		return bounds.Intersects( player.GetRect( position.x, position.y ) );
	}

	public static Layout At( ReplayReactionEffect effect, Vector2 origin, int age, bool preview = false )
	{
		float t = Math.Clamp( age / (float)Lifetime, 0f, 1f );
		int width = AvatarSize + Gap + effect.Width;
		int height = Math.Max( AvatarSize, effect.Height );
		float drift = preview ? 0f : 10f * (1f - (1f - t) * (1f - t));
		// The click anchors the effect; reserve space for the avatar on its left, then clamp
		// the entire pair to the playfield. Every art edge lands on a logical pixel boundary.
		float left = Math.Clamp( MathF.Round( origin.x - effect.Width * 0.5f - AvatarSize - Gap ),
			Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE - width );
		float top = Math.Clamp( MathF.Round( origin.y + height * 0.5f + drift ),
			Arena.WALL_SIZE + height, Arena.HEIGHT - Arena.WALL_SIZE );
		return new Layout( left, top, width, height, preview ? 0.65f : Opacity( age ) );
	}
}