Files
Bullosseum/systems/input/controls_manager.gd
T
richard 431ee295ae 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
2026-09-10 19:33:02 +03:00

505 lines
21 KiB
GDScript

extends Node
## Global keybinding manager (autoload `Controls`).
## Loads saved keyboard bindings on startup and applies them to InputMap so
## rebinds made in the intro menu persist into the game scene. Only the
## keyboard event of each action is overridden; joypad/mouse events are kept.
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 = [
&"move_forward",
&"move_back",
&"move_left",
&"move_right",
&"ability_kick",
&"ability_dash",
&"ability_slam",
&"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()
load_saved()
_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
## 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
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 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",
]
## 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 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
## 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 —
## 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
## of the box while "confirming" the focused button does nothing. Bind joypad button 0 (the
## bottom face button in Xbox-style position mapping) as the confirm button.
func _ensure_gamepad_confirm_binding() -> void:
var alt := InputEventJoypadButton.new()
alt.button_index = JOY_BUTTON_A
if not InputMap.action_has_event(&"ui_accept", alt):
InputMap.action_add_event(&"ui_accept", alt)
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls
# requestFullscreen() synchronously inside the real user-gesture event — the same thing
# three.js games do. Godot dispatches input from its rAF render loop, one hop removed from the
# DOM event, so both Godot's window_set_mode and a JS call made from _input() run outside the
# gesture's activation and some mobile browsers reject them. The listener below sidesteps that
# entirely, and — left attached — re-enters fullscreen on the next tap if the player leaves it.
# All of it is guarded, so iPhone Safari (no requestFullscreen) and desktop just no-op cleanly.
func _install_web_fullscreen_hook() -> void:
if not OS.has_feature("web"):
return
JavaScriptBridge.eval("""
(function(){
if (window.__bullFSInit) return;
window.__bullFSInit = true;
window.__bull_fs = 'idle';
// Armed = we should grab fullscreen on the next tap. Starts true so the first tap enters,
// then disarms — so once the player is in (or deliberately leaves via Esc / back / swipe)
// ordinary gameplay taps never yank them back in. WebStartGate re-arms via arm_fullscreen().
window.__bullFSArmed = true;
window.__bullFS = function(){
try {
if (document.fullscreenElement || document.webkitFullscreenElement) {
window.__bull_fs = 'ok'; window.__bullFSArmed = false; return;
}
if (!window.__bullFSArmed) return;
var c = document.getElementById('canvas') || document.querySelector('canvas')
|| document.documentElement;
var rf = c.requestFullscreen || c.webkitRequestFullscreen
|| c.msRequestFullscreen || c.mozRequestFullScreen;
if (!rf) { window.__bull_fs = 'no-api'; lock(); return; }
window.__bull_fs = 'req';
var p = rf.call(c);
if (p && p.then) {
p.then(function(){ window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); },
function(e){ window.__bull_fs = 'err:' + (e && e.name || e); });
} else { window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); }
} catch(e){ window.__bull_fs = 'ex:' + (e && e.name || e); }
function lock(){ try { if (screen.orientation && screen.orientation.lock)
screen.orientation.lock('landscape').catch(function(){}); } catch(e){} }
};
// pointerup / touchend / click grant transient activation (pointerdown/touchstart do not).
['pointerup','touchend','click'].forEach(function(ev){
window.addEventListener(ev, function(){ window.__bullFS(); }, true);
});
})();
""", true)
## Ask to enter fullscreen + landscape now (e.g. from WebStartGate's tap). The DOM listener
## installed above normally beats this to it on the same gesture; this is the explicit path and
## is a safe no-op if we're already fullscreen. No-op off web.
func request_fullscreen_landscape() -> void:
if not OS.has_feature("web"):
return
_install_web_fullscreen_hook()
JavaScriptBridge.eval("if (window.__bullFS) window.__bullFS();", true)
## Re-arm the "grab fullscreen on next tap" hook, so the next tap re-enters. WebStartGate calls
## this whenever it shows itself (boot, or after the player left fullscreen). No-op off web.
func arm_fullscreen() -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval("window.__bullFSArmed = true;", true)
## Whether the browser can go fullscreen at all. False on iPhone Safari (no requestFullscreen),
## which lets WebStartGate stop prompting there instead of nagging forever. True off web (n/a).
func fullscreen_supported() -> bool:
if not OS.has_feature("web"):
return true
var r: Variant = JavaScriptBridge.eval(
"(function(){var c=document.getElementById('canvas')||document.documentElement;"
+ "return !!(c.requestFullscreen||c.webkitRequestFullscreen"
+ "||c.msRequestFullscreen||c.mozRequestFullScreen)?1:0;})()", true)
if r is bool:
return r
if r is int or r is float:
return int(r) != 0
return false
## Is the browser currently fullscreen? No-op-ish off web (returns false).
func is_browser_fullscreen() -> bool:
if not OS.has_feature("web"):
return false
var r: Variant = JavaScriptBridge.eval(
"(document.fullscreenElement||document.webkitFullscreenElement)?1:0", true)
if r is bool:
return r
if r is int or r is float:
return int(r) != 0
return false
## One-line web diagnostic for the on-device debug overlay: secure context, cross-origin
## isolation (threads), last fullscreen attempt result, and whether we're fullscreen now.
func fullscreen_status() -> String:
if not OS.has_feature("web"):
return "native"
var r: Variant = JavaScriptBridge.eval(
"'sec=' + (window.isSecureContext ? 1 : 0) + ' iso=' + (window.crossOriginIsolated ? 1 : 0)"
+ " + ' fs=' + (window.__bull_fs || '?')"
+ " + ' cur=' + ((document.fullscreenElement || document.webkitFullscreenElement) ? 1 : 0)",
true)
return str(r) if r != null else "?"
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
## instead of keyboard hints. Platform detection is cached (it can't change mid-run)
## so the per-frame/per-input callers don't repay the cost — but the DP force flag is
## re-read every call so the console can toggle the layout live on a desktop build.
var _touch_ui_cached: int = -1 # -1 = not yet detected, 0 = false, 1 = true
func use_touch_ui() -> bool:
if DP.b("force_touch_controls"):
return true
if _touch_ui_cached == -1:
_touch_ui_cached = 1 if _detect_touch_ui() else 0
return _touch_ui_cached == 1
## True only on touch-primary devices (phones, tablets). Detection is deliberately
## conservative so a desktop never gets the on-screen thumb UI.
##
## DisplayServer.is_touchscreen_available() is unusable here on two fronts: on native
## desktop `emulate_touch_from_mouse` (on for testing) forces it true, and on the web build
## it maps to `'ontouchstart' in window`, which desktop Chromium reports true even with no
## touchscreen. So on web we ask the browser two questions instead: does it report touch
## points, AND is the *primary* pointer coarse? A desktop-with-mouse (even a touch laptop)
## answers no to the second and keeps the desktop HUD; a phone/tablet answers yes to both.
## maxTouchPoints also catches iPadOS, which masquerades as desktop Safari.
func _detect_touch_ui() -> bool:
if (
OS.has_feature("mobile")
or OS.has_feature("web_android")
or OS.has_feature("web_ios")
):
return true
if not OS.has_feature("web"):
return false
var js := "(navigator.maxTouchPoints > 0) && window.matchMedia('(pointer: coarse)').matches"
var touch_primary: Variant = JavaScriptBridge.eval(js, true)
if touch_primary is bool:
return touch_primary
if touch_primary is int or touch_primary is float:
return int(touch_primary) != 0
return false
## Whether on-screen debug overlays (touch state + render diagnostics) should show. True on
## the DP "touch_debug" flag, or — so it can be switched on from a phone where no console is
## reachable — when the page URL carries a `debug` query param (append `?debug=1`).
var _url_debug: int = -1
func debug_overlay() -> bool:
if DP.b("touch_debug"):
return true
if _url_debug == -1:
_url_debug = 0
if OS.has_feature("web"):
var has: Variant = JavaScriptBridge.eval(
"String(window.location.search).indexOf('debug') >= 0", true)
if (has is bool and has) or ((has is int or has is float) and int(has) != 0):
_url_debug = 1
return _url_debug == 1
## Whether to apply the rigid-skin workaround (rigid_skin.gd) instead of native GPU skinning.
## Defaults to the DP "use_rigid_skin" flag (on for web), but a page URL param overrides it so
## native skinning can be A/B'd on the actual phone with no console reachable: append `?noskin=1`
## to force it OFF (does the device render skinned meshes natively now — e.g. after a Godot web
## template update? — and do the bear's rigid-skin seam holes go away), or `?skin=1` to force ON.
## -1 = not read yet, 0 = url forces off, 1 = url forces on, 2 = no url override (use DP flag).
var _url_skin: int = -1
func rigid_skin_enabled() -> bool:
if _url_skin == -1:
_url_skin = 2
if OS.has_feature("web"):
var q: Variant = JavaScriptBridge.eval("String(window.location.search)", true)
var s: String = str(q) if q != null else ""
if s.find("noskin") >= 0:
_url_skin = 0
elif s.find("skin") >= 0:
_url_skin = 1
if _url_skin == 0:
return false
if _url_skin == 1:
return true
return DP.b("use_rigid_skin")
## 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 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)
return
JavaScriptBridge.eval("""
(function(){
try {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
var pad = pads[%d];
var act = pad && pad.vibrationActuator;
if (act && act.playEffect) {
act.playEffect('dual-rumble', {
duration: %d,
weakMagnitude: %f,
strongMagnitude: %f
});
}
} catch (e) {}
})();
""" % [device, int(duration * 1000.0), weak_magnitude, strong_magnitude], true)
## Godot's own start_joy_vibration() docs warn that a single long effect is unreliable —
## "not every hardware is compatible with long effects; you may want to make a shorter
## effect and make it repeat instead." That's exactly what broke the lobby gate's 7-second
## rumble (the short 0.2 s hit-vibration works fine): this repeats vibrate() as short pulses
## to cover the same total duration instead of one long call.
## Explicitly stopping between pulses (rather than letting each one time out on its own) and
## spacing them out avoids re-triggering the effect faster than some drivers/browsers can
## retire the previous one — back-to-back-to-back start calls left the gamepad's rumble
## motor stuck reporting "busy" for the rest of the session on at least one setup.
func vibrate_for(device: int, weak_magnitude: float, strong_magnitude: float,
total_duration: float, pulse: float = 0.25, gap: float = 0.6) -> void:
var elapsed := 0.0
while elapsed < total_duration:
vibrate(device, weak_magnitude, strong_magnitude, pulse)
await get_tree().create_timer(pulse).timeout
if not OS.has_feature("web"):
Input.stop_joy_vibration(device)
await get_tree().create_timer(gap).timeout
elapsed += pulse + gap
## Returns the first keyboard event bound to an action, or null.
func _get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventKey:
return event
return null
## 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)
if event == null:
return "—"
return event.as_text_physical_keycode()
## 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)
save()
func save() -> void:
var cfg := ConfigFile.new()
for action: StringName in REBINDABLE_ACTIONS:
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)
func load_saved() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return
for action: StringName in REBINDABLE_ACTIONS:
var key := String(action)
if not cfg.has_section_key(SECTION, key):
continue
var keycode: int = cfg.get_value(SECTION, key)
var event := InputEventKey.new()
event.physical_keycode = keycode as Key
_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:
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 InputEventKey:
InputMap.action_add_event(action, event)
replaced = true
else:
InputMap.action_add_event(action, existing)
if not replaced:
InputMap.action_add_event(action, event)
func reset_defaults() -> void:
InputMap.load_from_project_settings()
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)