FpsUI/Widgets/XpWidget.cs

UI widget for an FPS XP feed. Builds a right-side stacked list of transient "+N XP action" entries with pop/fade animations, and exposes Award(amount, action) to add entries.

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

namespace Goo.FpsUI;

// Stateless presenter: an upper-right stack of "+amount XP  action" grants that pop in and fade.
static class XpView
{
    public const float Drop    = -60f;   // px from screen center (negative = up): slightly above the crosshair
    public const float OffsetX = 110f;   // px right of screen center: off to the right of the crosshair

    public static Container Build( XpModel m, FpsTheme t )
    {
        var col = new Container { Key = "xp", FlexDirection = FlexDirection.Column, AlignItems = Align.FlexStart, Gap = 4f };
        // All children keyed by stable entry Id (per-entry pop transform rules out AddRange auto-keying).
        foreach ( var e in m.Entries )
        {
            float spring = MathF.Max( 0f, e.Pop.Current );  // unclamped spring: overshoots past 1 for the scale pop
            float pop = Math.Clamp( spring, 0f, 1f );        // clamped value for opacity + Y-rise
            float fade = Math.Clamp( (m.Lifetime - e.Age) / 0.4f, 0f, 1f ); // fade out in the last 0.4s
            var row = new Container
            {
                Key = e.Id.ToString(),
                FlexDirection = FlexDirection.Row, AlignItems = Align.Center, Gap = 8f,
                Opacity = Math.Min( pop, fade ),
                // Scale springs from 0, overshoots its standing size, then settles to 1 for the pop; plus a small rise.
                Transform = PanelTransform.Scale( spring ).TranslateY( Px.Of( (1f - pop) * 10f ) ),
                Children =
                {
                    new Text( $"+{e.Amount} XP" )
                        { FontFamily = t.FontFamily, FontSize = 22f, FontWeight = t.WeightBold, FontColor = t.Xp },
                },
            };
            if ( !string.IsNullOrEmpty( e.Action ) )
                row.Children.Add( new Text( e.Action )
                    { FontFamily = t.FontFamily, FontSize = 16f, FontColor = t.Ink } );
            col.Children.Add( row );
        }
        return col;
    }
}

// Standalone XP feed. Call Award(amount, action) when the player earns XP.
public sealed partial class XpWidget : GooPanel<Container>
{
    [Property, Range( 1, 10 )]  public int MaxEntries { get; set; } = 6;     // grants shown before eviction
    [Property, Range( 0.5f, 6f )] public float Lifetime { get; set; } = 1.8f; // seconds a grant lives

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

    public void Award( int amount, string action = "" ) => _m.Add( amount, action ); // grant XP

    // 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.MaxEntries = MaxEntries; _m.Lifetime = Lifetime;
        bool demo = false;
        StepDemo( dt, ref demo );
        bool moving = _m.Tick( dt );
        return demo || moving || _m.Entries.Count > 0;
    }

    protected override Container Build()
    {
        var root = Parts.Root( "fpsXp" );
        root.Children.Add( Parts.Offset( "xp", XpView.OffsetX, XpView.Drop, XpView.Build( _m, _t ) ) );
        return root;
    }
}