Files
Bullosseum/controls_manager.gd
T
2026-08-23 20:32:52 +03:00

186 lines
6.7 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"
## 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()
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# Browsers only allow requestFullscreen / screen.orientation.lock from inside a user
# gesture, so we piggyback on the first tap/click of the web build to go fullscreen and
# lock to landscape. One-shot; input still flows to the menu/game normally (never consumed).
var _fs_triggered: bool = false
func _input(event: InputEvent) -> void:
if _fs_triggered or not OS.has_feature("web"):
return
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed)
if gesture:
_fs_triggered = true
request_fullscreen_landscape()
## Enter browser fullscreen and lock to landscape. Must be called from a user-gesture
## context (first tap, or a button press). No-op off web. Android Chrome/Brave honour the
## orientation lock; iOS Safari ignores it (unsupported), so the .catch() swallows the reject.
func request_fullscreen_landscape() -> void:
if not OS.has_feature("web"):
return
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
JavaScriptBridge.eval(
"(function(){function l(){try{if(screen.orientation&&screen.orientation.lock)"
+ "screen.orientation.lock('landscape').catch(function(){});}catch(e){}}"
+ "l();document.addEventListener('fullscreenchange',l,{once:true});})()", true)
## 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)