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() ## 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 ## 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)