Code/Demos/ComposableHud/SquadHealthView.cs
using System;
using Goo;
using Goo.Animation;
using Sandbox;
using Sandbox.UI;
namespace Sandbox.ComposableHud;
// N ally health bars: each a mock player (avatar icon + name) whose bar flashes on its own damage at an independent, non-harmonic cadence. One view owns N-length spring arrays (the RadialView idiom) so the host sees a single HudElement and never becomes a god-object. Internals: SVG avatar, name label, and a styled bar with two SmoothFloats (smooth health drain + decaying flash).
sealed class SquadHealthView
{
const int N = 6;
const int MaxHp = 100;
const float AvatarSize = 30f;
const float TrackWidth = 200f;
const float TrackHeight = 14f;
const float RowGap = 8f;
const float DamageChunk = 0.2f;
const float DrainSmooth = 0.12f;
const float FlashSmooth = 0.08f;
const float FlashPeak = 0.85f;
const float ReviveFloor = 0.05f;
static readonly Color LowHealth = new( 0.90f, 0.25f, 0.25f );
static readonly Color HighHealth = new( 0.35f, 0.85f, 0.45f );
static readonly Color NameColor = new( 0.92f, 0.94f, 0.98f );
// Alpha lives in the color, not Container.Opacity (Opacity would fade the name/icon too).
static readonly Color RowBg = new Color( 0f, 0f, 0f ).WithAlpha( 0.8f );
// Every ally is the goo mascot (splat) in a different tint. Tint is a CSS color string
// (SvgPanel.Color is string, not Color).
const string Mascot = "goo-blobs/splat.svg";
readonly record struct Ally( string Name, string Tint );
static readonly Ally[] Squad =
{
new( "Jimmy Weiners", "#73a0f2" ),
new( "Jiggalater 9000", "#73d980" ),
new( "Peepee Poopoo Terry", "#f7994d" ),
new( "Thug blaster4321", "#f2d966" ),
new( "Monster Energy Zero Ultra", "#b07cf2" ),
new( "hunter42", "#f274a6" ),
};
// Distinct, non-harmonic periods (seconds) so the bars flash independently with no visual sync.
static readonly float[] Period = { 1.7f, 2.3f, 2.9f, 3.7f, 4.3f, 5.1f };
readonly float[] _health = new float[N];
readonly float[] _timer = new float[N];
readonly SmoothFloat[] _shown = new SmoothFloat[N];
readonly SmoothFloat[] _flash = new SmoothFloat[N];
bool _dirty = true;
void Invalidate() => _dirty = true;
public SquadHealthView() => Reset();
public void Reset()
{
for ( int i = 0; i < N; i++ )
{
_health[i] = 1f;
_timer[i] = i * 0.4f; // stagger the first hits
_shown[i] = new SmoothFloat( 1f, DrainSmooth );
_flash[i] = new SmoothFloat( 0f, FlashSmooth );
}
Invalidate();
}
public bool Tick( Scene? scene, float dt )
{
for ( int i = 0; i < N; i++ )
{
_timer[i] += dt;
if ( _timer[i] >= Period[i] )
{
_timer[i] -= Period[i];
_health[i] = _health[i] - DamageChunk <= ReviveFloor ? 1f : _health[i] - DamageChunk;
_flash[i].Current = 1f; // instant peak; decays toward Target 0
Invalidate();
}
_shown[i].Target = _health[i]; _shown[i].Update( dt );
_flash[i].Target = 0f; _flash[i].Update( dt );
}
if ( NeedsRebuild() ) Invalidate();
bool d = _dirty; _dirty = false; return d;
}
// Idle between hits: once every bar's drain and flash settle, stop rebuilding.
bool NeedsRebuild()
{
for ( int i = 0; i < N; i++ )
if ( !_shown[i].IsSettled || !_flash[i].IsSettled ) return true;
return false;
}
public Container Build()
{
// One dark backing behind the whole stack for bright-scene readability. Alpha is in the color, not Container.Opacity (Opacity would fade the names/icons/numbers too).
var squad = new Container
{
Key = "squad",
FlexDirection = FlexDirection.Column,
Gap = RowGap,
Padding = 8f,
BackgroundColor = RowBg,
BorderRadius = 8f,
};
for ( int i = 0; i < N; i++ )
squad.Children.Add( Row( i ) );
return squad;
}
Container Row( int i )
{
var row = new Container
{
Key = $"bar-{i}",
FlexDirection = FlexDirection.Row,
AlignItems = Align.Center,
Gap = 8f,
PointerEvents = PointerEvents.None,
};
row.Children.Add( new Goo.SvgPanel
{
Key = "avatar",
Path = Mascot,
Color = Squad[i].Tint,
Width = AvatarSize,
Height = AvatarSize,
} );
// Name on the left, current/max HP right-aligned on the same line (Row + SpaceBetween).
int cur = (int)MathF.Round( Math.Clamp( _shown[i].Current, 0f, 1f ) * MaxHp );
var nameRow = new Container
{
Key = "nameRow",
FlexDirection = FlexDirection.Row,
Width = TrackWidth,
JustifyContent = Justify.SpaceBetween,
AlignItems = Align.Center,
};
nameRow.Children.Add( new Text( Squad[i].Name ) { Key = "name", FontSize = 13f, FontColor = NameColor } );
nameRow.Children.Add( new Text( $"{cur}/{MaxHp}" ) { Key = "hp", FontSize = 12f, FontColor = NameColor } );
var meta = new Container { Key = "meta", FlexDirection = FlexDirection.Column, Gap = 3f };
meta.Children.Add( nameRow );
meta.Children.Add( Bar( i ) );
row.Children.Add( meta );
return row;
}
Container Bar( int i )
{
float h = Math.Clamp( _shown[i].Current, 0f, 1f );
var track = new Container
{
Key = "track",
Position = PositionMode.Relative,
Width = TrackWidth,
Height = TrackHeight,
BackgroundColor = Color.Black.WithAlpha( 0.5f ),
BorderRadius = 4f,
Overflow = OverflowMode.Hidden,
};
// Health fill, smooth-drained via _shown[i]. Round it directly: s&box overflow:hidden
// doesn't clip absolutely-positioned children to the track's border-radius.
track.Children.Add( new Container
{
Key = "fill",
Position = PositionMode.Absolute,
Top = 0,
Left = 0,
Height = Length.Percent( 100 ),
Width = Length.Percent( h * 100f ),
BorderRadius = 4f,
BackgroundColor = Color.Lerp( LowHealth, HighHealth, h ),
} );
// Per-instance white damage flash over the whole track; decays independently.
track.Children.Add( new Container
{
Key = "flash",
Position = PositionMode.Absolute,
Top = 0,
Left = 0,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
BorderRadius = 4f,
BackgroundColor = Color.White,
Opacity = Math.Clamp( _flash[i].Current, 0f, 1f ) * FlashPeak,
} );
return track;
}
}