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