Files
Bullosseum/touch_controls.gd
T
richard 981ebf1910 Add blood decals, gore, mobile HUD, web start gate + touch/perf tests
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
2026-09-04 14:07:57 +03:00

657 lines
26 KiB
GDScript

extends CanvasLayer
## On-screen controls for touch devices: a floating movement joystick on the left
## and a cluster of ability buttons on the right. Instantiated by hud.gd.
##
## All interaction is driven from raw `_input()` with manual hit-testing, NOT from
## Control.gui_input. On the web/wasm build the overlay lives in a CanvasLayer nested
## under the HUD, and GUI touch routing into a nested CanvasLayer renders fine but
## silently drops input — a standard Button here never fires. `_input()` sees every
## InputEventScreenTouch regardless of nesting, mouse_filter, or emulate_mouse_from_touch,
## so the joystick and buttons work reliably across browsers. Everything is drawn in one
## Control (_surface) whose rects are the single source of truth for both drawing and
## hit-testing.
##
## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive (via
## Input.action_press with an analog strength), so player.gd needs no touch awareness — it
## just reads Input.get_vector() as always. Ability buttons call the player's public
## try_activate_ability(), sharing the keyboard's cooldown gate.
##
## Shown only on touch platforms (see Controls.use_touch_ui), or when DP
## "force_touch_controls" is on so the layout can be exercised on a desktop build
## (emulate_touch_from_mouse turns a mouse click into a touch event for that).
# move_* actions, indexed by the four joystick push directions.
const _MOVE_RIGHT: StringName = &"move_right"
const _MOVE_LEFT: StringName = &"move_left"
const _MOVE_BACK: StringName = &"move_back"
const _MOVE_FORWARD: StringName = &"move_forward"
# Ability slots in player.gd order: kick=0, dash=1, slam=2, roll=3.
const _ABILITY_ICONS: Array[String] = [
"res://HUD/KICK.png",
"res://HUD/DASH.png",
"res://HUD/SLAM.png",
"res://HUD/ROLL.png",
]
const _ABILITY_LETTERS: Array[String] = ["K", "D", "S", "R"]
const _ABILITY_ACCENT: Array[Color] = [
Color(1.00, 0.62, 0.16), # Kick — ember orange
Color(0.45, 0.75, 1.00), # Dash — sky blue
Color(0.98, 0.34, 0.20), # Slam — crimson
Color(1.00, 0.82, 0.32), # Roll — gold
]
# Sentinel touch indices. A real finger uses its own non-negative index; the mouse
# (desktop force-flag testing, when no real touch has been seen) uses _MOUSE.
const _MOUSE: int = -2
var _surface: Control
var _player: Node = null
# Cached textures + styleboxes so the per-frame redraw (cooldowns animate) allocates nothing.
var _ability_tex: Array[Texture2D] = []
var _ability_style: Array[StyleBoxFlat] = []
var _menu_style: StyleBoxFlat
# Layout rects in screen space — the single source of truth for drawing AND hit-testing.
# Ability buttons are hit-tested against a rect grown by _ability_hit_pad (fills the gaps
# between them) so a near-miss still fires — the visual box stays the smaller drawn rect.
# _slot_pressed drives a brief press highlight so a tap reads as registered.
var _stick_zone: Rect2 = Rect2()
var _ability_rects: Array[Rect2] = []
var _ability_hit_pad: float = 0.0
var _slot_pressed: Array[bool] = [false, false, false, false]
var _menu_rect: Rect2 = Rect2()
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a
# first-time player sees where to plant their thumb instead of guessing. _hint_pulse gives
# it a slow breathing alpha for discoverability.
var _stick_hint_center: Vector2 = Vector2()
var _hint_pulse: float = 0.0
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is
# the current thumb position; both in screen coordinates. _stick_index is the finger driving
# the stick (-1 = none): keying release/drag off it (not just the owner dict) lets the latest
# left-zone touch always re-acquire the stick, so a stranded stick — e.g. a touchend lost to a
# resize — self-heals the moment the player touches down again.
var _stick_active: bool = false
var _stick_index: int = -1
var _origin: Vector2 = Vector2.ZERO
var _knob: Vector2 = Vector2.ZERO
var _stick_radius: float = 110.0
# Live fingers by index → screen position, plus what each finger is doing ("stick", a slot
# int, "menu", or "camera"). Ownership keeps a second finger on a button from stealing the
# stick, and keeps steering/ability fingers from being mistaken for a pinch gesture.
var _touches: Dictionary = {}
var _touch_owner: Dictionary = {}
var _saw_real_touch: bool = false
# Pinch-to-zoom: only "camera" fingers (right side, not on any button) count, so steering
# with one thumb while tapping an ability with the other never reads as a zoom.
var _pinching: bool = false
var _pinch_dist: float = 0.0
# Diagnostics surfaced by DP "touch_debug" — lets us see, on the actual device, whether
# events arrive and where, since no console is reachable there.
var _dbg_events: int = 0
var _dbg_last_pos: Vector2 = Vector2.ZERO
# Profiler peak-hold (debug overlay). Godot's game logic — every _process / _physics_process —
# runs on ONE thread on web regardless of thread_support, so a lag spike shows up as a jump in
# process(script) or physics(ragdoll) ms, NOT in FPS alone. We hold the worst value seen in the
# last _PEAK_HOLD_MS so an ability-press spike stays readable instead of flashing by in a frame.
# JS-backed fields (fullscreen status, cross-origin isolation) are cached so the overlay's own
# per-frame JavaScriptBridge.eval doesn't inflate the very process time it's trying to measure.
const _PEAK_HOLD_MS: float = 2000.0
var _peak_proc_ms: float = 0.0
var _peak_phys_ms: float = 0.0
var _peak_proc_t: int = 0
var _peak_phys_t: int = 0
var _iso_cached: int = -1
var _fs_cache: String = "?"
var _fs_cache_t: int = 0
# Cached real GPU name. RenderingServer.get_video_adapter_name() returns the browser's
# privacy-masked "WebKit WebGL" on the web; the true chip (Adreno/Mali/…) — which decides
# whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via
# the WEBGL_debug_renderer_info extension on Godot's own canvas context. "" = not yet probed.
var _gpu_unmasked: String = ""
func _unmasked_gpu() -> String:
if _gpu_unmasked != "":
return _gpu_unmasked
_gpu_unmasked = "n/a"
if OS.has_feature("web"):
var js := """(function(){try{
var c=document.getElementById('canvas');
var gl=c?c.getContext('webgl2'):null;
if(!gl){var t=document.createElement('canvas');gl=t.getContext('webgl2')||t.getContext('webgl');}
if(!gl)return 'no-gl';
var e=gl.getExtension('WEBGL_debug_renderer_info');
if(!e)return 'masked';
return String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL));
}catch(err){return 'err';}})()"""
var r: Variant = JavaScriptBridge.eval(js, true)
if r is String and (r as String) != "":
_gpu_unmasked = r
return _gpu_unmasked
func _ready() -> void:
layer = 10 # below the HUD (11) so the game-over screen always draws on top
process_mode = Node.PROCESS_MODE_ALWAYS
for slot: int in 4:
var tex: Texture2D = null
if ResourceLoader.exists(_ABILITY_ICONS[slot]):
tex = load(_ABILITY_ICONS[slot])
_ability_tex.append(tex)
_ability_style.append(_button_style(_ABILITY_ACCENT[slot]))
_menu_style = _button_style(Color(1.00, 0.78, 0.22, 0.8))
_surface = Control.new()
_surface.set_anchors_preset(Control.PRESET_FULL_RECT)
_surface.mouse_filter = Control.MOUSE_FILTER_IGNORE
_surface.visible = false
_surface.draw.connect(_draw_surface)
add_child(_surface)
_relayout()
get_viewport().size_changed.connect(_relayout)
# ── Layout ────────────────────────────────────────────────────────────────────
# Left half is the joystick zone (touch anywhere in it to plant the base under the thumb);
# the ability cluster and menu button sit in the right half so they never overlap it.
func _relayout() -> void:
# A resize / orientation flip invalidates every rect and can swallow an in-flight touchend
# (the browser drops it mid-transition), which is exactly what used to strand the joystick.
# Clearing touch state here means the layout always comes back to a clean slate.
_reset_touch_state()
var vp := get_viewport().get_visible_rect().size
var short := minf(vp.x, vp.y)
_stick_radius = clampf(short * 0.16, 80.0, 150.0)
_stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y))
var bs := clampf(short * 0.22, 108.0, 152.0) # ability button edge — bigger, easier thumb target
var gap := bs * 0.22
var margin := bs * 0.34
_ability_hit_pad = gap * 0.5 # grow hit rects to meet in the gaps; near-misses still register
var right := vp.x - margin
var bottom := vp.y - margin
var col0 := right - bs * 2.0 - gap
var col1 := right - bs
var row0 := bottom - bs * 2.0 - gap
var row1 := bottom - bs
_ability_rects = [
Rect2(col0, row0, bs, bs), # kick
Rect2(col1, row0, bs, bs), # dash
Rect2(col0, row1, bs, bs), # slam
Rect2(col1, row1, bs, bs), # roll
]
var ms := clampf(short * 0.11, 60.0, 84.0) # bigger menu button
var mm := ms * 0.34 # and pulled further off the corner
_menu_rect = Rect2(vp.x - ms - mm, mm, ms, ms)
_stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74)
_surface.queue_redraw()
# ── Input ───────────────────────────────────────────────────────────────────
# Raw events only. Real touches drive the touch path; once any real touch is seen the
# mouse path is disabled (emulate_mouse_from_touch would otherwise double-fire every tap).
# The mouse path stays live for desktop force-flag testing where no touchscreen exists.
func _input(event: InputEvent) -> void:
if get_tree().paused or not Controls.use_touch_ui():
return
if event is InputEventScreenTouch:
_saw_real_touch = true
var t := event as InputEventScreenTouch
_dbg_events += 1
_dbg_last_pos = t.position
if t.pressed:
_touches[t.index] = t.position
_on_press(t.index, t.position)
else:
_on_release(t.index, t.position)
_touches.erase(t.index)
_update_pinch_state()
_surface.queue_redraw()
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag:
var d := event as InputEventScreenDrag
_dbg_events += 1
_dbg_last_pos = d.position
_touches[d.index] = d.position
_on_drag(d.index, d.position)
_surface.queue_redraw()
get_viewport().set_input_as_handled()
elif not _saw_real_touch and event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
_dbg_events += 1
_dbg_last_pos = mb.position
if mb.pressed:
_touches[_MOUSE] = mb.position
_on_press(_MOUSE, mb.position)
else:
_on_release(_MOUSE, mb.position)
_touches.erase(_MOUSE)
_update_pinch_state()
_surface.queue_redraw()
elif not _saw_real_touch and event is InputEventMouseMotion:
if _touch_owner.get(_MOUSE, null) == "stick":
_touches[_MOUSE] = (event as InputEventMouseMotion).position
_update_stick(_touches[_MOUSE])
_surface.queue_redraw()
func _on_press(index: int, pos: Vector2) -> void:
if _menu_rect.has_point(pos):
_touch_owner[index] = "menu"
return
for slot: int in _ability_rects.size():
if _ability_rects[slot].grow(_ability_hit_pad).has_point(pos):
_touch_owner[index] = slot
_slot_pressed[slot] = true
if _player != null:
_player.call(&"try_activate_ability", slot)
return
if _stick_zone.has_point(pos):
# Last touch in the zone (re)acquires the stick — this is what recovers a stranded stick
# whose finger's release was never delivered. A prior owner is superseded via _stick_index.
_touch_owner[index] = "stick"
_begin_stick(index, pos)
return
_touch_owner[index] = "camera"
func _on_drag(index: int, pos: Vector2) -> void:
match _touch_owner.get(index, null):
"stick":
if index == _stick_index:
_update_stick(pos)
"camera":
if _pinching:
_apply_pinch()
func _on_release(index: int, pos: Vector2) -> void:
var owner: Variant = _touch_owner.get(index, null)
match owner:
"stick":
# Only the finger that currently owns the stick ends it; a superseded finger
# (another thumb took over) just drops its own mapping below.
if index == _stick_index:
_end_stick()
"menu":
if _menu_rect.has_point(pos):
_touch_owner.erase(index)
_return_to_menu()
return
if owner is int:
_slot_pressed[owner] = false
_touch_owner.erase(index)
# ── Joystick ────────────────────────────────────────────────────────────────
func _begin_stick(index: int, pos: Vector2) -> void:
_stick_active = true
_stick_index = index
_origin = pos
_knob = pos
func _update_stick(pos: Vector2) -> void:
if not _stick_active:
return
var offset := pos - _origin
if offset.length() > _stick_radius:
offset = offset.normalized() * _stick_radius
_knob = _origin + offset
_apply_move(offset / _stick_radius)
func _end_stick() -> void:
_stick_active = false
_stick_index = -1
_release_move()
# Translate a normalised stick offset (screen space: +x right, +y down) into analog
# strengths on the four movement actions. Opposite pairs are mutually exclusive, so only
# one of each axis is ever pressed at a time.
func _apply_move(v: Vector2) -> void:
Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x))
Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x))
Input.action_press(_MOVE_BACK, maxf(0.0, v.y))
Input.action_press(_MOVE_FORWARD, maxf(0.0, -v.y))
func _release_move() -> void:
for action: StringName in [_MOVE_RIGHT, _MOVE_LEFT, _MOVE_BACK, _MOVE_FORWARD]:
Input.action_release(action)
# ── Pinch-to-zoom ─────────────────────────────────────────────────────────────
# Only "camera" fingers (open right-side area, not on a button) participate, so it can't
# fire while the player is steering or tapping abilities.
func _camera_touches() -> Array:
var pts: Array = []
for idx: int in _touches:
if _touch_owner.get(idx, null) == "camera":
pts.append(_touches[idx])
return pts
func _update_pinch_state() -> void:
var pts := _camera_touches()
var was := _pinching
_pinching = pts.size() >= 2
if _pinching and not was:
if _stick_active:
_end_stick()
_pinch_dist = (pts[0] as Vector2).distance_to(pts[1] as Vector2)
elif was and not _pinching:
_pinch_dist = 0.0
func _apply_pinch() -> void:
var pts := _camera_touches()
if pts.size() < 2:
return
var dist := (pts[0] as Vector2).distance_to(pts[1] as Vector2)
if _pinch_dist <= 0.0 or dist <= 0.0:
_pinch_dist = dist
return
var cam := get_viewport().get_camera_3d()
if cam != null and cam.has_method(&"adjust_zoom"):
cam.call(&"adjust_zoom", dist / _pinch_dist)
_pinch_dist = dist
# ── Drawing ───────────────────────────────────────────────────────────────────
func _draw_surface() -> void:
for slot: int in _ability_rects.size():
var r: Rect2 = _ability_rects[slot]
_surface.draw_style_box(_ability_style[slot], r)
if _slot_pressed[slot]:
var glow := _ABILITY_ACCENT[slot]
glow.a = 0.28
_surface.draw_rect(r.grow(-r.size.x * 0.10), glow) # press flash so a tap reads as registered
var inner := r.grow(-r.size.x * 0.06) # small inset so the icon nearly fills the button
if _ability_tex[slot] != null:
_draw_icon_fit(_ability_tex[slot], inner)
else:
_draw_centered_text(_ABILITY_LETTERS[slot], r, 40, _ABILITY_ACCENT[slot])
var frac := 0.0
if _player != null:
frac = clampf(_player.call(&"ability_cooldown_fraction", slot), 0.0, 1.0)
if frac > 0.0:
var ch := r.size.y * frac
_surface.draw_rect(
Rect2(r.position.x, r.position.y + r.size.y - ch, r.size.x, ch),
Color(0.0, 0.0, 0.0, 0.55))
_surface.draw_style_box(_menu_style, _menu_rect)
_draw_hamburger(_menu_rect)
if _stick_active:
_surface.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20))
_surface.draw_arc(
_origin, _stick_radius, 0.0, TAU, 48, Color(0.93, 0.88, 0.72, 0.30), 4.0, true)
_surface.draw_circle(_knob, _stick_radius * 0.42, Color(1.00, 0.82, 0.32, 0.55))
elif not _pinching:
_draw_stick_hint()
if Controls.debug_overlay():
_draw_debug()
# A faint "ghost" joystick at rest in the lower-left with a caption, so the movement zone
# is discoverable. Breathes gently via _hint_pulse; vanishes the moment the stick is grabbed.
func _draw_stick_hint() -> void:
var a := 0.16 + 0.12 * (0.5 + 0.5 * sin(_hint_pulse))
var c := _stick_hint_center
var r := _stick_radius * 0.72
_surface.draw_circle(c, r, Color(0.0, 0.0, 0.0, a * 0.45))
_surface.draw_arc(c, r, 0.0, TAU, 40, Color(0.93, 0.88, 0.72, a + 0.14), 3.0, true)
_surface.draw_circle(c, r * 0.32, Color(1.00, 0.82, 0.32, a + 0.16))
var font := ThemeDB.fallback_font
if font != null:
var txt := "DRAG TO MOVE"
var fs := 18
var tw := font.get_string_size(txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
_surface.draw_string(
font, c + Vector2(-tw.x * 0.5, r + 28.0), txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
Color(0.93, 0.88, 0.72, a + 0.30))
func _draw_icon_fit(tex: Texture2D, box: Rect2) -> void:
var ts := tex.get_size()
if ts.x <= 0.0 or ts.y <= 0.0:
return
var scale := minf(box.size.x / ts.x, box.size.y / ts.y)
var ds := ts * scale
_surface.draw_texture_rect(tex, Rect2(box.position + (box.size - ds) * 0.5, ds), false)
func _draw_centered_text(text: String, box: Rect2, size: int, color: Color) -> void:
var font := ThemeDB.fallback_font
if font == null:
return
var tw := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var pos := box.position + (box.size - tw) * 0.5 + Vector2(0.0, tw.y * 0.35)
_surface.draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
# Three stacked bars — a "☰" glyph drawn by hand, since the fallback font that ships with
# the web build has no U+2630 and renders it as tofu / a codepoint number.
func _draw_hamburger(r: Rect2) -> void:
var col := Color(1.00, 0.78, 0.22)
var bw := r.size.x * 0.5
var bh := maxf(3.0, r.size.y * 0.09)
var x := r.position.x + (r.size.x - bw) * 0.5
var cy := r.position.y + r.size.y * 0.5
var gap := r.size.y * 0.22
for i: int in [-1, 0, 1]:
_surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col)
# Live on-device profiler. Reads Godot's Performance monitors so we can see WHICH stage is slow
# during a lag spike instead of guessing: process(script) is main-thread GDScript, physics is the
# ragdoll/Jolt step, and low-both-but-low-FPS means the GPU (fill rate / resolution) is the wall.
# The verdict line names the bottleneck. Enable on the phone with ?debug=1 in the page URL.
func _draw_debug() -> void:
var font := ThemeDB.fallback_font
if font == null:
return
var fps: float = Performance.get_monitor(Performance.TIME_FPS)
var proc_ms: float = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
var phys_ms: float = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
var draws: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
var objs: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME))
var prims: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
var node_n: int = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
var bodies: int = int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
var vmem: float = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0
var now := Time.get_ticks_msec()
if proc_ms >= _peak_proc_ms or now - _peak_proc_t > _PEAK_HOLD_MS:
_peak_proc_ms = proc_ms
_peak_proc_t = now
if phys_ms >= _peak_phys_ms or now - _peak_phys_t > _PEAK_HOLD_MS:
_peak_phys_ms = phys_ms
_peak_phys_t = now
var mats := get_tree().get_nodes_in_group(&"matador")
var rt := get_viewport().get_texture()
var res: Vector2i = rt.get_size() if rt != null else Vector2i.ZERO
var lines := [
"FPS %d frame %.1fms >> %s" % [
int(round(fps)), 1000.0 / maxf(fps, 1.0), _perf_verdict(fps, proc_ms, phys_ms)],
"process(script) %5.1fms peak %.1f" % [proc_ms, _peak_proc_ms],
"physics(ragdoll) %5.1fms peak %.1f bodies %d" % [phys_ms, _peak_phys_ms, bodies],
"draws %d objs %d prims %s" % [draws, objs, _kfmt(prims)],
"nodes %d matadors %d vmem %.0fMB" % [node_n, mats.size(), vmem],
"render %dx%d threads(iso)=%s" % [res.x, res.y, _iso_str()],
"chip: %s fs: %s" % [_unmasked_gpu(), _fs_status_throttled()],
]
var fsz := 18
var line_h := 24.0
var w := 0.0
for line: String in lines:
w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz).x)
var top := clampf(get_viewport().get_visible_rect().size.y * 0.28, 96.0, 220.0)
_surface.draw_rect(
Rect2(10.0, top, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.72))
var y := top + 22.0
for line: String in lines:
_surface.draw_string(
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz, Color(1.0, 1.0, 0.35))
y += line_h
# Names the current bottleneck from the frame breakdown. Directly tests the "CPU bound to one
# thread" hypothesis: if process or physics ms is high, that's the single main thread saturated
# and threads can't help it; if both are low while FPS is low, the GPU (fill rate) is the wall.
func _perf_verdict(fps: float, proc_ms: float, phys_ms: float) -> String:
if fps >= 55.0:
return "smooth"
if phys_ms >= 6.0 and phys_ms >= proc_ms:
return "PHYSICS-bound (ragdolls, 1 thread)"
if proc_ms >= 6.0:
return "SCRIPT-bound (GDScript, 1 thread)"
if proc_ms + phys_ms < 5.0:
return "GPU/fill-bound (resolution/shaders)"
return "mixed CPU"
func _kfmt(n: int) -> String:
if n >= 1000000:
return "%.1fM" % (n / 1000000.0)
if n >= 1000:
return "%.0fk" % (n / 1000.0)
return str(n)
# crossOriginIsolated: whether SharedArrayBuffer/threads are even permitted (needs COOP+COEP). Even
# when true, Godot's per-frame game logic stays single-threaded — threads only aid render/audio/load.
func _iso_str() -> String:
if not OS.has_feature("web"):
return "native"
if _iso_cached == -1:
var r: Variant = JavaScriptBridge.eval("window.crossOriginIsolated?1:0", true)
_iso_cached = 1 if ((r is bool and r) or ((r is int or r is float) and int(r) != 0)) else 0
return str(_iso_cached)
# fullscreen_status() does a JavaScriptBridge.eval; throttle it so the overlay isn't paying that
# cost (and skewing the process-ms it reports) every single frame.
func _fs_status_throttled() -> String:
var now := Time.get_ticks_msec()
if now - _fs_cache_t > 500 or _fs_cache == "?":
_fs_cache = Controls.fullscreen_status()
_fs_cache_t = now
return _fs_cache
func _button_style(accent: Color) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = Color(0.10, 0.07, 0.03, 0.80)
s.set_corner_radius_all(18)
s.corner_detail = 6
s.set_border_width_all(3)
s.border_color = accent
return s
# ── Menu ──────────────────────────────────────────────────────────────────────
func _return_to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# Stay fullscreen across menu ↔ game so tapping Play never lands you windowed; the player
# leaves fullscreen only via the browser's own back / swipe / Esc, and the start gate then
# re-appears so the next tap goes back in.
get_tree().change_scene_to_file("res://MainMenu.tscn")
# ── Lifecycle ─────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
_hint_pulse = fmod(_hint_pulse + delta * 2.2, TAU)
var active := Controls.use_touch_ui() and not get_tree().paused
# The game-over / pause screen owns the display when paused — stand down and drop any
# held movement so the bull doesn't coast on a stuck joystick.
if not active:
if _surface.visible:
_surface.visible = false
_reset_touch_state()
return
_surface.visible = true
_prune_touch_state()
if _player == null:
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_player = players[0]
_surface.queue_redraw() # cooldown overlays animate every frame
# Fully drop every trace of in-flight touch input: release held movement, clear ownership,
# end the stick, cancel any pinch. Used on stand-down, on relayout, and on focus/pause loss.
func _reset_touch_state() -> void:
_end_stick()
_release_move()
_touches.clear()
_touch_owner.clear()
for i: int in _slot_pressed.size():
_slot_pressed[i] = false
_pinching = false
_pinch_dist = 0.0
# Per-frame self-heal: drop ownership entries whose finger is no longer down, and stand the
# stick down if the finger driving it has lifted. Catches a release that slipped through
# without a matching event so state can't quietly rot into a stuck stick.
func _prune_touch_state() -> void:
if not _touch_owner.is_empty():
var orphans: Array = []
for idx: int in _touch_owner:
if not _touches.has(idx):
orphans.append(idx)
for idx: int in orphans:
var owner: Variant = _touch_owner[idx]
if owner is int:
_slot_pressed[owner] = false
if _stick_active and idx == _stick_index:
_end_stick()
_touch_owner.erase(idx)
if _stick_active and not _touches.has(_stick_index):
_end_stick()
func _notification(what: int) -> void:
if (
what == NOTIFICATION_APPLICATION_FOCUS_OUT
or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT
or what == NOTIFICATION_APPLICATION_PAUSED
):
_reset_touch_state()