Files
spacel/scripts/touch_controls.gd
alpacaman edc40f9008 Initial commit — Godot space roguelite source
- Touch controls: direct InputEventScreenTouch in shop_ui (bypass relay)
- ItemDB: static preload list instead of DirAccess scan (export fix)
- All 18 items with EN localisation (name_en, desc_en, category_en)
- Ship playstyles: NOVA-1 shield, INFERNO ram, AURORA agile/tank
- Quasar: SMBH visual, jet boost, merge, push, BH-eating
- Atlas & UI text updated EN+DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:38:09 +02:00

310 lines
13 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)