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.
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;
}
}