Concept: Radial Selection & Slice State
The model behind UCrimsonRadialWheel: how a direction becomes a selection, how a slice's look is decided, and why the wheel is a composed widget rather than a base class.
Selection is by direction, not by hover
The cursor position is converted to a direction from the wheel center, and the slice whose angular sector contains that direction wins. The gamepad stick feeds the exact same function. Every widget the wheel spawns is hit-test invisible; the wheel does the math.
- One code path serves mouse and stick, so they cannot drift apart.
- Distance does not matter - a slice is selectable from anywhere along its angle past the dead zone.
- Sector art can overlap freely, because nothing relies on widget geometry for hit testing.
Layout convention: slice 0 sits at 12 o'clock and slices proceed clockwise. GetSliceDirection returns the unit vector in UMG space (+X right, +Y down) so you can place your own decoration on the same circle.
(0,0) and would wipe a mouse selection. A centered stick is fed through deliberately once engaged - that zero is the cancel gesture on a gamepad.State is data, the material has no logic
The wheel resolves a FCrimsonRadialSliceStateStyle per slice and pushes colors and scalars into a material instance. The material contains no notion of "focused" or "disabled" - it renders what it is handed, which is why a new state costs no material work.
| State | When | How it reads |
|---|---|---|
Normal | Resting | Muted crimson rim on a dark plum ground. |
Focused | Pointed at | Lifts off the hub, brightens, picks up a rim bloom. |
Disabled | bEnabled is false | Desaturated and hatched. |
Destructive | Focused and bDestructive | Hotter, stronger bloom, and a pulse. |
Destructive: once the focus color is the brand crimson, "it is red" stops distinguishing anything, so motion carries the difference for a player who cannot separate the two reds.Cooldown and hold progress are overlays, orthogonal to state - a slice can be disabled and cooling down, and both draw.
The wheel is composed, not inherited
UCrimsonRadialWheel is a UCommonUserWidget that hosts do not subclass - they embed it as a BindWidget. Its two shipped hosts could not share an ancestor: UCrimsonContextMenuWidget_Radial has to stay under UCrimsonContextMenuWidget_Modal for click-outside dismissal and viewport clamping, while UCrimsonRadialMenuWidget is a plain activatable screen. Composition lets both use the identical wheel.
Geometry adapts to slice count
Slice content is placed at the sector's area centroid, not at a fixed fraction between the radii:
(2/3) * (R^3 - r^3)/(R^2 - r^2) * sin(h)/h h = half-angle in radians
The sin(h)/h term pulls the centroid inward in proportion to how wide the sector is. A fixed fraction that looks centered on an eight-slice wheel sits far too close to the rim on a three-slice one - about 32 px out at a 620 px wheel. ContentRadiusScale multiplies this value, so 1.0 is centered at any slice count.
Widgets are pooled - state must be per-open
CommonUI's activatable container recycles widget instances rather than destroying them. A second open hands back the same object with all of its state intact.
NativeOnActivated, never behind a "first time" check such as Options.Num() == 0.Anything cleaned up on close must undo its side effects, not merely forget them - dropping a tracking array without removing the widgets it tracked strands them on a canvas that outlives the widget, and the next rebuild draws a second set on top.
This is why the menu pushes its slices on every activation and the wheel restarts its own update timer defensively.
See also
- How-To: Build a Radial Menu - the practical recipe.
- Concept: Input Routing & Activatable Widgets - how activation and input configs interact.
- API Reference -> Radial menus - the full surface.