FpsUI/Widgets/AmmoWidget.cs

UI widget for an FPS ammo counter. Defines a stateless AmmoView that builds the visual components (mode label, magazine/reserve numbers, reload progress bar, reload cue) and an AmmoWidget panel that manages ammo/fire state, input handling, and ticking, exposing Fire/Reload and fire-mode controls.

NetworkingFile Access
using System;
using Goo;
using Sandbox;
using Sandbox.UI;

namespace Goo.FpsUI;

// Stateless presenter: big magazine number, dim reserve, and a reload progress bar.
static class AmmoView
{
    const float Width    = 124f; // fixed counter width snug for "300 / 999", stops the backing reflowing as digits change
    const float Height   = 80f;  // fixed height so the layout doesn't jump when the mode label / reload bar change
    const float BarHeight = 8f;  // reload bar thickness (chunky so the reload read is obvious)

    public const float ReloadCueDrop = 90f;  // px below screen center for the reload cue (under the crosshair)

    static string ModeLabel( FireMode mode ) => mode switch
    {
        FireMode.Semi  => "SEMI",
        FireMode.Burst => "BURST",
        _              => "AUTO",
    };

    public static Container Build( AmmoModel m, FpsTheme t, FireMode mode = FireMode.Auto )
    {
        Color magColor = m.Reloading ? t.Dim : (m.LowOnAmmo ? t.Warn : t.Ink);

        // Reload bar slot is always present to reserve its height, painting a track and fill only while reloading.
        var bar = new Container
        {
            // In flow at the bottom so it reserves height and never overlaps the number, spanning the full width.
            Key = "rl", Position = PositionMode.Relative, Height = BarHeight, AlignSelf = Align.Stretch,
            BorderRadius = t.Radius, Overflow = OverflowMode.Hidden,
            BackgroundColor = m.Reloading ? t.TrackBg : new Color( 0f, 0f, 0f, 0f ), // transparent when idle
        };
        if ( m.Reloading )
            bar.Children.Add( Parts.FillRect( "rlfill", m.ReloadProgress, t.Accent, t.Radius ) );

        return new Container
        {
            // Fixed footprint: mode label on top, number row centered, reload bar reserving the bottom strip.
            Key = "ammo", Width = Width, Height = Height,
            FlexDirection = FlexDirection.Column, AlignItems = Align.Center, JustifyContent = Justify.Center, Gap = 6f,
            Children =
            {
                new Text( ModeLabel( mode ) )
                    { Key = "mode", FontFamily = t.FontFamily, FontSize = 13f, FontWeight = t.WeightBold, FontColor = t.Dim },
                new Container
                {
                    Key = "row", FlexDirection = FlexDirection.Row, AlignItems = Align.FlexEnd, Gap = 6f,
                    Children =
                    {
                        new Text( m.Mag.ToString() )
                            { FontFamily = t.FontFamily, FontSize = 40f, FontWeight = t.WeightBold, FontColor = magColor },
                        new Text( $"/ {m.Reserve}" )
                            { FontFamily = t.FontFamily, FontSize = 18f, FontColor = t.Dim },
                    },
                },
                bar,
            },
        };
    }

    // A blinking "[key] RELOAD" cue (keycap glyph + word). The caller positions it under the crosshair.
    public static Container ReloadCue( FpsTheme t, string key )
    {
        float blink = 0.55f + 0.45f * MathF.Sin( Time.Now * 6f );
        return new Container
        {
            Key = "rlrow", FlexDirection = FlexDirection.Row, AlignItems = Align.Center, Gap = 7f, Opacity = blink,
            Children =
            {
                new Container
                {
                    BackgroundColor = t.Ink, BorderRadius = t.Radius,
                    PaddingLeft = 7f, PaddingRight = 7f, PaddingTop = 2f, PaddingBottom = 2f,
                    Children = { new Text( key )
                        { FontFamily = t.FontFamily, FontSize = 16f, FontWeight = t.WeightBold, FontColor = new Color( 0.05f, 0.06f, 0.09f ) } },
                },
                new Text( "RELOAD" )
                    { FontFamily = t.FontFamily, FontSize = 17f, FontWeight = t.WeightBold, FontColor = t.Warn },
            },
        };
    }
}

// Standalone ammo counter. Call Fire()/Reload() from your weapon code.
public sealed partial class AmmoWidget : GooPanel<Container>
{
    [Property, Range( 1, 300 )] public int MagSize { get; set; } = 30;        // rounds per magazine
    [Property, Range( 0, 999 )] public int StartingReserve { get; set; } = 120; // starting reserve rounds
    [Property, Range( 60f, 1200f )] public float RoundsPerMinute { get; set; } = 600f; // fire rate cap while holding fire
    [Property] public bool AutoReload { get; set; } = true;                            // auto-reload once the magazine empties
    [Property] public string ReloadKey { get; set; } = "R";                            // glyph shown in the low-ammo reload cue
    [Property] public FireMode FireMode { get; set; } = FireMode.Auto;                 // semi / burst / auto trigger discipline
    [Property, Range( 2, 6 )] public int BurstCount { get; set; } = 3;                 // rounds per pull in Burst mode
    [Property] public string FireSelectAction { get; set; } = "";                      // input action that cycles fire mode (empty = none)

    readonly AmmoModel _m = new();
    readonly FireControlModel _fire = new();
    readonly FpsTheme _t = new();
    bool _booted;

    void Boot()
    {
        _m.MagSize = MagSize; _m.AutoReload = AutoReload; _m.Reset(); _m.SetReserve( StartingReserve );
        _fire.Mode = FireMode; _fire.BurstCount = BurstCount; _fire.Rpm = RoundsPerMinute; _fire.Reset();
        _booted = true;
    }

    public void Fire() => _m.Fire();                                      // consume a round
    public void Reload() => _m.Reload();                                  // begin a reload
    public void SetFireMode( FireMode mode ) { FireMode = mode; _fire.Mode = mode; } // set the active fire mode
    public void CycleFireMode() { _fire.Cycle(); FireMode = _fire.Mode; }            // advance semi -> burst -> auto

    // 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();
        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;
            if ( _fire.Tick( Sandbox.Input.Down( "attack1" ), dt ) ) Fire();
            if ( Sandbox.Input.Pressed( "reload" ) )  Reload();
        }
        bool moving = _m.Tick( dt );
        return demo || moving || _m.NeedsReloadHint; // keep ticking so the reload cue keeps blinking
    }

    protected override Container Build()
    {
        if ( !_booted ) Boot();
        var root = Parts.Root( "fpsAmmo" );
        root.Children.Add( Parts.Anchor( "a", Parts.Corner.BottomRight, _t.Margin, Parts.Panel( "bg", _t, AmmoView.Build( _m, _t, FireMode ) ) ) );
        // Reload cue lives under the crosshair (screen center), not pinned to the bottom-right counter.
        if ( _m.NeedsReloadHint )
            root.Children.Add( Parts.CenterOffset( "reloadCue", AmmoView.ReloadCueDrop, AmmoView.ReloadCue( _t, ReloadKey ) ) );
        return root;
    }
}