UI component for an FPS heads-up display. Manages HUD models (health, stamina, ammo, crosshair, hitmarker, killfeed, compass, XP, scoreboard), processes input for firing/reloading/sprinting/aim, runs optional hitscan damage, syncs optional viewmodel animgraph, and builds the visual widget tree.
using Goo;
using Goo.Animation;
using Sandbox;
using Sandbox.UI;
namespace Goo.FpsUI;
// One drop-in FPS HUD. Add it to a GameObject under a ScreenPanel, toggle the widgets you want,
// set the knobs, and either leave Demo on (self-running preview) or drive the public methods.
public sealed partial class FpsHud : GooPanel<Container>
{
// ---- General ----
[Property, Group( "General" )] public PlayerController? Player { get; set; } // player to gate sprint on when stamina is empty (null = skip gating)
[Property, Group( "General" )] public Color AccentColor { get; set; } = new( 0.40f, 0.85f, 1f ); // killfeed highlight + UI accents
// ---- Health ----
[Property, Group( "Health" )] public bool ShowHealth { get; set; } = true; // bottom-left health bar
[Property, Group( "Health" ), Range( 1f, 1000f )] public float MaxHealth { get; set; } = 100f; // health bar full value
// ---- Stamina ----
[Property, Group( "Stamina" )] public bool ShowStamina { get; set; } = true; // secondary bar under health
[Property, Group( "Stamina" ), Range( 1f, 1000f )] public float MaxStamina { get; set; } = 100f; // stamina bar full value
[Property, Group( "Stamina" ), Range( 0.05f, 2f )] public float StaminaDrainRate { get; set; } = 0.55f; // fraction of max drained per second while sprinting
// ---- Ammo ----
[Property, Group( "Ammo" )] public bool ShowAmmo { get; set; } = true; // bottom-right ammo counter
[Property, Group( "Ammo" ), Range( 1, 300 )] public int MagSize { get; set; } = 30; // ammo per magazine
[Property, Group( "Ammo" ), Range( 0, 999 )] public int StartingReserve { get; set; } = 120; // ammo in reserve
[Property, Group( "Ammo" ), Range( 60f, 1200f )] public float RoundsPerMinute { get; set; } = 600f; // fire rate cap while holding fire
[Property, Group( "Ammo" )] public bool AutoReload { get; set; } = true; // auto-reload once the magazine empties
[Property, Group( "Ammo" )] public string ReloadKey { get; set; } = "R"; // glyph shown in the low-ammo reload cue
[Property, Group( "Ammo" )] public FireMode FireMode { get; set; } = FireMode.Auto; // semi / burst / auto trigger discipline
[Property, Group( "Ammo" ), Range( 2, 6 )] public int BurstCount { get; set; } = 3; // rounds per pull in Burst mode
[Property, Group( "Ammo" )] public string FireSelectAction { get; set; } = ""; // input action that cycles fire mode (empty = none)
// ---- Crosshair ----
[Property, Group( "Crosshair" )] public bool ShowCrosshair { get; set; } = true; // center crosshair
[Property, Group( "Crosshair" )] public Color CrosshairColor { get; set; } = new( 0.40f, 0.85f, 1f ); // crosshair lines + dot
// ---- Hitmarker ----
[Property, Group( "Hitmarker" )] public bool ShowHitmarker { get; set; } = true; // center hitmarker
[Property, Group( "Hitmarker" )] public Color HitmarkerColor { get; set; } = new( 0.40f, 0.85f, 1f ); // kill tint (normal hits stay white)
[Property, Group( "Hitmarker" )] public SoundPointComponent? HitmarkerSound { get; set; } // SoundPointComponent retriggered the instant a hitscan connects, in sync with the marker (set Force2d for a flat UI cue)
// ---- Hitscan ----
// With Demo off, a live shot traces from the camera and damages whatever IDamageable it hits (the
// bundled FpsTarget, or anything else implementing it). A hit pops the hitmarker; a kill adds a killfeed row + XP.
[Property, Group( "Hitscan" )] public bool Hitscan { get; set; } = true; // trace + damage on each live shot
[Property, Group( "Hitscan" ), Range( 64f, 50000f )] public float HitscanRange { get; set; } = 8192f; // max trace distance (units)
[Property, Group( "Hitscan" ), Range( 1f, 500f )] public float HitscanDamage { get; set; } = 25f; // damage per round that connects
[Property, Group( "Hitscan" ), Range( 0, 1000 )] public int XpPerKill { get; set; } = 100; // XP granted when a hitscan kills a target
[Property, Group( "Hitscan" )] public string LocalPlayerName { get; set; } = "You"; // attacker name shown in the killfeed
// ---- Killfeed ----
[Property, Group( "Killfeed" )] public bool ShowKillfeed { get; set; } = true; // top-right killfeed
// ---- Compass ----
[Property, Group( "Compass" )] public bool ShowCompass { get; set; } = true; // top-center heading strip
[Property, Group( "Compass" )] public bool CompassFollowCamera { get; set; } = true; // when not Demo, point the compass at the active camera yaw
[Property, Group( "Compass" )] public CompassDockMode CompassDock { get; set; } = CompassDockMode.TopBar; // top-edge bar or floating card
// ---- XP ----
[Property, Group( "XP" )] public bool ShowXp { get; set; } = true; // center XP grant feed
// ---- Scoreboard ----
[Property, Group( "Scoreboard" )] public bool ShowScoreboard { get; set; } = true; // top-center gamemode header
[Property, Group( "Scoreboard" )] public ScoreboardMode ScoreMode { get; set; } = ScoreboardMode.Tdm; // gamemode header variant
// ---- Viewmodel ----
// Optional: point at a standard s&box weapon SkinnedModelRenderer and the HUD drives its animgraph
// (attack / reload / sprint / empty / firing_mode / ironsights / lower / pose / bob) from its own
// state plus a couple of key inputs. Leave null for HUD-only.
[Property, Group( "Viewmodel" )] public SkinnedModelRenderer? Viewmodel { get; set; }
[Property, Group( "Viewmodel" ), Range( 0f, 1f )] public float MoveBob { get; set; } = 0.5f; // weapon bob amount (move_bob)
[Property, Group( "Viewmodel" )] public string AimAction { get; set; } = "attack2"; // hold to aim down sights (ironsights)
[Property, Group( "Viewmodel" )] public string LowerWeaponAction { get; set; } = ""; // press to toggle the weapon lowered (empty = off)
[Property, Group( "Viewmodel" )] public ParticleEffect? Muzzle { get; set; } // optional: muzzle-flash effect, replayed on every shot (emitter Loop must be off)
[Property, Group( "Viewmodel" )] public PointLight? MuzzleLight { get; set; } // optional: dynamic light pulsed (bloom + decay) on every shot
[Property, Group( "Viewmodel" )] public SoundPointComponent? MuzzleSound { get; set; } // optional: SoundPointComponent on the muzzle bone, retriggered on every round spent
// m4a1 animgraph enum values. CONFIRM these against the model's animgraph (placeholders for now).
const int PoseHandguardCovers = 1; // weapon_pose: handguard_covers
const int ReloadPull = 1; // reload_type: pull (the m4a1's only reload)
const int IronsightsAimed = 1; // ironsights: aimed
const string FireSelectTrigger = "b_fire_select"; // CONFIRM: the m4a1 selector-switch trigger param name
// ---- Widget models: engine-free state, one per HUD element (the source of truth) ----
readonly HealthModel _health = new();
readonly StaminaModel _stamina = new();
readonly AmmoModel _ammo = new();
readonly FireControlModel _fire = new();
readonly CrosshairModel _crosshair = new();
readonly HitmarkerModel _hitmarker = new();
readonly KillfeedModel _killfeed = new();
readonly CompassModel _compass = new();
readonly XpModel _xp = new();
readonly ScoreboardModel _scoreboard = new();
readonly FpsTheme _t = new();
bool _booted;
// ---- Live-input state (sprint gate, lowered stance, ADS) ----
bool _sprintBlocked; // RunSpeed currently clamped to walk by the stamina gate
float _cachedRunSpeed; // player's RunSpeed, saved while the gate is active so it can be restored
bool _lowered; // weapon lowered stance, toggled by LowerWeaponAction
bool _fireLock; // fire suppressed until the trigger is released (after raising from lowered)
bool _aiming; // aiming down sights this frame (drives ironsights + the crosshair/hitmarker fade)
SmoothFloat _adsFade = new( 1f, 0.08f ); // 1 = crosshair/hitmarker visible, 0 = faded out while aiming
// ---- Viewmodel sync state: edge/delta trackers that turn HUD events into one-frame animgraph pulses ----
bool _shotThisFrame; // a round was spent this frame (detected in Tick, drives weapon effects + b_attack)
int _lastMag; // last frame's Mag; a drop means a round was spent -> pulse b_attack
bool _attackPulsed; // b_attack was pulsed last frame -> clear it this frame
bool _fireSelectChanged; // fire mode just cycled -> pulse the selector-switch trigger
bool _selectorPulsed; // selector trigger was pulsed last frame -> clear it this frame
bool _jumpPulsed; // b_jump was pulsed last frame -> clear it this frame
bool _wasGrounded = true;// previous-frame ground state, to fire b_jump once on takeoff
Color _muzzlePeak; // authored MuzzleLight color, captured as the muzzle-flash peak
DecayFloat _muzzleFlash = new( 0f, 0.03f ); // 1 on a shot, decays to 0 -> muzzle light intensity
void Boot()
{
_t.Accent = AccentColor;
_health.MaxHealth = MaxHealth; _health.Reset();
_stamina.MaxStamina = MaxStamina; _stamina.Reset();
_ammo.MagSize = MagSize; _ammo.AutoReload = AutoReload; _ammo.Reset(); _ammo.SetReserve( StartingReserve );
_fire.Mode = FireMode; _fire.BurstCount = BurstCount; _fire.Rpm = RoundsPerMinute; _fire.Reset();
_scoreboard.Mode = ScoreMode;
_lastMag = _ammo.Mag; // seed so the first frame doesn't read as a shot
if ( MuzzleLight.IsValid() ) { _muzzlePeak = MuzzleLight.LightColor; MuzzleLight.Enabled = false; } // off until a shot
_booted = true;
}
// ---- public API: drive the HUD from your game ----
public void Damage( float a ) => _health.Damage( a );
public void Heal( float a ) => _health.Heal( a );
public void Sprinting( bool on ) => _stamina.SetSprinting( on );
public void Fire() { if ( _ammo.Fire() ) _crosshair.Fire(); } // only bump spread on a real shot (not mid-reload/empty)
public void Reload() => _ammo.Reload();
public void SetFireMode( FireMode mode ) { FireMode = mode; _fire.Mode = mode; } // set the active fire mode
public void CycleFireMode() { _fire.Cycle(); FireMode = _fire.Mode; _fireSelectChanged = true; } // advance semi -> burst -> auto, play the selector anim
public void Hitmarker( bool kill = false ) => _hitmarker.Pop( kill );
public void Kill( string attacker, string victim ) => _killfeed.Add( attacker, victim );
public void Kill( string attacker, KillTeam attackerTeam, string victim, KillTeam victimTeam, bool attackerLocal = false, bool victimLocal = false )
=> _killfeed.Add( attacker, attackerTeam, victim, victimTeam, attackerLocal, victimLocal );
public void SetHeading( float yawDeg ) => _compass.SetHeading( yawDeg );
public void Xp( int amount, string action = "" ) => _xp.Add( amount, action ); // award XP to the feed
public ScoreboardModel Scoreboard => _scoreboard; // mutate scores/timer/captures from your game
// Demo-only seam: implemented in FpsDemo.cs, compiles out when that file is deleted.
partial void StepDemo( float dt, ref bool active );
protected override bool Tick( float dt )
{
if ( !_booted ) Boot();
_t.Accent = AccentColor;
_stamina.DrainRate = StaminaDrainRate; // live-tunable
_crosshair.Gap = 6f; // root uses sensible crosshair defaults, tune via a standalone CrosshairWidget
bool demo = false;
StepDemo( dt, ref demo );
if ( !demo )
{
if ( !string.IsNullOrEmpty( FireSelectAction ) && Sandbox.Input.Pressed( FireSelectAction ) ) CycleFireMode();
_fire.Mode = FireMode; _fire.BurstCount = BurstCount; _fire.Rpm = RoundsPerMinute;
// Lower-weapon stance: X toggles it; while lowered the gun can't fire and the first attack1 press
// only raises the weapon (no shot). Exiting the stance requires releasing attack1 before firing.
bool attackDown = Sandbox.Input.Down( "attack1" );
if ( !string.IsNullOrEmpty( LowerWeaponAction ) && Sandbox.Input.Pressed( LowerWeaponAction ) )
{ _lowered = !_lowered; if ( !_lowered ) _fireLock = true; }
if ( _lowered && Sandbox.Input.Pressed( "attack1" ) ) { _lowered = false; _fireLock = true; } // click raises, no shot
if ( _fireLock && !attackDown ) _fireLock = false; // re-arm once the trigger is released
if ( _fire.Tick( !_lowered && !_fireLock && attackDown, dt ) )
{
int magBefore = _ammo.Mag;
Fire();
if ( Hitscan && _ammo.Mag < magBefore ) RunHitscan(); // trace only on a real shot (not mid-reload/empty)
}
if ( Sandbox.Input.Pressed( "reload" ) ) Reload();
bool sprint = Sandbox.Input.Down( "run" );
Sprinting( sprint );
FpsInput.ApplySprintGate( Player, _stamina.Stamina, ref _sprintBlocked, ref _cachedRunSpeed );
_scoreboard.Mode = ScoreMode;
}
// The compass always follows the active scene camera (no demo spin), disable via CompassFollowCamera.
// Negate yaw: s&box yaw is CCW-positive (left), compass headings are CW-positive (N->E->S->W).
if ( ShowCompass && CompassFollowCamera && Scene?.Camera is { } cam ) _compass.SetHeading( -cam.WorldRotation.Angles().yaw );
// Tick every model every frame (| not || so none are short-circuited).
bool moving = _health.Tick( dt );
moving |= _stamina.Tick( dt ) | _ammo.Tick( dt )
| _crosshair.Tick( dt ) | _hitmarker.Tick( dt ) | _killfeed.Tick( dt ) | _compass.Tick( dt ) | _xp.Tick( dt );
if ( ShowScoreboard ) _scoreboard.Tick( dt ); // count the round clock down
// Aiming down sights fades the crosshair + hitmarker out (and back in on release).
_aiming = !string.IsNullOrEmpty( AimAction ) && Sandbox.Input.Down( AimAction );
_adsFade.Target = _aiming ? 0f : 1f;
moving |= _adsFade.Tick( dt );
// Detect a round spent this frame (works in demo + live), then fire the weapon effects that don't
// need a viewmodel (muzzle flash/light/sound) and mirror state onto the optional viewmodel animgraph.
_shotThisFrame = _ammo.Mag < _lastMag;
_lastMag = _ammo.Mag;
FireWeaponEffects( dt );
SyncViewmodel( dt );
// Keep ticking for the compass spin, the scoreboard clock, and the critical-health pulse (HealthView
// reads Time.Now, so Build must keep running while health is critical or the pulse freezes).
return demo || moving || ShowCompass || ShowScoreboard
|| (ShowHealth && _health.Critical) || (ShowAmmo && _ammo.NeedsReloadHint) || (ShowXp && _xp.Entries.Count > 0);
}
// How viewmodel sync works: the HUD models are the source of truth, and every frame this mirrors their
// state onto the weapon's animgraph. Held state (reload/sprint/empty/aim/grounded) is written directly as
// bools/ints; one-shot events (a shot, a fire-mode switch, a jump) are edge-detected against last frame and
// pulsed as a trigger that's cleared the very next frame. Only these action params are touched - authored
// pose params (finger curls, skeleton, deploy_type, ...) are left exactly as set in the editor.
void SyncViewmodel( float dt )
{
if ( Viewmodel is not { } vm ) return;
// Triggers / one-frame pulses (the shot edge is detected in Tick and shared via _shotThisFrame).
if ( _attackPulsed ) { vm.Set( "b_attack", false ); _attackPulsed = false; } // trigger lasts one frame
if ( _shotThisFrame ) { vm.Set( "b_attack", true ); _attackPulsed = true; }
if ( _selectorPulsed ) { vm.Set( FireSelectTrigger, false ); _selectorPulsed = false; }
if ( _fireSelectChanged ) { vm.Set( FireSelectTrigger, true ); _selectorPulsed = true; _fireSelectChanged = false; } // selector anim
// Held / continuous state
vm.Set( "b_reload", _ammo.Reloading ); // held true across the reload, clears when it finishes
vm.Set( "b_sprint", _stamina.Sprinting );
vm.Set( "b_empty", _ammo.Mag == 0 );
vm.Set( "firing_mode", (int)FireMode + 1 ); // Semi/Burst/Auto -> 1/2/3
bool grounded = Player is { } gp && gp.IsValid() ? gp.IsOnGround : true; // assume grounded with no player
vm.Set( "b_grounded", grounded ); // held: airborne pose holds while false
if ( _jumpPulsed ) { vm.Set( "b_jump", false ); _jumpPulsed = false; } // trigger lasts one frame
if ( _wasGrounded && !grounded ) { vm.Set( "b_jump", true ); _jumpPulsed = true; } // fire once on takeoff
_wasGrounded = grounded;
// move_bob is the bob intensity: scale it by how fast the player is actually moving (0 when still).
// Needs Player assigned; without it there is no velocity source so the gun simply does not bob.
float bob = 0f;
if ( Player is { } p && p.IsValid() )
{
float refSpeed = p.RunSpeed <= 0f ? 1f : p.RunSpeed;
bob = MoveBob * (p.Velocity.WithZ( 0f ).Length / refSpeed).Clamp( 0f, 1f );
}
vm.Set( "move_bob", bob );
vm.Set( "weapon_pose", PoseHandguardCovers );
vm.Set( "reload_type", ReloadPull ); // the m4a1 only has the one reload, always pull
// Manual viewmodel inputs
vm.Set( "ironsights", _aiming ? IronsightsAimed : 0 );
vm.Set( "b_lower_weapon", _lowered ); // toggled / gated in the live-input block
}
// Per-shot weapon effects that do NOT need the viewmodel animgraph: muzzle flash, muzzle-light bloom,
// and the shot sound. Runs every frame (demo + live, viewmodel or not) so the light can decay.
void FireWeaponEffects( float dt )
{
if ( _shotThisFrame )
{
Muzzle?.ResetEmitters(); // replay the muzzle-flash burst
_muzzleFlash.Current = 1f; // kick the muzzle light to full
Retrigger( MuzzleSound ); // replay the muzzle shot sound from its bone
}
// Muzzle light: a quick bloom that decays to nothing after each shot.
if ( MuzzleLight.IsValid() )
{
_muzzleFlash.Target = 0f;
_muzzleFlash.Tick( dt );
float f = _muzzleFlash.Current.Clamp( 0f, 1f );
bool on = f > 0.01f;
MuzzleLight.Enabled = on;
MuzzleLight.LightColor = on ? _muzzlePeak * f : _muzzlePeak; // restore peak when idle so it never persists dark
}
}
// Trace from the camera on a live shot, damage the first IDamageable the ray connects with, and drive
// the matching feedback: pop the hitmarker (kill-tinted on a kill) + play the hit cue, and on a kill add
// a killfeed row and grant XP. Edge-detects the FpsTarget's death so kill feedback fires exactly once.
void RunHitscan()
{
if ( Scene?.Camera is not { } cam ) return;
var origin = cam.WorldPosition;
var trace = Scene.Trace.Ray( origin, origin + cam.WorldRotation.Forward * HitscanRange );
if ( Player.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( Player.GameObject ); // never shoot ourselves
var tr = trace.Run();
if ( !tr.Hit || !tr.GameObject.IsValid() ) return;
// Engine convention (matches TriggerHurt): the damageable lives on the hit object or an ancestor.
// Use whatever IDamageable the model ships with - our FpsTarget, or a standard breakable Prop, etc.
const FindMode find = FindMode.Enabled | FindMode.InSelf | FindMode.InAncestors;
var damageable = tr.GameObject.Components.Get<Component.IDamageable>( find );
if ( damageable is null ) return; // hit world geometry / something that can't be hurt
var hitbox = tr.GameObject.Components.Get<FpsHitbox>( find ); // region the ray landed on (null = body / no hitbox)
// Kill detection per damageable kind: our FpsTarget exposes IsDead; a Prop exposes Health and destroys
// itself when it breaks; for any other IDamageable, fall back to "its GameObject got destroyed".
var target = damageable as FpsTarget;
var prop = damageable as Sandbox.Prop;
var dmgGo = (damageable as Component)?.GameObject ?? tr.GameObject;
string victim = target?.Name ?? dmgGo?.Name ?? "Target"; // capture before a break destroys the object
bool wasAlive = target is not null ? !target.IsDead
: prop is not null ? prop.Health > 0f
: dmgGo.IsValid();
damageable.OnDamage( new DamageInfo
{
Damage = HitscanDamage * (hitbox?.DamageMultiplier ?? 1f), // scale by the hit region (headshot etc.)
Attacker = Player.IsValid() ? Player.GameObject : GameObject,
Weapon = Viewmodel.IsValid() ? Viewmodel.GameObject : null,
Origin = origin,
Position = tr.EndPosition,
} );
bool dead = target is not null ? target.IsDead
: prop is not null ? prop.Health <= 0f
: !dmgGo.IsValid();
bool kill = wasAlive && dead;
Hitmarker( kill );
Retrigger( HitmarkerSound ); // play the hit cue in sync with the marker
if ( kill )
{
_killfeed.Add( LocalPlayerName, KillTeam.Friendly, victim, KillTeam.Enemy, attackerLocal: true );
_xp.Add( XpPerKill, hitbox is not null ? hitbox.Region : "Kill" ); // label the grant (e.g. "Headshot")
}
}
// A SoundPointComponent reuses a single voice, so stop+start forces it to retrigger on every shot/hit.
// (Rapid auto-fire shots cut the previous tail - the tradeoff of a placed sound vs a fresh Sound.Play voice.)
static void Retrigger( SoundPointComponent? s )
{
if ( !s.IsValid() ) return;
s.StopSound();
s.StartSound();
}
CrosshairStyle MakeCrosshairStyle() => new()
{
Color = CrosshairColor, Length = 8f, Thickness = 2f, ShowLines = true, ShowDot = true,
DotSize = 2f, Outline = true, OutlineColor = new Color( 0f, 0f, 0f, 0.7f ), TStyle = false,
};
protected override Container Build()
{
if ( !_booted ) Boot();
var root = Parts.Root( "fpsHud" );
// Bottom-left vitals stacked in one column.
if ( ShowHealth || ShowStamina )
{
var vitals = new Container { Key = "vitals", FlexDirection = FlexDirection.Column, Gap = 10f };
// Health on top, stamina below (column order).
if ( ShowHealth ) vitals.Children.Add( HealthView.Build( _health, _t ) );
if ( ShowStamina ) vitals.Children.Add( StaminaView.Build( _stamina, _t ) );
root.Children.Add( Parts.Anchor( "vitals", Parts.Corner.BottomLeft, _t.Margin, Parts.Panel( "vitalsBg", _t, vitals ) ) );
}
if ( ShowAmmo ) root.Children.Add( Parts.Anchor( "ammo", Parts.Corner.BottomRight, _t.Margin, Parts.Panel( "ammoBg", _t, AmmoView.Build( _ammo, _t, FireMode ) ) ) );
float adsFade = _adsFade.Current.Clamp( 0f, 1f ); // crosshair + hitmarker fade out while aiming
if ( ShowCrosshair ) root.Children.Add( Parts.Anchor( "xhair", Parts.Corner.Center, 0f, CrosshairView.Build( _crosshair, MakeCrosshairStyle(), _t ) ) with { Opacity = adsFade } );
if ( ShowHitmarker ) root.Children.Add( Parts.Anchor( "hit", Parts.Corner.Center, 0f, HitmarkerView.Build( _hitmarker, _t, HitmarkerColor ) ) with { Opacity = adsFade } );
if ( ShowKillfeed ) root.Children.Add( Parts.Anchor( "kf", Parts.Corner.TopRight, _t.Margin, KillfeedView.Build( _killfeed, _t ) ) );
if ( ShowCompass )
{
float compassInset = CompassDock == CompassDockMode.TopBar ? 0f : _t.Margin; // TopBar sits flush to the edge
root.Children.Add( Parts.Anchor( "compass", Parts.Corner.TopCenter, compassInset, CompassView.Build( _compass, _t, CompassDock ) ) );
}
if ( ShowScoreboard )
{
// Dock just under the compass when it is shown, otherwise at the screen margin.
float sbInset = ShowCompass
? (CompassDock == CompassDockMode.TopBar ? 0f : _t.Margin) + CompassView.StripH + 8f
: _t.Margin;
root.Children.Add( Parts.Anchor( "scoreboard", Parts.Corner.TopCenter, sbInset, Parts.Panel( "sbBg", _t, ScoreboardView.Build( _scoreboard, _t ) ) ) );
}
// XP feed floats above the crosshair, the reload cue sits just below it. Both are screen-centered.
if ( ShowXp ) root.Children.Add( Parts.Offset( "xp", XpView.OffsetX, XpView.Drop, XpView.Build( _xp, _t ) ) );
if ( ShowAmmo && _ammo.NeedsReloadHint )
root.Children.Add( Parts.CenterOffset( "reloadCue", AmmoView.ReloadCueDrop, AmmoView.ReloadCue( _t, ReloadKey ) ) );
return root;
}
}