How-To: Build a Player-Editable Wheel

Goal: ship an empty wheel and let the player decide what goes in it - a consumable here, an emote there - with the arrangement surviving a save and reload.

Prerequisites
Read How-To: Build a Radial Menu first - this page assumes you already have a wheel on screen. Concept: Slot Bindings explains the model this page applies. You also need a UCrimsonPrimaryGameLayout in play, from How-To: Get the Building Blocks.

Everything on this page uses CrimsonUI alone. The tag provider shipped here needs no other Crimson plugin, so a project that installed only CrimsonUI can complete every step.


1. Create the layout definition

A UCrimsonRadialLayoutDefinition describes the shape of the wheel and nothing about its contents. Contents come from the player.

In the Content Browser: right-click -> Miscellaneous -> Data Asset, pick CrimsonRadialLayoutDefinition, and name it DA_MyWheel.

Add one entry to Pages. Set its Layout Id to a gameplay tag that is unique across every wheel in your project - this is the key the player's bindings are stored under. Then size Slots to how many positions the wheel has (8 is the usual maximum before sectors get too thin to hit).

Verify
The asset saves with one page and eight empty slot entries. Nothing renders yet - there is no widget pointing at it.

2. Decide what each slot accepts

Every slot is a FCrimsonRadialSlotDefinition. All of its fields are optional; an untouched slot accepts anything and starts empty.

FieldPurpose
SlotIdStable identity for the position. Survives reordering the array - prefer it over the index anywhere near a save.
bLockedThe player cannot change this slot. Its content is whatever DefaultBinding says. A fixed entry is a locked slot, not a separate concept.
DefaultBindingWhat a fresh profile starts with, and what Reset To Defaults restores.
AcceptedEntriesA FGameplayTagQuery matched against each entry's FilterTags. Empty accepts everything.
EmptyLabel / EmptyIconThe placeholder shown while the slot is empty - conventionally a plus sign.
Verify
Set one slot's bLocked to true. After step 6 you will find the picker refuses to open on it and a carried slice refuses to land on it.

3. Add a provider

A provider is where bindable things come from. UCrimsonRadialBindingProvider_Tag ships with CrimsonUI and offers a hand-authored list of gameplay tags, firing its OnTagChosen event (and the process-wide native OnAnyTagChosen) with the chosen one - a complete emote or ping wheel on its own.

On the layout asset, add one entry to Providers and choose Gameplay Tags from the class picker (it is Instanced, so it is authored inline on the asset).

Set Provider Id to a tag that identifies this provider - every binding it issues carries it, and that is how a saved slot finds its way back. Set Message Channel to the tag your game listens on. Then fill Entries, one per emote: its Tag, DisplayName, Icon, and any FilterTags you want slots to be able to filter on.

Provider Id is not optional
A provider with no ProviderId is skipped with a warning and offers nothing, because a binding with no route cannot be resolved after a reload. Two providers claiming the same id is also a warning - the first wins, and which one that is would otherwise depend on array order.
Verify
Nothing visible yet. The warning above is what you will see in the log if the id is missing.

4. Give the player somewhere to store bindings

UCrimsonRadialBindingComponent holds every layout the player has edited. Add it to whichever actor your project persists - the PlayerController is the simplest, the PlayerState survives seamless travel. Find searches the controller, then its PlayerState, then its Pawn, so callers never care which you chose.

Open your PlayerController Blueprint, Add Component -> Crimson Slot Binding. There is nothing to configure on it.

Without a store the wheel is read-only
A wheel whose player has no binding component still renders - every slot shows its placeholder - but nothing can be assigned, and a warning is logged the first time an edit is attempted. This is deliberate: a missing component should not crash a menu.
Verify
Play, then in the console run showdebug or check the component list on your controller - the component is present on the server and on the owning client.

5. Build the wheel widget

Create a Widget Blueprint whose parent class is CrimsonRadialMenuWidget_Bindable. It inherits everything from UCrimsonRadialMenuWidget, so the wheel binding is unchanged.

Widget nameTypeRequired
WheelA CrimsonRadialWheel subclassYes - inherited BindWidget

Then set these Class Defaults:

Class DefaultSet to
Layout DefinitionThe DA_MyWheel asset from step 1.
Picker Widget ClassThe widget from step 6. Without one, slots can be cleared but not filled.
Picker Layer TagA UI.Layer.* tag the picker is pushed onto - UI.Layer.Modal is the usual choice.
Start In Edit ModeOn for a wheel opened from a loadout screen; off for a hold-to-open combat wheel.
Verify
Push the widget to a layer. Eight placeholder slices render and none of them can be confirmed.
Reference implementation
Open the shipped W_CrimsonRadialMenu_Bindable (/CrimsonUI/UI/Foundation/Radial/) - it is exactly this step, with Wheel bound to W_CrimsonRadialWheel and the picker class and layer already set. Duplicate it as your starting point and point Layout Definition at your own asset. Plugin content is hidden in the Content Browser until you tick Settings -> Show Plugin Content.

