Code/Demos/RadialWheel/RadialView.cs
using System;
using Goo;
using Goo.Animation;
using HitShapes;
using Sandbox;
using Sandbox.UI;
using static Sandbox.DemoTokens;
namespace Sandbox.RadialWheel;
// Radial action wheel: owns animator state, slot dispatcher, and event handlers; components are pure builders.
public sealed class RadialView
{
const int SlotCount = 4;
const float WheelSize = 320f;
const float InnerRatio = 0.45f;
const float LabelRadius = 116f;
const float LabelBox = 72f;
// Lower than a fully-opaque disc would need since the disc is blurred.
const float BackdropAlpha = 0.45f;
const float WedgeRestAlpha = 0.92f;
const float HighlightAlpha = 0.18f;
const float BackdropBlur = 12f; // px blur on inner disc
const float WheelBlurAmount = 8f; // px blur on wheel-sized backdrop
const float ShadowAlpha = 0.45f;
const float ShadowThickness = 4f; // px inner-rim border thickness
const float FlashPeak = 0.4f; // white-blend at peak brightness flash
const float FlashSmoothing = 0.05f; // ~150ms decay
const float HotScaleFreq = 14f;
const float HotScaleDamping = 0.55f;
const float OpenSpringFreq = 9f;
const float OpenSpringDamping = 0.55f;
const float CloseKick = 8f; // upward velocity to pop before collapsing
const float HotScale = 1.05f;
const float Smoothing = 0.06f;
const float FontSizeRest = 18f;
const float FontSizeHot = 22f;
const float BounceFrequency = 9f;
const float BounceDamping = 0.4f;
const float BounceKick = 0.5f;
const float ClickKick = -6f;
const float WedgeCornerRadius = 0.075f; // ~12px on 320px wheel; tune to taste
const float StartAngleDeg = 0f; // 0 = slot 0 at 12 o'clock; rotate cw to taste.
const float FlipDuration = 0.5f;
const float FlipPerspective = 800f;
// press to toggle open/closed; closed state captures nothing so the wheel coexists with gameplay in a shared HUD. uppercase = letter key per Sandbox.Input.Keyboard.Down naming.
const string ToggleKey = "R";
readonly record struct SlotData( string Title, string IconSrc );
static readonly SlotData[] Slots =
{
new( "mine", "SVGs/mining.svg" ),
new( "chop", "SVGs/chop.svg" ),
new( "fish", "SVGs/pole.svg" ),
new( "poop", "SVGs/turd.svg" ),
};
static readonly Color HotOverlay = HoverGlow;
int? _hovered;
int? _selected;
readonly int[] _slotState = new int[SlotCount];
bool _overHotZone;
bool _open; // starts closed; ToggleKey opens it
bool _togglePrev; // edge-detect the toggle key
SpringFloat _hotScale = new( 1f, HotScaleFreq, HotScaleDamping );
SpringFloat _openScale = new( 0f, OpenSpringFreq, OpenSpringDamping ); // scales+fades wheel on open/close; starts collapsed
SpringFloat _wheelBounce = new( 1f, BounceFrequency, BounceDamping );
readonly SpringFloat[] _labelBounce = new SpringFloat[SlotCount];
readonly SmoothFloat[] _labelFontSize = new SmoothFloat[SlotCount];
readonly SmoothFloat[] _brightFlash = new SmoothFloat[SlotCount];
readonly float[] _flipT = new float[SlotCount];
readonly bool[] _flipResetPending = new bool[SlotCount];
bool _dirty = true;
void Invalidate() => _dirty = true;
readonly SlotDispatcher _dispatcher;
readonly Action<MousePanelEvent> _onMove;
readonly Action<MousePanelEvent> _onLeave;
readonly Action<MousePanelEvent> _onClick;
public RadialView()
{
for ( int i = 0; i < SlotCount; i++ )
{
_labelBounce[i] = new SpringFloat( 1f, BounceFrequency, BounceDamping );
_labelFontSize[i] = new SmoothFloat( FontSizeRest, Smoothing );
_brightFlash[i] = new SmoothFloat( 0f, FlashSmoothing ); // ~150ms decay
_flipT[i] = -1f; // idle
}
_dispatcher = new SlotDispatcher( HitShape.Radial( SlotCount, innerRatio: InnerRatio, startAngleDeg: StartAngleDeg ) )
{
OnSlotEnter = slot =>
{
_hovered = slot;
_wheelBounce.Velocity = BounceKick;
_brightFlash[slot].Current = 1.0f; // instant peak; decays toward Target=0
Invalidate();
},
OnSlotLeave = _ => { _hovered = null; Invalidate(); },
OnSlotClick = HandleSlotClick,
};
_onMove = HandleMouseMoveOrEnter;
_onLeave = e => { _dispatcher.HandleMouseLeave( e ); _overHotZone = false; };
_onClick = HandleWheelClick;
}
public void Reset()
{
_dispatcher.Reset();
_open = false;
_togglePrev = false;
_openScale.Current = _openScale.Target = 0f;
_openScale.Velocity = 0f;
Invalidate();
}
public Container Build()
{
// closed state: render an empty click-through placeholder so the HUD passes the cursor to gameplay; reopen is via ToggleKey, not a fullscreen catcher.
bool fullyClosed = !_open && _openScale.Current <= 0.001f;
if ( fullyClosed )
return new Container { Key = "wheel-closed", PointerEvents = PointerEvents.None };
var restColor = Ink2.WithAlpha( WedgeRestAlpha );
Span<Color> wedgeColors = stackalloc Color[SlotCount];
for ( int i = 0; i < SlotCount; i++ )
{
if ( _hovered == i )
{
float t = _brightFlash[i].Current * FlashPeak;
wedgeColors[i] = Color.Lerp( HotOverlay, Color.White, t );
}
else
{
wedgeColors[i] = restColor;
}
}
// Clamp openScale: SpringFloat overshoots below 0 briefly while settling after the close kick.
float openScale = MathF.Max( 0f, _openScale.Current );
var wheel = new Container
{
// keyed so the anchored slot stays all-keyed across the open/closed flip (mixed keyed/unkeyed pair warns).
Key = "wheel",
Position = PositionMode.Relative,
Width = WheelSize,
Height = WheelSize,
PointerEvents = PointerEvents.All,
Transform = Goo.PanelTransform.Scale( _wheelBounce.Current * openScale ),
Opacity = openScale,
OnMouseEnter = _onMove,
OnMouseLeave = _onLeave,
OnMouseMove = _onMove,
OnClick = _onClick,
};
// Z-order: shadow -> wheel-blur -> wedges -> labels -> inner-backdrop -> center.
wheel.Children.Add( WheelShadow.Build( ShadowThickness, ShadowAlpha ) );
wheel.Children.Add( WheelBlur.Build( WheelBlurAmount ) );
float innerDiameterPx = WheelSize * InnerRatio;
Span<float> wedgeScales = stackalloc float[SlotCount];
for ( int i = 0; i < SlotCount; i++ )
wedgeScales[i] = (_hovered == i) ? _hotScale.Current : 1f;
wheel.Children.Add( WedgeRing.Build(
segments: SlotCount,
innerRadius: InnerRatio,
wedgeCornerRadius: WedgeCornerRadius,
colors: wedgeColors,
wedgeScales: wedgeScales,
startAngleDeg: StartAngleDeg,
key: "ring" ) );
for ( int slot = 0; slot < SlotCount; slot++ )
{
float theta = slot * MathF.Tau / SlotCount - MathF.PI / 2f + StartAngleDeg * MathF.PI / 180f;
float cx = WheelSize * 0.5f + MathF.Cos( theta ) * LabelRadius;
float cy = WheelSize * 0.5f + MathF.Sin( theta ) * LabelRadius;
wheel.Children.Add( SlotLabel.Build(
slot: slot,
cx: cx,
cy: cy,
box: LabelBox,
label: Slots[slot].Title,
badge: PerSlotBadge( slot, _slotState[slot] ),
bounce: _labelBounce[slot].Current,
fontSize: _labelFontSize[slot].Current,
flipDeg: FlipAngleDeg( slot ),
flipPerspective: FlipPerspective,
isHovered: _hovered == slot,
isSelected: _selected == slot ) );
}
Center.SlotContent? centerContent = _hovered is int h
? new Center.SlotContent( Slots[h].Title, Slots[h].IconSrc, IsSelected: _selected == h )
: null;
wheel.Children.Add( Backdrop.Build( innerDiameterPx, BackdropAlpha, BackdropBlur, HighlightAlpha ) );
wheel.Children.Add( Center.Build( centerContent, innerDiameterPx ) );
return wheel;
}
// Ease-in-out for a snappier feel at the edge-on midpoint of the flip.
float FlipAngleDeg( int slot )
{
if ( _flipT[slot] < 0f ) return 0f;
float p = MathF.Min( _flipT[slot] / FlipDuration, 1f );
float eased = p < 0.5f
? 2f * p * p
: 1f - MathF.Pow( -2f * p + 2f, 2f ) * 0.5f;
return eased * 360f;
}
public bool Tick( Scene? scene, float dt )
{
bool toggleDown = Sandbox.Input.Keyboard.Down( ToggleKey );
if ( toggleDown && !_togglePrev ) Toggle();
_togglePrev = toggleDown;
_hotScale.Target = _overHotZone ? HotScale : 1f;
_hotScale.Update( dt );
_wheelBounce.Update( dt );
_openScale.Update( dt );
// ensure a final Invalidate() when _openScale settles near 0 so the closed placeholder mounts; without this, IsSettled halts the rebuild loop while Current is ~0.0001f and the invisible wheel keeps capturing the cursor.
if ( !_open && _openScale.IsSettled && _openScale.Current <= 0.001f )
Invalidate();
for ( int i = 0; i < SlotCount; i++ )
{
_labelFontSize[i].Target = (_hovered == i) ? FontSizeHot : FontSizeRest;
_labelFontSize[i].Update( dt );
_labelBounce[i].Update( dt );
_brightFlash[i].Update( dt );
if ( _flipT[i] >= 0f )
{
_flipT[i] += dt;
if ( _flipResetPending[i] && _flipT[i] >= FlipDuration * 0.5f )
{
_slotState[i] = 0;
if ( _selected == i ) _selected = null;
_flipResetPending[i] = false;
}
if ( _flipT[i] >= FlipDuration )
{
_flipT[i] = -1f;
}
}
}
if ( NeedsRebuild() ) Invalidate();
bool d = _dirty; _dirty = false; return d;
}
bool NeedsRebuild()
{
if ( !_hotScale.IsSettled || !_wheelBounce.IsSettled || !_openScale.IsSettled ) return true;
for ( int i = 0; i < SlotCount; i++ )
{
if ( !_labelBounce[i].IsSettled || !_labelFontSize[i].IsSettled ) return true;
if ( !_brightFlash[i].IsSettled ) return true;
if ( _flipT[i] >= 0f ) return true;
}
return false;
}
void HandleDismiss()
{
_open = false;
_openScale.Target = 0f;
_openScale.Velocity = CloseKick;
Invalidate();
}
void Toggle()
{
if ( _open ) HandleDismiss();
else Open();
}
void Open()
{
if ( _open ) return;
_open = true;
_openScale.Target = 1f;
_openScale.Velocity = 0f;
Invalidate();
}
void HandleMouseMoveOrEnter( MousePanelEvent e )
{
_dispatcher.HandleMouseMove( e );
_overHotZone = _dispatcher.CurrentSlot is not null;
}
void HandleWheelClick( MousePanelEvent e )
{
var size = e.Target?.Box.Rect.Size ?? Vector2.Zero;
var slot = _dispatcher.Shape.Resolve( e.LocalPosition, size );
if ( slot is int s ) HandleSlotClick( s, e );
else if ( _open ) HandleDismiss();
}
void HandleSlotClick( int slot, MousePanelEvent e )
{
switch ( e.MouseButton )
{
case MouseButtons.Left:
_slotState[slot]++;
break;
case MouseButtons.Right:
// Defer the state reset to the midpoint of a Y-flip so the user
// sees the old badge rotate to edge-on, then the new (cleared)
// state rotate into view. Drives the "card flip" magic.
_flipT[slot] = 0f;
_flipResetPending[slot] = true;
break;
case MouseButtons.Middle:
if ( _selected == slot ) _selected = null;
break;
}
_labelBounce[slot].Velocity = ClickKick;
Invalidate();
}
static string PerSlotBadge( int slot, int n )
{
if ( n <= 0 ) return "";
return slot switch
{
0 => $"x{n}",
1 => (n % 3) switch { 1 => "sword", 2 => "shield", _ => "bow" },
2 => (n % 3) switch { 1 => "iron", 2 => "gold", _ => "silver" },
3 => $"stink x{n}",
_ => "",
};
}
}