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