Show Xbox-style A/B/X/Y glyphs on the ability bar for a gamepad

Controls now tracks whether the player's last deliberate press was a
gamepad button/stick or a keyboard key/mouse click (ignoring plain
mouse motion, since the mouse still drives the camera regardless of
which device moves the bull). The HUD's ability-bar keycaps read from
that: keyboard letters normally, Xbox-style face-button glyphs while
a gamepad is in use, swapping live the moment the player switches
devices.

Godot's joypad button indices are already Xbox-position-normalized
(by SDL natively, by the browser's Standard Gamepad Mapping on web),
so the glyph mapping (button 0/1/2/3 -> A/B/X/Y) reads correctly for
any controller brand with no per-device special-casing.

Verified headlessly: labels start as the bound keyboard letters
(F/G/H/J), switch to X/A/B/Y on a synthetic joypad button press
(matching project.godot's kick/dash/slam/roll bindings), and switch
back on a keyboard press.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
This commit is contained in:
2026-09-10 19:20:54 +03:00
parent e810d2e75b
commit a34989c49e
2 changed files with 77 additions and 1 deletions
+58
View File
@@ -27,6 +27,64 @@ func _ready() -> void:
_install_web_fullscreen_hook() _install_web_fullscreen_hook()
# ── Active input device ──────────────────────────────────────────────────────────
# Tracks whether the player's last deliberate input was a gamepad or keyboard/mouse, so the
# HUD can swap its ability-bar keycaps to match whichever the player is actually holding.
# Mouse motion is deliberately ignored — the mouse still drives the camera regardless of
# which device is moving the bull, so panning the camera shouldn't flip the hint away from
# gamepad glyphs. A small deadzone on joypad axes avoids stick drift/noise doing the same.
const _JOY_AXIS_DEADZONE := 0.5
var _last_input_is_gamepad: bool = false
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton:
if event.pressed:
_last_input_is_gamepad = true
elif event is InputEventJoypadMotion:
if absf(event.axis_value) > _JOY_AXIS_DEADZONE:
_last_input_is_gamepad = true
elif event is InputEventKey:
if event.pressed:
_last_input_is_gamepad = false
elif event is InputEventMouseButton:
if event.pressed:
_last_input_is_gamepad = false
## Whether the player's last button/key/stick press was on a gamepad rather than
## keyboard/mouse. Drives which glyph set the HUD's ability keycaps show.
func using_gamepad() -> bool:
return _last_input_is_gamepad
# Godot's joypad button indices are already Xbox-position-normalized — by SDL on native, by
# the browser's Standard Gamepad Mapping on web — so this reads correctly for any controller
# brand (a Nintendo pad's physically-labelled "A" is button 1 here, same as "B" on Xbox).
const _XBOX_BUTTON_GLYPHS := {
JOY_BUTTON_A: "A",
JOY_BUTTON_B: "B",
JOY_BUTTON_X: "X",
JOY_BUTTON_Y: "Y",
}
## Xbox-style face-button glyph ("A"/"B"/"X"/"Y") for the action's bound joypad button, or
## "—" if it has none.
func get_joy_button_label(action: StringName) -> String:
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventJoypadButton:
return _XBOX_BUTTON_GLYPHS.get(event.button_index, "?")
return "—"
## Ability-bar keycap label for `action`: the bound Xbox-style face button while a gamepad
## is in use, else the bound keyboard key — so the on-screen prompt always matches whichever
## the player is actually holding.
func ability_key_label(action: StringName) -> String:
return get_joy_button_label(action) if using_gamepad() else get_key_label(action)
## Godot's built-in ui_accept (menu/button confirm) ships with zero joypad bindings — ## Godot's built-in ui_accept (menu/button confirm) ships with zero joypad bindings —
## checked directly: only Enter/Kp Enter/Space. ui_left/right/up/down, by contrast, do ## checked directly: only Enter/Kp Enter/Space. ui_left/right/up/down, by contrast, do
## default to the D-pad and stick, which is why gamepad focus navigation on a menu works out ## default to the D-pad and stick, which is why gamepad focus navigation on a menu works out
+19 -1
View File
@@ -44,7 +44,11 @@ var _hp_seg_fills: Array[ColorRect] = []
var _bottom_col: VBoxContainer var _bottom_col: VBoxContainer
var _ability_bar: HBoxContainer var _ability_bar: HBoxContainer
var _ability_panels: Array[PanelContainer] = [] var _ability_panels: Array[PanelContainer] = []
var _ability_key_labels: Array[Label] = []
var _bull_bg: TextureRect var _bull_bg: TextureRect
# Cached so the ability keycaps only get rewritten on an actual keyboard<->gamepad switch,
# not every frame.
var _showing_gamepad_hints: bool = false
# Lobby barrel-smash reward — tally the barrels present on load; when the last one bursts, # Lobby barrel-smash reward — tally the barrels present on load; when the last one bursts,
# throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it # throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it
@@ -257,6 +261,7 @@ static func _format_time(seconds: float) -> String:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_update_control_hints() _update_control_hints()
_update_ability_key_labels()
if _run_timer_label != null: if _run_timer_label != null:
_run_timer_label.visible = Run.run_timer_started() and not _game_over _run_timer_label.visible = Run.run_timer_started() and not _game_over
if _run_timer_label.visible: if _run_timer_label.visible:
@@ -469,6 +474,18 @@ func _update_control_hints() -> void:
_tab_hint.visible = not _controls_visible _tab_hint.visible = not _controls_visible
# Swaps the ability-bar keycaps between keyboard letters and Xbox-style face-button glyphs
# (A/B/X/Y) as the player switches between keyboard and gamepad. Only rewrites the labels on
# an actual switch, not every frame — Controls.using_gamepad() itself is cheap either way.
func _update_ability_key_labels() -> void:
var gamepad := Controls.using_gamepad()
if gamepad == _showing_gamepad_hints:
return
_showing_gamepad_hints = gamepad
for i in _ability_key_labels.size():
_ability_key_labels[i].text = Controls.ability_key_label(_ABILITY_ACTIONS[i])
# ── Damage vignette ─────────────────────────────────────────────────────────── # ── Damage vignette ───────────────────────────────────────────────────────────
# A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red # A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red
# toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built # toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built
@@ -902,7 +919,7 @@ func _build_ability_slot(idx: int) -> Control:
vbox.add_child(keycap) vbox.add_child(keycap)
var key_lbl := Label.new() var key_lbl := Label.new()
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx]) key_lbl.text = Controls.ability_key_label(_ABILITY_ACTIONS[idx])
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -912,6 +929,7 @@ func _build_ability_slot(idx: int) -> Control:
key_lbl.add_theme_constant_override("outline_size", 3) key_lbl.add_theme_constant_override("outline_size", 3)
key_lbl.add_theme_font_size_override("font_size", 14) key_lbl.add_theme_font_size_override("font_size", 14)
keycap.add_child(key_lbl) keycap.add_child(key_lbl)
_ability_key_labels.append(key_lbl)
return vbox return vbox