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>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user