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. var _stick_zone: Rect2 = Rect2() var _ability_rects: Array[Rect2] = [] 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. var _stick_active: bool = false 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 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: 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.16, 72.0, 120.0) # ability button edge var gap := bs * 0.18 var margin := bs * 0.28 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.09, 48.0, 72.0) _menu_rect = Rect2(vp.x - ms - 16.0, 16.0, 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].has_point(pos): _touch_owner[index] = slot if _player != null: _player.call(&"try_activate_ability", slot) return if _stick_zone.has_point(pos) and not _stick_active: _touch_owner[index] = "stick" _begin_stick(pos) return _touch_owner[index] = "camera" func _on_drag(index: int, pos: Vector2) -> void: match _touch_owner.get(index, null): "stick": _update_stick(pos) "camera": if _pinching: _apply_pinch() func _on_release(index: int, pos: Vector2) -> void: match _touch_owner.get(index, null): "stick": _end_stick() "menu": if _menu_rect.has_point(pos): _touch_owner.erase(index) _return_to_menu() return _touch_owner.erase(index) # ── Joystick ──────────────────────────────────────────────────────────────── func _begin_stick(pos: Vector2) -> void: _stick_active = true _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 _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) var inner := r.grow(-r.size.x * 0.14) 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) func _draw_debug() -> void: var font := ThemeDB.fallback_font if font == null: return var players := get_tree().get_nodes_in_group(&"player") var mats := get_tree().get_nodes_in_group(&"matador") var lines := [ "GPU: %s" % RenderingServer.get_video_adapter_name(), "API: %s" % RenderingServer.get_video_adapter_api_version(), "method=%s web=%s touch_ui=%s" % [ ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"), OS.has_feature("web"), Controls.use_touch_ui()], "bull: %s" % _node_report(players), "matador(%d): %s" % [mats.size(), _node_report(mats)], "events=%d last=%s touches=%d stick=%s" % [ _dbg_events, str(_dbg_last_pos.round()), _touches.size(), _stick_active], ] var fs := 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, fs).x) _surface.draw_rect( Rect2(10.0, 34.0, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.62)) var y := 34.0 + 22.0 for line: String in lines: _surface.draw_string( font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color(1.0, 1.0, 0.3)) y += line_h # One-line "does this character exist / is it drawable / where is it" report for the debug # overlay, so an invisible bull/matador on-device can be pinned to spawn vs. position vs. # GPU-draw failure. func _node_report(nodes: Array) -> String: if nodes.is_empty(): return "NONE" var n := nodes[0] as Node3D if n == null: return "not Node3D" return "vis=%s pos=%s" % [n.is_visible_in_tree(), str(n.global_position.round())] 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 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 if _stick_active: _end_stick() _touches.clear() _touch_owner.clear() _pinching = false return _surface.visible = true 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