extends Node2D # ─── Android Touch Controls ─────────────────────────────────────────────────── # Node2D child of the HUD CanvasLayer → renders above the game in screen-space. # Two operating modes: # GAME – virtual joystick (left half) + THRUST / FIRE / WIPE / PAUSE buttons # MENU – swipe gestures + tap forwarded as ui_* actions for all menu screens # # Gameplay uses Input.action_press/release (persistent held state that # game_world._handle_input() reads via Input.is_action_pressed()). # Menu navigation uses Input.parse_input_event(InputEventAction) — a one-shot # event that triggers _unhandled_input handlers in main_menu / pause_menu etc. # # Always process even while paused (PROCESS_MODE_ALWAYS) so the Pause button # and resume-swipe work when get_tree().paused == true. enum Mode { HIDDEN, MENU, GAME } var _mode: Mode = Mode.HIDDEN # Set from main.gd; needed to check BigWipe activity for the SURVIVE button. var game_world: Node = null # Set from main.gd to the shop_ui node so MENU mode skips the relay and lets # shop_ui handle raw InputEventScreenTouch directly (relay via parse_input_event # is unreliable in exported builds). var direct_touch_ui: Node = null # ─── Buttons (finger IDs) ───────────────────────────────────────────────────── var _left_id : int = -1 var _right_id : int = -1 var _thrust_id : int = -1 var _shoot_id : int = -1 var _wipe_id : int = -1 var _pause_id : int = -1 # ─── Menu swipe ────────────────────────────────────────────────────────────── const SWIPE_MIN := 26.0 # px min for swipe classification var _menu_id : int = -1 var _menu_origin: Vector2 = Vector2.ZERO var _menu_pos : Vector2 = Vector2.ZERO # ─── Palette (same green as the rest of the UI) ─────────────────────────────── const C_RIM := Color(0.00, 1.00, 0.533, 0.38) const C_ACTIVE := Color(0.00, 1.00, 0.533, 0.72) const C_FILL := Color(0.00, 0.06, 0.03, 0.30) const C_FILL_A := Color(0.00, 1.00, 0.533, 0.18) const C_WIPE := Color(1.00, 0.67, 0.00, 0.65) const C_HINT := Color(0.00, 1.00, 0.533, 0.16) const C_MENU := Color(0.00, 1.00, 0.533, 0.20) # ─── Lifecycle ─────────────────────────────────────────────────────────────── func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS func _process(_dt: float) -> void: if visible: queue_redraw() func set_mode(m: Mode) -> void: _mode = m visible = (m != Mode.HIDDEN) and _touch_wanted() if not visible: _release_all() queue_redraw() # AUTO = show only on actual touchscreen hardware (Android/iOS/web) # ON = always show (useful for testing on PC or touchscreen laptops) # OFF = always hidden (pure keyboard/controller players) func _touch_wanted() -> bool: match Settings.touch_mode: 1: return true 2: return false # 0 = auto: show on mobile OS or if a touchscreen is present return OS.has_feature("android") or OS.has_feature("ios") or \ OS.has_feature("web") or DisplayServer.is_touchscreen_available() func _release_all() -> void: for a: String in ["p1_thrust", "p1_left", "p1_right", "p1_shoot", "p1_wipe"]: Input.action_release(a) _left_id = -1; _right_id = -1 _thrust_id = -1; _shoot_id = -1 _wipe_id = -1; _pause_id = -1; _menu_id = -1 # ─── Input ─────────────────────────────────────────────────────────────────── func _input(event: InputEvent) -> void: if not visible: return if _mode == Mode.GAME: _game_input(event) elif _mode == Mode.MENU: _menu_input(event) # ── Gameplay touch ──────────────────────────────────────────────────────────── func _game_input(event: InputEvent) -> void: if event is InputEventScreenTouch: Settings.last_input_device = "touch" if event.pressed: _game_down(event.index, event.position) else: _game_up(event.index) get_viewport().set_input_as_handled() func _game_down(id: int, p: Vector2) -> void: var vs := get_viewport_rect().size if _pause_id == -1 and _hit(_pause_btn(vs), p): _pause_id = id _fire_ui("ui_cancel") return if _left_id == -1 and _hit(_left_btn(vs), p): _left_id = id Input.action_press("p1_left") elif _right_id == -1 and _hit(_right_btn(vs), p): _right_id = id Input.action_press("p1_right") elif _thrust_id == -1 and _hit(_thrust_btn(vs), p): _thrust_id = id Input.action_press("p1_thrust") elif _shoot_id == -1 and _hit(_shoot_btn(vs), p): _shoot_id = id Input.action_press("p1_shoot") elif _wipe_id == -1 and _is_wipe_active() and _hit(_wipe_btn(vs), p): _wipe_id = id Input.action_press("p1_wipe") func _game_up(id: int) -> void: if id == _left_id: _left_id = -1 Input.action_release("p1_left") elif id == _right_id: _right_id = -1 Input.action_release("p1_right") elif id == _thrust_id: _thrust_id = -1 Input.action_release("p1_thrust") elif id == _shoot_id: _shoot_id = -1 Input.action_release("p1_shoot") elif id == _wipe_id: _wipe_id = -1 Input.action_release("p1_wipe") elif id == _pause_id: _pause_id = -1 # ── Menu touch ──────────────────────────────────────────────────────────────── func _menu_input(event: InputEvent) -> void: if event is InputEventScreenTouch: Settings.last_input_device = "touch" var has_direct: bool = is_instance_valid(direct_touch_ui) and direct_touch_ui.visible if event.pressed: _menu_id = event.index _menu_origin = event.position _menu_pos = event.position elif event.index == _menu_id: _menu_id = -1 if not has_direct: # Relay-based navigation for screens without direct touch handling var d := _menu_pos - _menu_origin if d.length() < 22.0 and _hit(_back_btn(get_viewport_rect().size), event.position): _fire_ui("ui_cancel") elif d.length() < 22.0: _fire_ui("ui_accept") elif abs(d.y) >= abs(d.x): if d.y < -SWIPE_MIN: _fire_ui("ui_up") elif d.y > SWIPE_MIN: _fire_ui("ui_down") else: if d.x < -SWIPE_MIN: _fire_ui("ui_left") elif d.x > SWIPE_MIN: _fire_ui("ui_right") # Only consume the event when the direct-touch UI is NOT active — otherwise # let it propagate so shop_ui._input can handle it directly. if not has_direct: get_viewport().set_input_as_handled() elif event is InputEventScreenDrag: if event.index == _menu_id: _menu_pos = event.position var has_direct: bool = is_instance_valid(direct_touch_ui) and direct_touch_ui.visible if not has_direct: get_viewport().set_input_as_handled() # Fire a one-shot UI action so _unhandled_input handlers see it. func _fire_ui(action: String) -> void: var ev := InputEventAction.new() ev.action = action ev.pressed = true ev.strength = 1.0 Input.parse_input_event(ev) # Immediate release so the next frame sees it as "just pressed, not held". ev = InputEventAction.new() ev.action = action ev.pressed = false ev.strength = 0.0 Input.parse_input_event(ev) # ─── Button geometry (viewport-relative, returns Vector3 cx/cy/r) ───────────── func _left_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.080, vs.y * 0.820, vs.y * 0.082) func _right_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.230, vs.y * 0.820, vs.y * 0.082) func _back_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.055, vs.y * 0.930, vs.y * 0.055) func _thrust_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.854, vs.y * 0.855, vs.y * 0.092) func _shoot_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.958, vs.y * 0.640, vs.y * 0.076) func _wipe_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.500, vs.y * 0.945, vs.y * 0.048) func _pause_btn(vs: Vector2) -> Vector3: return Vector3(vs.x * 0.977, vs.y * 0.047, vs.y * 0.042) func _hit(btn: Vector3, p: Vector2) -> bool: return p.distance_to(Vector2(btn.x, btn.y)) <= btn.z func _is_wipe_active() -> bool: if not is_instance_valid(game_world): return false var bw = game_world.get("big_wipe") return bw != null and bw.is_active() # ─── Drawing ────────────────────────────────────────────────────────────────── func _draw() -> void: if not visible: return var vs := get_viewport_rect().size match _mode: Mode.GAME: _draw_game(vs) Mode.MENU: _draw_menu_hint(vs) func _draw_game(vs: Vector2) -> void: # ── LEFT button ─────────────────────────────────────────────────────────── var lb := _left_btn(vs) _btn(Vector2(lb.x, lb.y), lb.z, "◄\nLEFT", _left_id >= 0) # ── RIGHT button ────────────────────────────────────────────────────────── var rb := _right_btn(vs) _btn(Vector2(rb.x, rb.y), rb.z, "►\nRIGHT", _right_id >= 0) # ── THRUST button ───────────────────────────────────────────────────────── var t := _thrust_btn(vs) _btn(Vector2(t.x, t.y), t.z, "▲\nGAS", _thrust_id >= 0) # ── FIRE button ─────────────────────────────────────────────────────────── var s := _shoot_btn(vs) _btn(Vector2(s.x, s.y), s.z, "●\nFIRE", _shoot_id >= 0) # ── WIPE / SURVIVE button (only during BigWipe) ─────────────────────────── if _is_wipe_active(): var w := _wipe_btn(vs) var wc := C_WIPE if _wipe_id >= 0 else Color(C_WIPE.r, C_WIPE.g, C_WIPE.b, 0.42) draw_circle(Vector2(w.x, w.y), w.z, Color(wc.r, wc.g, wc.b, 0.15 if _wipe_id >= 0 else 0.07)) _ring(Vector2(w.x, w.y), w.z, wc, 1.5) _label(Vector2(w.x, w.y), "SURVIVE", 7, wc) # ── PAUSE button (top-right corner) ────────────────────────────────────── var pp := _pause_btn(vs) _btn(Vector2(pp.x, pp.y), pp.z, "II", _pause_id >= 0, true) func _draw_menu_hint(vs: Vector2) -> void: # ◄ BACK button — bottom-left corner var bb := _back_btn(vs) var bc := Vector2(bb.x, bb.y) draw_circle(bc, bb.z, C_FILL) _ring(bc, bb.z, C_RIM, 1.5) _label(bc, "◄", 9, C_RIM) # Subtle swipe hint at bottom center var font := ThemeDB.fallback_font var hint := "↑↓ wischen ● tippen" if Settings and Settings.language == "en": hint = "↑↓ swipe ● tap" var sz := 7 var tw := font.get_string_size(hint, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x draw_string(font, Vector2((vs.x - tw) * 0.5, vs.y - 5.0), hint, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, C_MENU) # ─── Draw helpers ───────────────────────────────────────────────────────────── func _btn(c: Vector2, r: float, label: String, active: bool, small: bool = false) -> void: draw_circle(c, r, C_FILL_A if active else C_FILL) _ring(c, r, C_ACTIVE if active else C_RIM, 1.5) _label(c, label, 7 if small else 8, C_ACTIVE if active else C_RIM) func _ring(c: Vector2, r: float, col: Color, w: float = 1.5) -> void: var segs := 24 for i: int in segs: var a0 := i * TAU / segs var a1 := (i + 1) * TAU / segs draw_line(c + Vector2(cos(a0), sin(a0)) * r, c + Vector2(cos(a1), sin(a1)) * r, col, w) func _label(c: Vector2, text: String, sz: int, col: Color) -> void: var font := ThemeDB.fallback_font # Handle multi-line (e.g. "▲\nGAS") var lines := text.split("\n") var line_h := float(sz) * 1.3 var total_h := line_h * lines.size() for i: int in lines.size(): var line: String = lines[i] var tw := font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x var y := c.y - total_h * 0.5 + i * line_h + sz draw_string(font, Vector2(c.x - tw * 0.5, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)