Demos/ComposableHud/PlayerHealthView.cs
using System;
using System.Linq;
using Goo;
using Goo.Animation;
using Sandbox;
using Sandbox.UI;
namespace Sandbox.ComposableHud;
// Prominent player HP bar that mirrors a real PlayerHealth component (damaged by engine TriggerHurt volumes), found in the scene via Scene.GetAllComponents. Falls back to a self-contained demo if no PlayerHealth is present: press H to take damage, J to heal. Smooth drain + a white flash on each hit.
sealed class PlayerHealthView
{
const float BarWidth = 360f;
const float BarHeight = 22f;
const float MaxFallback = 100f;
const float DrainSmooth = 0.14f;
const float FlashSmooth = 0.09f;
const float FlashPeak = 0.8f;
const float DebugHit = 12f;
const float DebugHeal = 18f;
static readonly Color LowHealth = new( 0.90f, 0.22f, 0.22f );
static readonly Color HighHealth = new( 0.35f, 0.85f, 0.45f );
static readonly Color NameColor = new( 0.95f, 0.97f, 1f );
SmoothFloat _shown = new( 1f, DrainSmooth );
SmoothFloat _flash = new( 0f, FlashSmooth );
float _cur = MaxFallback;
float _max = MaxFallback;
float _last = MaxFallback;
bool _hPrev, _jPrev;
bool _dirty = true;
void Invalidate() => _dirty = true;
public void Reset()
{
_shown = new SmoothFloat( 1f, DrainSmooth );
_flash = new SmoothFloat( 0f, FlashSmooth );
_cur = _max = _last = MaxFallback;
_hPrev = _jPrev = false;
Invalidate();
}
public bool Tick( Scene? scene, float dt )
{
var hp = scene?.GetAllComponents<PlayerHealth>().FirstOrDefault();
if ( hp is not null )
{
_max = MathF.Max( 1f, hp.MaxHealth );
_cur = Math.Clamp( hp.Health, 0f, _max );
}
else
{
// Standalone demo when no PlayerHealth is wired up: H hurts, J heals.
bool h = Sandbox.Input.Keyboard.Down( "H" );
bool j = Sandbox.Input.Keyboard.Down( "J" );
if ( h && !_hPrev ) _cur = Math.Clamp( _cur - DebugHit, 0f, _max );
if ( j && !_jPrev ) _cur = Math.Clamp( _cur + DebugHeal, 0f, _max );
_hPrev = h; _jPrev = j;
}
if ( _cur < _last - 0.01f ) { _flash.Current = 1f; Invalidate(); } // flash on damage
_last = _cur;
_shown.Target = _cur / _max; _shown.Update( dt );
_flash.Target = 0f; _flash.Update( dt );
if ( !_shown.IsSettled || !_flash.IsSettled ) Invalidate();
bool d = _dirty; _dirty = false; return d;
}
public Container Build()
{
float frac = Math.Clamp( _shown.Current, 0f, 1f );
var root = new Container
{
Key = "player-hp",
FlexDirection = FlexDirection.Column,
Gap = 4f,
PointerEvents = PointerEvents.None,
};
// "Player" left, current/max right-aligned on the same line.
var label = new Container
{
Key = "label",
FlexDirection = FlexDirection.Row,
Width = BarWidth,
JustifyContent = Justify.SpaceBetween,
AlignItems = Align.Center,
};
label.Children.Add( new Text( "Player" ) { Key = "name", FontSize = 15f, FontColor = NameColor } );
label.Children.Add( new Text( $"{(int)MathF.Round( _cur )}/{(int)MathF.Round( _max )}" )
{ Key = "hp", FontSize = 14f, FontColor = NameColor } );
root.Children.Add( label );
var track = new Container
{
Key = "track",
Position = PositionMode.Relative,
Width = BarWidth,
Height = BarHeight,
BackgroundColor = Color.Black.WithAlpha( 0.55f ),
BorderRadius = 5f,
Overflow = OverflowMode.Hidden,
};
// Round the fill/flash themselves: s&box overflow:hidden doesn't clip absolutely-
// positioned children to the track's border-radius, so the corners must be set here.
track.Children.Add( new Container
{
Key = "fill",
Position = PositionMode.Absolute,
Top = 0,
Left = 0,
Height = Length.Percent( 100 ),
Width = Length.Percent( frac * 100f ),
BorderRadius = 5f,
BackgroundColor = Color.Lerp( LowHealth, HighHealth, frac ),
} );
track.Children.Add( new Container
{
Key = "flash",
Position = PositionMode.Absolute,
Top = 0,
Left = 0,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
BorderRadius = 5f,
BackgroundColor = Color.White,
Opacity = Math.Clamp( _flash.Current, 0f, 1f ) * FlashPeak,
} );
root.Children.Add( track );
return root;
}
}