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