FpsUI/Widgets/CompassWidget.cs

A UI widget that renders a 180-degree compass strip with labeled cardinal ticks. CompassView builds the clipped tick strip and backing card/top bar; CompassWidget is a panel that updates heading from camera or via SetHeading and places the compass at the top-center of the screen.

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

namespace Goo.FpsUI;

// Dock style for the compass: a full-width bar flush to the top edge, or a floating rounded card.
public enum CompassDockMode { TopBar, Card }

// Stateless presenter: a clipped heading strip with cardinal ticks positioned by angular delta.
static class CompassView
{
    const float StripW = 360f, Fov = 180f;  // 180-degree window across the strip
    public const float StripH = 28f;        // strip / top-dock bar height (others offset under it)
    const float PxPerDeg = StripW / Fov;
    static readonly (float Deg, string Label)[] Marks =
    {
        (0, "N"), (45, ""), (90, "E"), (135, ""), (180, "S"), (225, ""), (270, "W"), (315, ""),
    };

    // Both docks return the same bounded StripW band, only the backing/corners differ. The caller
    // anchors it top-center: TopBar with no margin (flush to the edge), Card with the screen margin.
    public static Container Build( CompassModel m, FpsTheme t, CompassDockMode dock = CompassDockMode.Card )
    {
        var band = Band( m, t );
        // TopBar: solid backing, square corners, sits flush against the top edge.
        if ( dock == CompassDockMode.TopBar )
            return band with { BackgroundColor = t.BackingBg, BorderRadius = 0f };
        // Card: lighter backing, rounded, floats below the top edge.
        return band with { BackgroundColor = t.PanelBg, BorderRadius = t.Radius };
    }

    // The 180-degree tick band (transparent, clipped). Marks and the white center reference live here.
    static Container Band( CompassModel m, FpsTheme t )
    {
        var strip = new Container
        {
            Key = "compass", Position = PositionMode.Relative,
            Width = StripW, Height = StripH, Overflow = OverflowMode.Hidden,
        };
        foreach ( var mark in Marks )
        {
            float delta = CompassModel.ShortestDelta( m.Heading, mark.Deg );
            if ( MathF.Abs( delta ) > Fov / 2f ) continue;          // outside the window
            float x = StripW / 2f + delta * PxPerDeg;
            bool cardinal = mark.Label.Length > 0;
            strip.Children.Add( new Container
            {
                Key = ((int)mark.Deg).ToString(),
                Position = PositionMode.Absolute, Top = 0, Left = x - 10f, Width = 20f, Height = StripH,
                JustifyContent = Justify.Center, AlignItems = Align.Center,
                Children = { cardinal
                    ? new Text( mark.Label ) { FontFamily = t.FontFamily, FontSize = 14f, FontWeight = t.WeightBold, FontColor = t.Ink }
                    : new Text( "|" ) { FontFamily = t.FontFamily, FontSize = 10f, FontColor = t.Dim } },
            } );
        }
        // center reference marker, always white so it reads against any accent
        strip.Children.Add( new Container
        {
            Key = "center", Position = PositionMode.Absolute, Top = 0, Left = StripW / 2f - 1f,
            Width = 2f, Height = StripH, BackgroundColor = Color.White,
        } );
        return strip;
    }
}

// Standalone compass. Call SetHeading(yawDeg) each frame from your player's view angle.
public sealed class CompassWidget : GooPanel<Container>
{
    [Property] public bool FollowCamera { get; set; } = true; // follow the active camera's yaw (off = drive it via SetHeading)
    [Property] public CompassDockMode Dock { get; set; } = CompassDockMode.TopBar; // top-edge bar or floating card

    readonly CompassModel _m = new();
    readonly FpsTheme _t = new();

    public void SetHeading( float yawDeg ) => _m.SetHeading( yawDeg ); // override the heading manually (call each frame, set FollowCamera = false)

    protected override bool Tick( float dt )
    {
        // Negate yaw: s&box yaw is CCW-positive (left), compass headings are CW-positive (N->E->S->W).
        if ( FollowCamera && Scene?.Camera is { } cam ) _m.SetHeading( -cam.WorldRotation.Angles().yaw );
        _m.Tick( dt );
        return true;  // heading typically changes every frame, the strip is cheap (plain rects)
    }

    protected override Container Build()
    {
        var root = Parts.Root( "fpsCompass" );
        float inset = Dock == CompassDockMode.TopBar ? 0f : _t.Margin; // TopBar sits flush to the edge
        root.Children.Add( Parts.Anchor( "a", Parts.Corner.TopCenter, inset, CompassView.Build( _m, _t, Dock ) ) );
        return root;
    }
}