Full Xbox/PlayStation controller support: glyphs, remapping, vibration toggle
Extends the earlier Xbox-only ability glyphs into full brand-aware controller support, following standard practice (position-based button mapping, safe defaults for unrecognised pads, no per-device special-casing): - Controls detects PlayStation pads (DualShock/DualSense) from Input.get_joy_name() via a name-substring heuristic, checking for "xbox" first so a Bluetooth-connected Xbox pad (reports as "Xbox Wireless Controller") can't be misread as PlayStation through the generic "wireless controller" hint. Unrecognised/generic pads read as Xbox-style, matching the browser's Standard Gamepad Mapping. - Ability-bar keycaps and the options menu now show ✕/○/□/△ for a detected PlayStation pad instead of A/B/X/Y — no index remapping needed, since PlayStation's face buttons sit in the exact same four positions as Xbox's. - The options menu's Controls section gains a gamepad rebind button next to each ability's keyboard one (movement stays on the D-pad/ stick, not individually remapped, matching convention). Clicking it captures the next joypad button press, mirroring the existing keyboard-rebind flow exactly (Esc cancels either). Gamepad rebinds persist to controls.cfg alongside keyboard ones and survive a reload. - Added a player-facing "Controller Vibration" toggle (Settings... Gameplay section) — Controls.vibrate() now checks it alongside the existing DP debug flag, so either can silence rumble. Verified headlessly end-to-end: default bindings show correct brand-agnostic glyphs (kick=X, dash=A, ...), rebinding kick to a different button through the real options-menu flow changes only that action's glyph, leaves its keyboard binding and other abilities' gamepad bindings untouched, and survives a reload from disk. Sanity- checked the PlayStation name heuristic against realistic controller name strings from Xbox, PlayStation (Windows/macOS/Bluetooth variants), and other brands. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
This commit is contained in:
@@ -12,6 +12,9 @@ const SECTION := "options"
|
||||
var screen_shake: bool = true
|
||||
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
|
||||
var gore: bool = true
|
||||
## Controller rumble toggle — player-facing; Controls.vibrate() also respects the DP
|
||||
## "gamepad_vibration" debug flag, so either can silence it.
|
||||
var vibration: bool = true
|
||||
|
||||
## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change.
|
||||
var volume_master: float = 1.0
|
||||
@@ -62,6 +65,14 @@ func set_gore(value: bool) -> void:
|
||||
save()
|
||||
|
||||
|
||||
func set_vibration(value: bool) -> void:
|
||||
if vibration == value:
|
||||
return
|
||||
vibration = value
|
||||
changed.emit("vibration", value)
|
||||
save()
|
||||
|
||||
|
||||
func set_volume_master(value: float) -> void:
|
||||
volume_master = clampf(value, 0.0, 1.0)
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
|
||||
@@ -96,6 +107,7 @@ func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value(SECTION, "screen_shake", screen_shake)
|
||||
cfg.set_value(SECTION, "gore", gore)
|
||||
cfg.set_value(SECTION, "vibration", vibration)
|
||||
cfg.set_value(SECTION, "volume_master", volume_master)
|
||||
cfg.set_value(SECTION, "volume_ambient", volume_ambient)
|
||||
cfg.set_value(SECTION, "volume_effects", volume_effects)
|
||||
@@ -110,6 +122,7 @@ func load_saved() -> void:
|
||||
return
|
||||
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
|
||||
gore = cfg.get_value(SECTION, "gore", true) as bool
|
||||
vibration = cfg.get_value(SECTION, "vibration", true) as bool
|
||||
volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float
|
||||
volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float
|
||||
volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float
|
||||
|
||||
@@ -7,6 +7,7 @@ extends Node
|
||||
|
||||
const SAVE_PATH := "user://controls.cfg"
|
||||
const SECTION := "keybinds"
|
||||
const JOY_SECTION := "joybinds"
|
||||
|
||||
## Actions the options menu lets the player rebind (keyboard only).
|
||||
const REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
@@ -20,6 +21,15 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
&"ability_roll",
|
||||
]
|
||||
|
||||
## Actions the options menu also lets the player rebind on a gamepad. Movement stays on the
|
||||
## D-pad/stick (standard, not individually remapped); only the face-button abilities are.
|
||||
const JOY_REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
&"ability_kick",
|
||||
&"ability_dash",
|
||||
&"ability_slam",
|
||||
&"ability_roll",
|
||||
]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_ensure_gamepad_confirm_binding()
|
||||
@@ -35,15 +45,20 @@ func _ready() -> void:
|
||||
# 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
|
||||
## Device id of whichever gamepad was pressed most recently — which controller's name we
|
||||
## check to pick Xbox vs PlayStation glyphs when more than one is connected.
|
||||
var _last_joy_device: int = 0
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventJoypadButton:
|
||||
if event.pressed:
|
||||
_last_input_is_gamepad = true
|
||||
_last_joy_device = event.device
|
||||
elif event is InputEventJoypadMotion:
|
||||
if absf(event.axis_value) > _JOY_AXIS_DEADZONE:
|
||||
_last_input_is_gamepad = true
|
||||
_last_joy_device = event.device
|
||||
elif event is InputEventKey:
|
||||
if event.pressed:
|
||||
_last_input_is_gamepad = false
|
||||
@@ -59,23 +74,88 @@ func using_gamepad() -> bool:
|
||||
|
||||
|
||||
# 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).
|
||||
# the browser's Standard Gamepad Mapping on web — so a position-based glyph table reads
|
||||
# correctly for any controller brand with no per-device special-casing (a Nintendo pad's
|
||||
# physically-labelled "A" is button 1 here, same as "B" on Xbox; PlayStation's ✕/○/□/△ sit
|
||||
# in the exact same four positions as Xbox's A/B/X/Y, so the same button-index keys apply).
|
||||
const _XBOX_BUTTON_GLYPHS := {
|
||||
JOY_BUTTON_A: "A",
|
||||
JOY_BUTTON_B: "B",
|
||||
JOY_BUTTON_X: "X",
|
||||
JOY_BUTTON_Y: "Y",
|
||||
}
|
||||
const _PLAYSTATION_BUTTON_GLYPHS := {
|
||||
JOY_BUTTON_A: "✕",
|
||||
JOY_BUTTON_B: "○",
|
||||
JOY_BUTTON_X: "□",
|
||||
JOY_BUTTON_Y: "△",
|
||||
}
|
||||
## Substrings (checked lower-case) that identify a controller as a PlayStation pad from
|
||||
## Input.get_joy_name() — which varies a lot by OS/browser/driver, so this is necessarily a
|
||||
## heuristic. "wireless controller" is what macOS/some browsers call a DualShock 4 with no
|
||||
## further detail — but a Bluetooth-connected Xbox pad reports as "Xbox Wireless Controller",
|
||||
## so _is_playstation_pad checks for "xbox" first and bails out before these hints, or that
|
||||
## exact string would misfire as PlayStation. Anything that matches neither reads as
|
||||
## Xbox-style — the safe default, since unrecognised/generic pads and the browser's Standard
|
||||
## Gamepad Mapping are Xbox-shaped.
|
||||
const _PLAYSTATION_NAME_HINTS := [
|
||||
"sony", "playstation", "dualshock", "dualsense", "ps3", "ps4", "ps5", "wireless controller",
|
||||
]
|
||||
|
||||
|
||||
## 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:
|
||||
## True if the given gamepad's reported name looks like a PlayStation pad (DualShock/
|
||||
## DualSense), false (Xbox-style) for everything else, including unknown controllers.
|
||||
func _is_playstation_pad(device: int) -> bool:
|
||||
var name := Input.get_joy_name(device).to_lower()
|
||||
if name.contains("xbox"):
|
||||
return false
|
||||
for hint: String in _PLAYSTATION_NAME_HINTS:
|
||||
if name.contains(hint):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Returns the InputEventJoypadButton bound to `action`, or null.
|
||||
func _get_joy_button_event(action: StringName) -> InputEventJoypadButton:
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton:
|
||||
return _XBOX_BUTTON_GLYPHS.get(event.button_index, "?")
|
||||
return event
|
||||
return null
|
||||
|
||||
|
||||
## Face-button glyph for the action's bound joypad button — Xbox letters (A/B/X/Y) or
|
||||
## PlayStation shapes (✕/○/□/△) depending on whichever pad was pressed most recently — or
|
||||
## "—" if the action has no joypad binding.
|
||||
func get_joy_button_label(action: StringName) -> String:
|
||||
var event := _get_joy_button_event(action)
|
||||
if event == null:
|
||||
return "—"
|
||||
var glyphs := _PLAYSTATION_BUTTON_GLYPHS if _is_playstation_pad(_last_joy_device) \
|
||||
else _XBOX_BUTTON_GLYPHS
|
||||
return glyphs.get(event.button_index, "Btn %d" % event.button_index)
|
||||
|
||||
|
||||
## Replaces the joypad button bound to `action`, keeping every other event (keyboard,
|
||||
## alternates) in place.
|
||||
func rebind_joy(action: StringName, event: InputEventJoypadButton) -> void:
|
||||
_rebind_joy_no_save(action, event)
|
||||
save()
|
||||
|
||||
|
||||
## Rebuilds the action's event list, swapping the first joypad-button event for `event` in
|
||||
## place so ordering is preserved, same as _rebind_no_save() does for keyboard.
|
||||
func _rebind_joy_no_save(action: StringName, event: InputEventJoypadButton) -> void:
|
||||
var events := InputMap.action_get_events(action)
|
||||
InputMap.action_erase_events(action)
|
||||
var replaced := false
|
||||
for existing: InputEvent in events:
|
||||
if not replaced and existing is InputEventJoypadButton:
|
||||
InputMap.action_add_event(action, event)
|
||||
replaced = true
|
||||
else:
|
||||
InputMap.action_add_event(action, existing)
|
||||
if not replaced:
|
||||
InputMap.action_add_event(action, event)
|
||||
|
||||
|
||||
## Ability-bar keycap label for `action`: the bound Xbox-style face button while a gamepad
|
||||
@@ -293,13 +373,14 @@ func rigid_skin_enabled() -> bool:
|
||||
return DP.b("use_rigid_skin")
|
||||
|
||||
|
||||
## Rumble a connected gamepad, gated on the DP "gamepad_vibration" flag. Off web this is a thin
|
||||
## wrapper over Input.start_joy_vibration(); on web that call is a documented engine no-op
|
||||
## Rumble a connected gamepad, gated on the player-facing Settings.vibration toggle and the
|
||||
## DP "gamepad_vibration" debug flag (either can silence it). Off web this is a thin wrapper
|
||||
## over Input.start_joy_vibration(); on web that call is a documented engine no-op
|
||||
## (godotengine/godot#96985 — never wired to the browser side), so there we drive the Gamepad
|
||||
## Haptic Actuator API ourselves. Safe no-op wherever it's unsupported (Safari, most Firefox
|
||||
## versions, or a controller with no rumble motors) — desktop Chromium/Edge is the target.
|
||||
func vibrate(device: int, weak_magnitude: float, strong_magnitude: float, duration: float) -> void:
|
||||
if not DP.b("gamepad_vibration"):
|
||||
if not Settings.vibration or not DP.b("gamepad_vibration"):
|
||||
return
|
||||
if not OS.has_feature("web"):
|
||||
Input.start_joy_vibration(device, weak_magnitude, strong_magnitude, duration)
|
||||
@@ -344,7 +425,7 @@ func vibrate_for(device: int, weak_magnitude: float, strong_magnitude: float,
|
||||
|
||||
|
||||
## Returns the first keyboard event bound to an action, or null.
|
||||
func get_key_event(action: StringName) -> InputEventKey:
|
||||
func _get_key_event(action: StringName) -> InputEventKey:
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if event is InputEventKey:
|
||||
return event
|
||||
@@ -353,7 +434,7 @@ func get_key_event(action: StringName) -> InputEventKey:
|
||||
|
||||
## Human-readable label for the action's current key (e.g. "W", "Space").
|
||||
func get_key_label(action: StringName) -> String:
|
||||
var event := get_key_event(action)
|
||||
var event := _get_key_event(action)
|
||||
if event == null:
|
||||
return "—"
|
||||
return event.as_text_physical_keycode()
|
||||
@@ -362,16 +443,20 @@ func get_key_label(action: StringName) -> String:
|
||||
## Replaces the primary keyboard event of `action` with `event`, keeping
|
||||
## every other event (e.g. arrow-key alternates, joypad) in its place.
|
||||
func rebind(action: StringName, event: InputEventKey) -> void:
|
||||
rebind_no_save(action, event)
|
||||
_rebind_no_save(action, event)
|
||||
save()
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
for action: StringName in REBINDABLE_ACTIONS:
|
||||
var event := get_key_event(action)
|
||||
var event := _get_key_event(action)
|
||||
if event != null:
|
||||
cfg.set_value(SECTION, String(action), event.physical_keycode)
|
||||
for action: StringName in JOY_REBINDABLE_ACTIONS:
|
||||
var joy_event := _get_joy_button_event(action)
|
||||
if joy_event != null:
|
||||
cfg.set_value(JOY_SECTION, String(action), joy_event.button_index)
|
||||
cfg.save(SAVE_PATH)
|
||||
|
||||
|
||||
@@ -386,12 +471,20 @@ func load_saved() -> void:
|
||||
var keycode: int = cfg.get_value(SECTION, key)
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = keycode as Key
|
||||
rebind_no_save(action, event)
|
||||
_rebind_no_save(action, event)
|
||||
for action: StringName in JOY_REBINDABLE_ACTIONS:
|
||||
var joy_key := String(action)
|
||||
if not cfg.has_section_key(JOY_SECTION, joy_key):
|
||||
continue
|
||||
var button_index: int = cfg.get_value(JOY_SECTION, joy_key)
|
||||
var joy_event := InputEventJoypadButton.new()
|
||||
joy_event.button_index = button_index
|
||||
_rebind_joy_no_save(action, joy_event)
|
||||
|
||||
|
||||
## Rebuilds the action's event list, swapping the first keyboard event for
|
||||
## `event` in place so ordering (and thus the "primary" key) is preserved.
|
||||
func rebind_no_save(action: StringName, event: InputEventKey) -> void:
|
||||
func _rebind_no_save(action: StringName, event: InputEventKey) -> void:
|
||||
var events := InputMap.action_get_events(action)
|
||||
InputMap.action_erase_events(action)
|
||||
var replaced := false
|
||||
|
||||
+40
-3
@@ -80,6 +80,8 @@ const ACTION_LABELS: Dictionary = {
|
||||
|
||||
var _listening_action: StringName = &""
|
||||
var _listening_button: Button = null
|
||||
var _listening_joy_action: StringName = &""
|
||||
var _listening_joy_button: Button = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -207,6 +209,7 @@ func _build_options_toggles() -> void:
|
||||
_add_section_label("── Gameplay ──", idx); idx += 1
|
||||
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1
|
||||
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1
|
||||
_add_toggle_row("Controller Vibration", Settings.vibration, Settings.set_vibration, idx); idx += 1
|
||||
_add_section_label("── Controls ──", idx)
|
||||
|
||||
|
||||
@@ -316,6 +319,16 @@ func _build_rebind_rows() -> void:
|
||||
key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button))
|
||||
row.add_child(key_button)
|
||||
|
||||
# Movement stays on the D-pad/stick (standard, not individually remapped) — only the
|
||||
# face-button abilities get a gamepad rebind button, alongside the keyboard one.
|
||||
if Controls.JOY_REBINDABLE_ACTIONS.has(String(action)):
|
||||
var joy_button := Button.new()
|
||||
joy_button.custom_minimum_size = Vector2(72, 0)
|
||||
joy_button.text = Controls.get_joy_button_label(action)
|
||||
_style_button(joy_button, false)
|
||||
joy_button.pressed.connect(_on_rebind_joy_pressed.bind(action, joy_button))
|
||||
row.add_child(joy_button)
|
||||
|
||||
rebind_list.add_child(row)
|
||||
|
||||
|
||||
@@ -369,17 +382,26 @@ func _on_rebind_pressed(action: StringName, button: Button) -> void:
|
||||
button.text = "Press a key…"
|
||||
|
||||
|
||||
func _on_rebind_joy_pressed(action: StringName, button: Button) -> void:
|
||||
_cancel_listening()
|
||||
_listening_joy_action = action
|
||||
_listening_joy_button = button
|
||||
button.text = "Press…"
|
||||
|
||||
|
||||
func _cancel_listening() -> void:
|
||||
if _listening_button != null:
|
||||
_listening_button.text = Controls.get_key_label(_listening_action)
|
||||
if _listening_joy_button != null:
|
||||
_listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action)
|
||||
_listening_action = &""
|
||||
_listening_button = null
|
||||
_listening_joy_action = &""
|
||||
_listening_joy_button = null
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _listening_action == &"":
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if _listening_action != &"" and event is InputEventKey and event.pressed and not event.echo:
|
||||
if event.physical_keycode == KEY_ESCAPE:
|
||||
_cancel_listening()
|
||||
else:
|
||||
@@ -390,3 +412,18 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
_listening_action = &""
|
||||
_listening_button = null
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
if _listening_joy_action != &"":
|
||||
if event is InputEventKey and event.pressed and not event.echo \
|
||||
and event.physical_keycode == KEY_ESCAPE:
|
||||
_cancel_listening()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventJoypadButton and event.pressed:
|
||||
var new_joy_event := InputEventJoypadButton.new()
|
||||
new_joy_event.button_index = event.button_index
|
||||
Controls.rebind_joy(_listening_joy_action, new_joy_event)
|
||||
_listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action)
|
||||
_listening_joy_action = &""
|
||||
_listening_joy_button = null
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
Reference in New Issue
Block a user