Quick Weapon Menu
The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL below are used as in RFC 2119.
The Quick Weapon Menu is the in-field weapon palette — a small scrolling overlay (_QuickWeaponMenu, an inner Control of the field HUD) opened with the quick_weapon action. It lists the inventory weapons the active character can equip and lets the player swap the held weapon without going through the full start menu. This page is the normative contract for issue #424 (the unequip path, the SFX, and the list ordering) and is the parent quick-menu spec referenced by issue #141.
Which rows appear
The list is built by _build_weapon_list(). Each inventory item is included iff it resolves to a weapon in WeaponRegistry and passes the single canonical equip gate EquipmentUtils.item_fits_slot(item_id, "weapon") (PR #419). That gate is the same predicate that drives the shop / start-menu equippability markers, and it honors DebugConfig.equip_all. The menu MUST NOT re-derive equippability with a hand-rolled class check — see Equip Legality for the canonical item_fits_slot contract.
List ordering
When a weapon is equipped, its row MUST sort to the end of the list; all other (non-equipped) rows MUST precede it, ordered alphabetically by display name. Rationale: selecting the equipped row is the unequip action (below), so it belongs at the bottom of the palette as the "take it off" choice rather than the first thing the cursor lands on.
Initial cursor position
When the menu opens, the cursor MUST start on the first row of the built list (_selected_index == 0) — the top, alphabetically-first equippable weapon. It MUST NOT jump to the equipped weapon's row. Rationale (issue #141): a fixed, predictable entry point reads better than a cursor that lands in a different place each open depending on what's equipped; and since the equipped row sorts to the bottom (above), pre-selecting it would drop the cursor at the far end of the list rather than a natural starting point. This supersedes the earlier "pre-select the equipped row" behavior (#424), which positioned the cursor on the equipped weapon on open.
Selecting a row
Accept on a row dispatches in _equip_selected():
- Selecting a non-equipped weapon row MUST equip it (
equipment.weapon = item_id), refresh the player's held model, and then close the menu — unchanged behavior. - Selecting the currently-equipped weapon row MUST unequip it: set
equipment.weapon = ""so the player becomes barehanded, refresh the held model, and then close the menu. Prior to this fix, selecting the equipped row only dismissed the menu with no state change (issue #424).
Both branches mutate the same equipment dictionary returned by CharacterManager.get_active_character() and both refresh the player model when a player-group node with refresh_weapon() is present (guarded, so it is a no-op in a headless / no-player context). Neither branch re-checks legality — the list already only contains rows the canonical gate allowed.
Barehanded attack SFX
When the player attacks with no weapon equipped, the attack SFX MUST be the barehanded sound (common46), NOT common35 (the saber swing). The defect: _get_equipped_weapon_type() returns 0 for barehanded, but WeaponData.WeaponType.SABER == 0, so the weapon→SFX map resolved barehanded to the saber sound. The fix discriminates barehanded at the SFX call site (an explicit empty-weapon_id check, _is_barehanded()) and selects BAREHANDED_SFX; it MUST NOT change the 0 return of _get_equipped_weapon_type(), which several combat callers rely on as a fallback.
Caveat (asset-pack republish pending): common46 is not yet in the asset pack. Until the republish, the SFX site MUST play nothing for barehanded (gated behind a ResourceLoader.exists() check) rather than the wrong saber sound — i.e. this contract's binding requirement today is that barehanded MUST NOT play common35. Wiring the actual common46 playback is a follow-up that requires upload-pack.
Input capture (issue #467)
While the Quick Weapon Menu is open, it MUST capture input the same way the Start Menu does (issue #426): no action-palette action and no attack MUST fire, regardless of which button it is bound to. The defect this fixes: an attack mapped to the action palette on a button other than accept / cancel / quick_weapon still fired while the menu was up, because the menu only consumed its own navigation actions and let every other press fall through to the player's action path.
The mechanism MUST be the shared modal gate: _open() calls GameState.push_modal() and _close() calls GameState.pop_modal(), so GameState.is_gameplay_blocked() is true for the whole open window. The early-return on that gate in Player._unhandled_input (the same seam that enforces #426) is what suppresses the palette/attack dispatch. Closing the menu MUST restore palette firing on the next frame with no buffered press leaking through. This extends the confirm/interact precedence of #426 into a general rule: an active modal (Start Menu OR Quick Menu) > world interaction > palette / free action.
Movement is orthogonal. The modal gate blocks only the action path in _unhandled_input; player movement runs from the ungated _physics_process, so the Quick Menu (like the Start Menu) allows moving and navigating the list at the same time. The menu MUST NOT freeze the player.
The push/pop MUST stay balanced: _open() pushes only when the list is non-empty (it early-returns before the push otherwise, so an empty menu never opens and never blocks gameplay), and _close() is idempotent — it pops only if the menu was actually open, so a stray double-close cannot underflow the modal stack and wrongly unblock gameplay under an unrelated modal.
Invariants
- While the menu is open,
GameState.is_gameplay_blocked()MUST be true (a modal is pushed); no palette action or attack MUST fire on any button. Closing the menu MUST pop that modal. Player movement MUST stay enabled throughout. - The equipped weapon row, when present, MUST be the last entry of the built list.
- On open, the cursor MUST start at the first row (
_selected_index == 0) and MUST NOT pre-select the equipped row. - Accept on the equipped row MUST unequip to barehanded (
equipment.weapon == "") and then close; Accept on any other row MUST equip that weapon and then close. - Barehanded attack SFX MUST resolve to
common46and MUST NOT becommon35; whilecommon46is absent from the pack the barehanded swing is silent. - Which rows appear MUST come from the canonical
EquipmentUtils.item_fits_slotgate, never a re-derived class check.
Tests
test_quick_weapon_menu_unequip_and_order() in scripts/tools/test_runner.gd locks the ordering (equipped row sorts last), the cursor starting at the top on open (_selected_index == 0, not the equipped row), the unequip-on-equipped-row behavior, and that equip-on-other-row still works; it also asserts BAREHANDED_SFX != WEAPON_SFX[SABER] (barehanded no longer maps to common35). test_quick_weapon_menu_captures_input() locks the #467 input-capture contract: opening the menu flips GameState.is_gameplay_blocked() true, closing it flips it back, a double-close does not underflow the modal stack, and an empty-list open pushes no modal. test_field_weapon_swap_gate() covers the canonical-gate row filtering. The menu-driven autopilot step (opening the menu, accepting the equipped row) and actual common46 audio playback are verified on hardware — the new unequip branch emits [sanity] checkpoint: quickmenu-unequip-barehanded as the hook for that probe.
Implemented by
- scripts/3d/field/field_hud.gd —
_QuickWeaponMenu._build_weapon_list()(row filter + ordering),_QuickWeaponMenu._equip_selected()(equip / unequip dispatch), and_QuickWeaponMenu._open()/_close()(theGameState.push_modal()/pop_modal()input capture, #467). - scripts/autoloads/game_state.gd —
push_modal()/pop_modal()/is_gameplay_blocked(), the shared modal gate the Start Menu (#426) and Quick Menu (#467) both drive. - scripts/3d/player/player.gd —
WEAPON_SFX/BAREHANDED_SFXand_play_and_track_attack()/_is_barehanded()(barehanded SFX discrimination).
See also Equip Legality (the canonical item_fits_slot gate) and the Start Menu (the full equip flow, a different menu).