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)