Code/Demos/ComposableHud/DamageIndicatorView.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Goo;
using Sandbox;
using Sandbox.UI;
namespace Sandbox.ComposableHud;
// Transient damage-direction indicators that spawn on real hits and fade/despawn on their own timers (dynamic mount/unmount + keying). Each hit is a filled wedge (Sector) pointing from the on-screen direction of the damage source toward the crosshair. Direction is read from PlayerHealth.LastDamageOrigin, transformed into a screen bearing against camera yaw, and snapped to the 8 compass points. Each live wedge is keyed by a monotonic spawn id (not its list index) so the reconciler mounts/unmounts wedges without restarting survivors' animation (keyed-list contract: index-keying would reassign identity as wedges expire).
sealed class DamageIndicatorView
{
const float FrameSize = 440f;
const float InnerRadius = 0.52f; // fractions of the frame box (Sector convention)
const float OuterRadius = 0.92f;
const float CornerRad = 0.04f; // crisp arc edges (higher reads as blobby/splatty)
const float HalfSpanDeg = 32f; // wide classic damage arc (+/- around its direction)
const float Lifetime = 1.6f; // long enough to read the number
const float FadeFraction = 0.35f; // hold full opacity, then fade over the last 35% of life
const float PunchWindow = 0.15f; // pop happens in this many seconds, independent of lifetime
const float PunchScale = 0.12f;
// Floating damage number, centred on the wedge band's centerline.
const float NumberRadius = (InnerRadius + OuterRadius) * 0.25f * FrameSize; // band centerline, px
const float NumberRise = 8f; // gentle outward drift as it fades
const float NumberBox = 104f;
const float NumberFont = 38f;
static readonly Color NumberColor = new( 1f, 0.92f, 0.86f );
// Tunables for mapping world bearing -> screen angle (0 = up/forward, clockwise+). s&box yaw is
// CCW-positive, so the camera-relative bearing is negated; flip DirectionSign if it mirrors.
const float DirectionSign = 1f;
const float AngleOffset = 0f;
const bool SnapToCardinal = true;
static readonly Color WedgeColor = new( 1f, 0.28f, 0.16f );
readonly record struct Hit( int Id, float AngleDeg, float SpawnTime, int Amount );
readonly List<Hit> _active = new();
float _now;
int _nextId;
int _seen;
bool _synced;
bool _dirty = true;
void Invalidate() => _dirty = true;
public void Reset()
{
_active.Clear();
_now = 0f;
_nextId = 0;
_seen = 0;
_synced = false;
Invalidate();
}
public bool Tick( Scene? scene, float dt )
{
_now += dt;
var hp = scene?.GetAllComponents<PlayerHealth>().FirstOrDefault();
if ( hp is not null )
{
// Sync the event counter on first sight so a pre-existing count doesn't spawn a wedge.
if ( !_synced ) { _seen = hp.DamageEvents; _synced = true; }
else if ( hp.DamageEvents != _seen )
{
_seen = hp.DamageEvents;
float angle = BearingToScreen( hp.WorldPosition, hp.LastDamageOrigin, scene?.Camera );
_active.Add( new Hit( _nextId++, angle, _now, (int)MathF.Round( hp.LastDamageAmount ) ) );
Invalidate();
}
}
if ( _active.RemoveAll( h => _now - h.SpawnTime >= Lifetime ) > 0 )
Invalidate();
if ( _active.Count > 0 ) Invalidate(); // alive wedges are animating
bool d = _dirty; _dirty = false; return d;
}
// World direction (player -> source) mapped to a screen bearing, relative to camera facing.
static float BearingToScreen( Vector3 player, Vector3 source, CameraComponent? cam )
{
var dir = source - player;
float worldYaw = MathF.Atan2( dir.y, dir.x ) * (180f / MathF.PI); // CCW from +X (s&box yaw)
float camYaw = cam?.WorldRotation.Angles().yaw ?? 0f;
float screen = Normalize360( DirectionSign * (camYaw - worldYaw) + AngleOffset );
return SnapToCardinal ? MathF.Round( screen / 45f ) * 45f : screen;
}
static float Normalize360( float deg ) => ((deg % 360f) + 360f) % 360f;
public Container Build()
{
var frame = new Container
{
Key = "dmg-frame",
Position = PositionMode.Relative,
Width = FrameSize,
Height = FrameSize,
PointerEvents = PointerEvents.None,
};
foreach ( var h in _active )
{
frame.Children.Add( Wedge( h ) );
frame.Children.Add( Number( h ) );
}
return frame;
}
// Floating "-N" damage number, upright (not rotated), inside the arc toward the crosshair.
Container Number( Hit h )
{
float t = Math.Clamp( (_now - h.SpawnTime) / Lifetime, 0f, 1f );
float rad = h.AngleDeg * MathF.PI / 180f;
float r = NumberRadius + NumberRise * t;
float cx = FrameSize * 0.5f + MathF.Sin( rad ) * r;
float cy = FrameSize * 0.5f - MathF.Cos( rad ) * r;
return new Container
{
Key = $"num-{h.Id}",
Position = PositionMode.Absolute,
Left = cx - NumberBox * 0.5f,
Top = cy - NumberBox * 0.5f,
Width = NumberBox,
Height = NumberBox,
JustifyContent = Justify.Center,
AlignItems = Align.Center,
FontColor = NumberColor,
FontSize = NumberFont,
Opacity = Fade( h ),
PointerEvents = PointerEvents.None,
Children = { new Text( $"-{h.Amount}" ) },
};
}
// Holds full opacity, then fades over the last FadeFraction of the lifetime.
float Fade( Hit h )
{
float t = Math.Clamp( (_now - h.SpawnTime) / Lifetime, 0f, 1f );
return Math.Clamp( (1f - t) / FadeFraction, 0f, 1f );
}
// A direction wedge. Mirrors WedgeRing's proven pattern: rotate a full-bleed wrapper and keep the Sector's own angles positive (0..span). Setting StartAngle negative directly on the Sector rasterizes to nothing, which is why an earlier build showed no wedges.
Container Wedge( Hit h )
{
float age = _now - h.SpawnTime;
float punch = 1f + PunchScale * MathF.Max( 0f, 1f - age / PunchWindow ); // quick pop, then 1
var wrapper = new Container
{
Key = $"dmg-{h.Id}", // stable identity across spawn/despawn
Position = PositionMode.Absolute,
Top = 0,
Left = 0,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
Opacity = Fade( h ),
Transform = Goo.PanelTransform.Rotate( h.AngleDeg - HalfSpanDeg ).Scale( punch ),
PointerEvents = PointerEvents.None,
};
wrapper.Children.Add( new Goo.Sector
{
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
StartAngle = 0f,
EndAngle = 2f * HalfSpanDeg,
InnerRadius = InnerRadius,
OuterRadius = OuterRadius,
CornerRadius = CornerRad,
BackgroundColor = WedgeColor,
} );
return wrapper;
}
}