981ebf1910
Remove tools/fstest.html scratch page used to probe browser fullscreen/orientation APIs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
291 lines
11 KiB
GDScript
291 lines
11 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()
|
|
_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)
|