UI & Online · Updated 2552.07.26.14.59
Settings Menu — Build-Out Guide (Gameplay tab MVP)
How to finish the Gameplay settings screen (the milestone "stopping point": a settings screen reachable from the pause menu, gamepad-navigable, that reads + persists player setting
How to finish the Gameplay settings screen (the milestone "stopping point": a settings screen reachable from the pause menu, gamepad-navigable, that reads + persists player settings, with the command bar showing Back). The C++ backend is done and compiled; what remains is UMG authoring. Scope decided 2026-07-20: Gameplay tab only — Look Sensitivity X/Y, ADS Sensitivity, Invert Look Y/X, Field of View. Video / Audio / rebinds / tabs are deferred (see §7).
Companion docs:MenusAndOnline.md§3 (settings model + persistence split),UISystem.md§4.3 (row
classes), CommandBarBackButton.md (the command bar + its gamepad-only visibility toggle).
#1. What's already done (C++, compiled)
| Piece | Where | Notes |
|---|---|---|
USLPlayerProfileSaveGame | Settings/SLPlayerProfileSaveGame.h | USaveGame. Fields are BlueprintReadOnly (seed rows from them). |
USLPlayerProfileSubsystem | Settings/SLPlayerProfileSubsystem.* | GameInstance subsystem. Loads/creates on init; clamped Set* mutators broadcast OnPlayerProfileChanged; SaveProfile() persists; dirty-flush on Deinitialize. |
| Look wiring | SLPlayerController::Look | Applies sensitivity + invert live; blends ADS multiplier by zoom alpha. Defaults are identity → no feel change until edited. |
| FOV apply | SLPlayerCharacter::SetBaseFieldOfView + BeginPlay | Controller pushes FOV on OnPlayerProfileChanged; character pulls saved FOV at spawn. |
Save slot: "SLPlayerProfile", user index 0. Subsystem access in BP: Get Game Instance Subsystem → class SL Player Profile Subsystem.
Assets created (EMPTY — parents set, no tree/graph):
WBP_SL_SettingsRow_Slider— parentUSLSliderRow
WBP_SL_SettingsRow_Toggle— parentUSLToggleRow
WBP_SL_Settings— parentUSLScreenWidget
Why empty: fresh UE 5.7 WBPs are rootless and the editor-script bridge cannot mint/swap a root, so trees
are hand-authored in UMG. The bridge can set row instance properties + verify in PIE (see §6).
#2. The value contract (how the rows talk to C++)
Each row is a thin BP over a C++ base that owns the value. The BP only wires the control and reflects the value; the owning screen seeds the value and listens for changes.
- Slider (
USLSliderRow): control →SetValueFromNormalized(0..1); reflect in `OnValueRefreshed(Value,
Normalized). Programmatic Slider·SetValue does not re-fire OnValueChanged` → no feedback loop.
- Toggle (
USLToggleRow): control →SetValue(bool)(orToggleValue()); reflect in
OnValueRefreshed(bInValue).
- Both expose
OnValueChanged(BlueprintAssignable) — the screen binds this to the subsystem'sSet*.
- Label comes free: set each instance's
DisplayName(the base fills theLabelTextblock).
#3. Step 1 — the two row widgets
#WBP_SL_SettingsRow_Slider
Tree (root HorizontalBox):
LabelText— CommonTextBlock, named exactlyLabelText(base auto-fills it). Fill horizontally.
Slider— USlider.
ValueText— CommonTextBlock (numeric readout).
Graph:
Slider · On Value Changed (float)→Set Value From Normalized(New Value).
- Event
On Value Refreshed (Value, Normalized)→Slider · Set Value (Normalized)and
ValueText · Set Text (Value → Text).
#WBP_SL_SettingsRow_Toggle
Tree (root HorizontalBox):
LabelText— CommonTextBlock, named exactlyLabelText.
ToggleCheck— CheckBox (simplest reliable control; swap to a styled ON/OFFUSLButtonBaselater).
Graph:
ToggleCheck · On Check State Changed (bool)→Set Value(In Value).
- Event
On Value Refreshed (bInValue)→ToggleCheck · Set Is Checked (bInValue).
Compile + Save both. Then ping — the bridge configures row instances in §4 for you.
#4. Step 2 — WBP_SL_Settings (the screen)
Tree (root Overlay, fill):
- (optional) background panel/image.
- Title CommonTextBlock "SETTINGS" (top).
RowsBox— VerticalBox, centered, holding six row instances (names matter for §6/graph):
| Instance name | Widget | DisplayName | Range (Min / Max / Step) | Profile field | Setter |
|---|---|---|---|---|---|
Row_SensX | Slider row | Look Sensitivity X | 0.1 / 5.0 / 0.05 | LookSensitivityX | SetLookSensitivityX |
Row_SensY | Slider row | Look Sensitivity Y | 0.1 / 5.0 / 0.05 | LookSensitivityY | SetLookSensitivityY |
Row_ADS | Slider row | ADS Sensitivity | 0.1 / 2.0 / 0.05 | ADSSensitivityMul | SetADSSensitivityMul |
Row_InvertY | Toggle row | Invert Look Y | — | bInvertLookY | SetInvertLookY |
Row_InvertX | Toggle row | Invert Look X | — | bInvertLookX | SetInvertLookX |
Row_FOV | Slider row | Field of View | 70 / 120 / 1 | FieldOfView | SetFieldOfView |
CommandBar— dropWBP_SL_CommandBarat the bottom (Back prompt; hides itself on KBM — see
CommandBarBackButton.md).
Ranges stay inside the subsystem's clamps (Sens[0.05,10], ADS[0.1,2], FOV[70,120]). The bridge will
set each instance's DisplayName + Min/Max/Step for you once the tree exists — you don't hand-type them.
Graph:
Get Desired Focus Target(REQUIRED) — override it to returnRow_SensX(or its Slider).USLScreenWidget
fails to compile without this (footgun 6.29-adjacent).
- Event Construct → seed + bind. Get the subsystem once (`Get Game Instance Subsystem → SL Player Profile
- Seed (use
bBroadcast = falseso seeding doesn't write back): - Bind each row's
OnValueChanged→ the matchingProfileSubsetter (table above). Slider rows pass a
Subsystem), promote to a var ProfileSub; ProfileSub → Get Profile` for the read.
Row_SensX · SetValue(Profile.LookSensitivityX, false), …, Row_InvertY · SetValue(Profile.bInvertLookY, false), etc. (Ranges are already set as instance defaults per §6, so no SetRange needed here.)
float; toggle rows pass a bool.
- Persist on close. On the activatable's
On Deactivated(BP_OnDeactivated) → `ProfileSub · Save
Profile`. (Live edits already apply + broadcast; this writes to disk when you back out.)
Do not wire Back/close behavior — USLScreenWidget is already a back-handler (B/Esc pops it), which is
what fires On Deactivated.
#5. Step 3 — reach it from the pause menu
On WBP_SL_PauseMenu, the Settings button (currently a stub):
On Clicked→Get Owning Player→USLPrimaryGameLayout · Get For Player→
Push Widget To Layer (Layer = Menu, Widget Class = WBP_SL_Settings).
This pushes Settings on top of the pause menu on the Menu stack; pressing B/Esc pops it back to the pause menu automatically.
#6. What the bridge does (so you don't)
Once §3 + the §4 tree exist (correct instance names), the editor-script bridge will:
- Set each row instance's
DisplayName+ sliderMinValue/MaxValue/StepSizeper the §4 table.
- Read back the compiled tree to confirm binds resolved.
- Drive PIE checks for the §8 gate.
Ask for it after each step — this is the "verify one CommonUI change at a time" discipline.
#7. Deferred (not in this slice)
- Dropdown/rotator row —
USLDropdownRowexists in C++; noWBP_SL_SettingsRow_Dropdownyet. First use =
ADS/Crouch Hold vs Toggle. Author on a UCommonRotator (gamepad left/right cycling).
- Video tab (
UGameUserSettings), Audio tab (Sound Classes), Controls/rebinds
(UEnhancedInputUserSettings) — Phase C4/C5 in MenusAndOnline.md §6.
- Real tabs (
USLTabListWidget+ switcher) — only needed once there's more than one tab.
#8. Definition of done (PIE gate)
- Pause → Settings opens the screen; command bar shows Back (gamepad); B/Esc returns to pause.
- Gamepad navigates all six rows; sliders adjust, checkboxes toggle.
- Invert Look Y ON → look pitch inverts immediately (the headline ask). Sensitivity + FOV apply live.
- Change values → back out → reopen: values persisted (written on
On Deactivated, reloaded next boot).
- On KBM the command bar hides (by design); the screen still works with mouse.
#9. Session findings — 2026-07-21
Focusability (the core CommonUI constraint): only a UCommonButtonBase takes gamepad focus. Two answers in play:
- Rows (
USLSettingsRowWidget) are focusable directly (SetIsFocusable(true)) and handle Left/Right/Accept
in NativeOnKeyDown; Up/Down fall through to Slate nav; highlight via OnRowFocusChanged. ⚠ The inner USlider/CheckBox must have Is Focusable = OFF, or focus lands on them and the row's keys never fire.
- Discrete choices →
UCommonRotator(it already is a button, so focus is free). DECISION: use a OnValueChangedthe screen binds to the setter — this is also the deferredUSLDropdownRowslot (§7).
rotator for Invert Look Y/X (Off/On) and future discrete rows; keep sliders for continuous Sens/FOV. Port the 5.6 USystemLinkSettingsRotator (C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\): arrow buttons for mouse, NativeOnClicked(){} to kill click-cycle. Next code: USLRotatorRow : UCommonRotator + bool↔index map
Verified: both screens are InputModeOnActivate=MENU + pause; controller B backs out; CommonInput supports mouse. Slider Row_SensX adjusts on arrow keys and gamepad.
KBM cursor — FIXED + REBUILT + WORKING. Cursor vanished in-menu because CommonUI hides the OS cursor while the active device is a gamepad, and it never flipped back on mouse use. USLCommonActivatableWidget now shows the cursor for MouseAndKeyboard / hides for Gamepad (via HandleInputMethodChanged, seeded on activate) with a NativeOnMouseMove reveal fallback; RestoreGameInputMode still hides it on final close. PIE-confirmed working.
Testing note: Esc stops PIE (the editor reserves it) — test Back with gamepad B, or add Backspace to IA_SL_UI_Back for KBM.
#10. Rotator row — build state + finish recipe (▶ resume point)
USLRotatorRow : UCommonRotator — WRITTEN + REBUILT (SLRotatorRow.h/.cpp, registered). API: SetOptions, SetSelectedIndex(idx, bBroadcast), SetBoolValue(b, bBroadcast) / GetBoolValue, delegates OnValueChanged (int32) + OnBoolChanged(bool), SetDisplayName. Gamepad Left/Right cycles natively; A/click advances; arrow buttons drive it for mouse. Seeding via SetSelectedIndex(..., false) does NOT write back (base SetSelectedItem doesn't fire the rotate event).
Engine contract (verified in UE 5.7 source): UCommonRotator needs a BindWidget UCommonTextBlock named exactly MyText (the value display) — required, not optional.
WBP_SL_SettingsRow_Rotator — CREATED via bridge, parented to USLRotatorRow (via WidgetBlueprintFactory(parent_class=...), so no reparent/crash), saved, but ROOTLESS. The bridge cannot set WidgetTree.RootWidget in 5.7 (no Python setter). A harmless orphan RowBox HorizontalBox is in the tree from the probe.
Finish steps:
- (Beepers, manual — the only bridge-blocked step) open
WBP_SL_SettingsRow_Rotator→ drag a **Horizontal
Box as the root** (name RowBox) → Compile + Save.
- (Bridge) under the root add:
LabelText[CommonTextBlock, fills left] ·LeftArrowButton[Button, **Is
Focusable OFF] · MyText [CommonTextBlock, exact required name] · RightArrowButton [Button, Is Focusable OFF**]. Target: [ Invert Look Y .......... ◄ Off ► ]. Compile + save.
- (Bridge/UMG) in
WBP_SL_SettingsswapRow_InvertY+Row_InvertXfrom the toggle WBP → the rotator
WBP. Per instance: SetOptions({"Off","On"}) + SetDisplayName. Graph: on Construct SetBoolValue(Profile. bInvertLookY, false) to seed; bind OnBoolChanged → ProfileSub·SetInvertLookY (and X).
- PIE gate: Invert Look Y flips on gamepad Left/Right and A; mouse arrows work; look pitch inverts live;
value persists across back-out/reopen.