6. Build the picker

Create a Widget Blueprint whose parent class is CrimsonRadialBindingPickerWidget. It gathers from every provider on the layout, drops anything the slot's filter rejects, and lists the rest.

Widget nameTypeRequired
List_EntriesListView with Entry Widget Class set to your row widgetOptional, but without it the picker shows nothing
Text_SearchEditableTextBoxOptional - filters the list as the player types
Text_TitleCommonTextBlockOptional - names the slot being edited

The row widget is an ordinary UserWidget implementing User Object List Entry. Its list item is a CrimsonRadialBindableEntryObject; read Entry.Display for the icon and name, and bIsClearEntry to style the pinned Clear Slot row differently.

Why there is a UObject wrapper
UListView requires UObject items and a USTRUCT cannot be one. UCrimsonRadialBindableEntryObject exists for that reason alone.
Verify
Enter edit mode and click an empty slice. The picker opens listing every emote from step 3. Picking one closes it and the slice takes that emote's icon and name immediately.
Reference implementation
Open the shipped W_CrimsonRadialBindingPicker (/CrimsonUI/UI/Foundation/Radial/) - ContentPanel > Canvas_Root > VBox_Picker holding Text_Title, Text_Search and List_Entries, with the list on a Fill rule inside a fixed-size canvas slot so it has bounded height to virtualize against. Its row is W_CrimsonRadialBindingEntry, which derives from UCrimsonRadialBindingEntryWidget - that C++ base is what supplies the required IUserObjectListEntry implementation, so derive your own row from it rather than from UserWidget.

7. Wire the edit inputs

Edit mode has three verbs. Confirm and the mouse secondary button work with no wiring; the other two are BlueprintCallable so you bind them however your project binds UI actions.

InputCallEffect
Click / A(automatic)Opens the picker for that slot
Right click(automatic)Clears the slot, or cancels a carry
A bound actionToggleCarryAtFocusLifts a slice; confirm elsewhere to swap the two
A bound actionTriggerSecondaryAction on the wheelThe gamepad equivalent of right click
Shoulder buttonsNextPage / PreviousPageCycles pages when the layout has more than one
A bound actionToggleEditModeEnters and leaves edit mode

Bind them the same way the shipped screens do - RegisterUIActionBinding against your own UI.Action.* tags, from the Blueprint subclass, so CrimsonUI stays free of any input-plugin knowledge.

An action with no key never appears
CommonBoundActionBar silently drops any action whose Input Action has no key mapped in an added Input Mapping Context. If your clear or page buttons do not show up, that is the first thing to check.
Verify
Lift a bound slice - it turns gold and pushes outward. Confirm on another slot and the two swap. Right-click a bound slice and it returns to its placeholder.

8. Persist the bindings

UCrimsonRadialBindingComponent deliberately has no save-system dependency of any kind. It exposes the whole store as plain data and takes no position on where it goes.

Get Save Data returns a CrimsonRadialBindingSaveData struct; Apply Save Data writes one back. Route them into whatever your project already saves - a SaveGame object, a profile blob, your own subsystem.

ApplySaveData is server-authoritative. Calling it on a client logs a warning and does nothing.

Want it to persist itself?
(CrimsonCore - requires all Crimson plugins) UCrimsonCoreRadialBindingComponent is this component already wired into the Crimson save pipeline (fragment CrimsonRadialBindings), and needs no code from you at all.
Verify
Bind two slices, save, restart Play, restore. Both are still where you put them. Then Play As Client with two players and confirm each one's wheel is independent - the layout container replicates COND_OwnerOnly.

9. Accept drops from another screen (optional)

A player can also assign from outside the wheel - dragging an item off an inventory grid, the way The Witcher 3 does it.

Override Translate Drop To Binding on your wheel Blueprint. You are handed the drag operation and return the binding it stands for, plus true. The default already handles CrimsonRadialBindingDragDropOperation, so override it only for your own drag type.

On the drag source, Make Binding Drag Operation builds the shipped operation from a CrimsonRadialBindableEntry.

Why the drop target is a direction
The drop slot is resolved from where the cursor is relative to the wheel center, not from a per-slice hit test. The sector images are hit-test invisible and the slice widgets are self-hit-test invisible, so per-slice hit testing does not exist - and direction is already the language the mouse and the stick both speak.
Verify
Drag an item onto the wheel while in edit mode. It lands in whichever slot you were pointing at, and is rejected if that slot is locked or filters it out.

See also

  • Concept: Slot Bindings - why a binding is not a slice, and what stale means
  • How-To: Build a Radial Menu - the authored wheel this one builds on
  • API Reference -> Slot bindings - every type and function
  • CrimsonCore -> Radial Slot Bindings - providers for abilities, items and equipment