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:
2026-04-21 14:38:09 +02:00
commit edc40f9008
108 changed files with 10068 additions and 0 deletions
+309
View File
@@ -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)