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" ## 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", ] func _ready() -> void: load_saved() _install_web_fullscreen_hook() # ── 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") ## 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) 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) ## 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)