FpsUI/FpsHud.cs

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.

NetworkingFile Access
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;
    }
}