FpsUI/Widgets/CrosshairWidget.cs

UI widget and view for a 4-line crosshair used in an FPS HUD. Defines a CrosshairStyle struct, a stateless CrosshairView that builds UI containers for lines and dot based on a CrosshairModel, and a CrosshairWidget component that exposes editable properties, updates a CrosshairModel each tick, and triggers spread changes on Fire() or sustained firing.

Native Interop
using System;
using Goo;
using Sandbox;
using Sandbox.UI;

namespace Goo.FpsUI;

// Pure styling for the crosshair view (colors/sizes), the model owns the animated gap.
public struct CrosshairStyle
{
    public Color Color;        // line + dot color
    public float Length;       // length of each line in px
    public float Thickness;    // line thickness in px
    public bool  ShowLines;    // draw the 4 lines
    public bool  ShowDot;      // draw the center dot
    public float DotSize;      // dot diameter in px
    public bool  Outline;      // draw a contrasting outline behind lines/dot
    public Color OutlineColor; // outline color
    public bool  TStyle;       // drop the top line (T-shaped crosshair)
}

// Stateless presenter: a dynamic 4-line crosshair whose gap is model.CurrentGap.
static class CrosshairView
{
    const float Box = 96f;  // square canvas the lines are positioned inside
    const float Mid = Box / 2f;

    public static Container Build( CrosshairModel m, CrosshairStyle s, FpsTheme t )
    {
        float gap = m.CurrentGap;
        var root = new Container { Key = "xhair", Position = PositionMode.Relative, Width = Box, Height = Box };

        if ( s.ShowLines )
        {
            if ( !s.TStyle ) AddLine( root, "top",    s, vertical: true,  along: Mid - gap - s.Length, cross: Mid - s.Thickness / 2f );
            AddLine( root, "bottom", s, vertical: true,  along: Mid + gap,             cross: Mid - s.Thickness / 2f );
            AddLine( root, "left",   s, vertical: false, along: Mid - s.Thickness / 2f, cross: Mid - gap - s.Length );
            AddLine( root, "right",  s, vertical: false, along: Mid - s.Thickness / 2f, cross: Mid + gap );
        }
        if ( s.ShowDot )
        {
            if ( s.Outline )
                root.Children.Add( Dot( "dotO", s.DotSize + 2f, s.OutlineColor ) );
            root.Children.Add( Dot( "dot", s.DotSize, s.Color ) );
        }
        return root;
    }

    // vertical line is width=Thickness height=Length, horizontal swaps them. along/cross are the top/left coords.
    static void AddLine( Container root, string key, CrosshairStyle s, bool vertical, float along, float cross )
    {
        float w = vertical ? s.Thickness : s.Length;
        float h = vertical ? s.Length : s.Thickness;
        float top = along;   // caller computes along as the Top coord for both orientations
        float left = cross;  // and cross as the Left coord (see call sites)
        if ( s.Outline )
            root.Children.Add( new Container
            {
                Key = key + "O", Position = PositionMode.Absolute,
                Top = top - 1f, Left = left - 1f, Width = w + 2f, Height = h + 2f,
                BackgroundColor = s.OutlineColor,
            } );
        root.Children.Add( new Container
        {
            Key = key, Position = PositionMode.Absolute,
            Top = top, Left = left, Width = w, Height = h, BackgroundColor = s.Color,
        } );
    }

    static Container Dot( string key, float size, Color color ) => new()
    {
        Key = key, Position = PositionMode.Absolute,
        Top = Mid - size / 2f, Left = Mid - size / 2f,
        Width = size, Height = size, BorderRadius = size / 2f, BackgroundColor = color,
    };
}

// Standalone crosshair. Fully customizable in the inspector, call Fire() on each shot.
public sealed partial class CrosshairWidget : GooPanel<Container>
{
    [Property] public Color Color { get; set; } = new( 0.40f, 0.85f, 1f ); // line/dot color
    [Property, Range( 0f, 40f )]  public float Gap { get; set; } = 6f;        // base center gap (px)
    [Property, Range( 1f, 40f )]  public float Length { get; set; } = 8f;     // line length (px)
    [Property, Range( 1f, 10f )]  public float Thickness { get; set; } = 2f;  // line thickness (px)
    [Property] public bool ShowLines { get; set; } = true;                    // draw the 4 lines
    [Property] public bool ShowDot { get; set; } = true;                      // draw the center dot
    [Property, Range( 1f, 12f )]  public float DotSize { get; set; } = 2f;    // dot diameter (px)
    [Property] public bool Outline { get; set; } = true;                      // contrasting outline
    [Property] public Color OutlineColor { get; set; } = new( 0f, 0f, 0f, 0.7f ); // outline color
    [Property] public bool TStyle { get; set; } = false;                      // T-shaped (no top line)
    [Property, Range( 0f, 40f )]  public float SpreadScale { get; set; } = 14f; // px opened at full spread (0 = static)
    [Property, Range( 60f, 1200f )] public float RoundsPerMinute { get; set; } = 600f; // full-auto bump rate while holding fire

    readonly CrosshairModel _m = new();
    readonly FpsTheme _t = new();
    float _fireCooldown;

    public void Fire() => _m.Fire();                       // bump spread on a shot
    public void SetSpread( float t01 ) => _m.SetSpread( t01 ); // sustained spread (e.g. while moving)

    CrosshairStyle Style() => new()
    {
        Color = Color, Length = Length, Thickness = Thickness,
        ShowLines = ShowLines, ShowDot = ShowDot, DotSize = DotSize,
        Outline = Outline, OutlineColor = OutlineColor, TStyle = TStyle,
    };

    // 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 )
    {
        _m.Gap = Gap; _m.SpreadScale = SpreadScale;
        bool demo = false;
        StepDemo( dt, ref demo );
        if ( !demo && FpsInput.AutoFire( Sandbox.Input.Down( "attack1" ), RoundsPerMinute, dt, ref _fireCooldown ) ) Fire();
        bool moving = _m.Tick( dt );
        return demo || moving;
    }

    protected override Container Build()
    {
        _m.Gap = Gap; _m.SpreadScale = SpreadScale;
        var root = Parts.Root( "fpsCrosshair" );
        root.Children.Add( Parts.Anchor( "a", Parts.Corner.Center, 0f, CrosshairView.Build( _m, Style(), _t ) ) );
        return root;
    }
}