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
+673
View File
@@ -0,0 +1,673 @@
extends Node2D
# ─── Atlas UI ────────────────────────────────────────────────────────────────
# Beschreibt alle kosmischen Objekte, Schiffe und Events mit Live-Preview.
# Previews verwenden die echten Spielobjekte (draw-Methoden identisch zum Spiel).
# Sichtbar über das Hauptmenü (ATLAS). Sendet `closed` wenn beendet.
signal closed
const CosmicObjects = preload("res://scripts/cosmic_objects.gd")
const BlackHoleClass = preload("res://scripts/black_hole.gd")
const PlanetClass = preload("res://scripts/planet.gd")
# NOVA-1 Palette — exakt aus main.gd SHIPS[0]
const PAL_NOVA1 := {
"nose": Color("#ffffff"), "bright": Color("#dddddd"), "mid": Color("#cccccc"),
"dim": Color("#aaaaaa"), "accent": Color("#88aaff"), "edge": Color("#888888"),
"shadow": Color("#666688"), "trail": Color(0.533, 0.667, 1.0, 0.251),
"thrustHot": Color("#ffcc44"), "thrustCool": Color(1.0, 0.533, 0.267, 0.533)
}
const COL_BG := Color(0.0, 0.0, 0.04, 0.90)
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 1.0)
const COL_ACCENT := Color(0.27, 1.0, 0.8, 1.0)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.55)
const COL_WHITE := Color(1.0, 1.0, 1.0, 0.90)
const COL_WARN := Color(1.0, 0.67, 0.0, 0.90)
var W: float = 960.0
var H: float = 600.0
var _blink: float = 0.0
var _cursor: int = 0
var _scroll: int = 0
var _time: float = 0.0
# Preview box center (fixed for 960×600 viewport)
# right_x=300, pbox_x=318, pbox_y=116, pbox_sz=180
const PREV_CX := 408.0
const PREV_CY := 206.0
# Preview objects — real game instances
var _preview_obj = null # single object (most kinds)
var _preview_list: Array = [] # multiple objects (antimatter)
var _preview_kind: String = ""
# Entry schema:
# kind: id used by preview dispatcher
# cat: category key (atlas_cat_*)
# name_key: translation key for name
# desc_key: translation key for description
# props: Array of [label_key, value_string] (value is NOT translated)
const ENTRIES: Array = [
{"kind": "star", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_star",
"desc_key": "atlas_d_star", "props": [
["atlas_prop_size", "17 px"],
["atlas_prop_speed", "0.050.3 px/f"],
["atlas_prop_spawn", "~150300"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "planet_terr", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_planet_terr",
"desc_key": "atlas_d_planet_terr", "props": [
["atlas_prop_size", "510 px"],
["atlas_prop_orbit", "1972 px"],
["atlas_prop_effect", "Wolken/Polkappen"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "planet_desert", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_planet_desert",
"desc_key": "atlas_d_planet_desert", "props": [
["atlas_prop_size", "510 px"],
["atlas_prop_orbit", "1972 px"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "planet_gas", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_planet_gas",
"desc_key": "atlas_d_planet_gas", "props": [
["atlas_prop_size", "1219 px"],
["atlas_prop_orbit", "1972 px"],
["atlas_prop_effect", "Ringe, Monde, Sturm"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "planet_ice", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_planet_ice",
"desc_key": "atlas_d_planet_ice", "props": [
["atlas_prop_size", "510 px"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "planet_lava", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_planet_lava",
"desc_key": "atlas_d_planet_lava", "props": [
["atlas_prop_size", "510 px"],
["atlas_prop_effect", "Halo + Glut"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "planet_toxic", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_planet_toxic",
"desc_key": "atlas_d_planet_toxic", "props": [
["atlas_prop_size", "510 px"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "nebula", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_nebula",
"desc_key": "atlas_d_nebula", "props": [
["atlas_prop_size", "120220 px"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "comet", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_comet",
"desc_key": "atlas_d_comet", "props": [
["atlas_prop_speed", "14 px/f"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "galaxy", "cat": "atlas_cat_cosmic", "name_key": "atlas_n_galaxy",
"desc_key": "atlas_d_galaxy", "props": [
["atlas_prop_size", "~80 px"],
["atlas_prop_effect", "SMBH-Trigger"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "blackhole", "cat": "atlas_cat_exotic", "name_key": "atlas_n_blackhole",
"desc_key": "atlas_d_blackhole", "props": [
["atlas_prop_size", "1440 px"],
["atlas_prop_effect", "Pull 160 px"],
["atlas_prop_hazard", "atlas_hazard_deadly"],
]},
{"kind": "whitehole", "cat": "atlas_cat_exotic", "name_key": "atlas_n_whitehole",
"desc_key": "atlas_d_whitehole", "props": [
["atlas_prop_size", "~16 px"],
["atlas_prop_effect", "Push 340 px"],
["atlas_prop_life", "~60 s"],
["atlas_prop_hazard", "atlas_hazard_low"],
]},
{"kind": "neutron", "cat": "atlas_cat_exotic", "name_key": "atlas_n_neutron",
"desc_key": "atlas_d_neutron", "props": [
["atlas_prop_size", "~6 px"],
["atlas_prop_effect", "Pulsar-Beam"],
["atlas_prop_hazard", "atlas_hazard_low"],
]},
{"kind": "quasar", "cat": "atlas_cat_exotic", "name_key": "atlas_n_quasar",
"desc_key": "atlas_d_quasar", "props": [
["atlas_prop_life", "~30 s"],
["atlas_prop_hazard", "atlas_hazard_none"],
]},
{"kind": "antimatter", "cat": "atlas_cat_anti", "name_key": "atlas_n_antimatter",
"desc_key": "atlas_d_antimatter", "props": [
["atlas_prop_size", "2 px"],
["atlas_prop_hazard", "atlas_hazard_deadly"],
]},
{"kind": "antistar", "cat": "atlas_cat_anti", "name_key": "atlas_n_antistar",
"desc_key": "atlas_d_antistar", "props": [
["atlas_prop_life", "~50 s"],
["atlas_prop_effect", "Push"],
["atlas_prop_hazard", "atlas_hazard_deadly"],
]},
{"kind": "player", "cat": "atlas_cat_ships", "name_key": "atlas_n_player",
"desc_key": "atlas_d_player", "props": [
["atlas_prop_hp", "1 (+Schilde)"],
["atlas_prop_speed", "7.5 Max"],
["atlas_prop_damage", "1.0 (Pierce ≥2)"],
]},
{"kind": "enemy", "cat": "atlas_cat_ships", "name_key": "atlas_n_enemy",
"desc_key": "atlas_d_enemy", "props": [
["atlas_prop_hp", "1"],
["atlas_prop_reward", "15 Credits"],
["atlas_prop_hazard", "atlas_hazard_mid"],
]},
{"kind": "wraith", "cat": "atlas_cat_ships", "name_key": "atlas_n_wraith",
"desc_key": "atlas_d_wraith", "props": [
["atlas_prop_hp", "20"],
["atlas_prop_reward", "150 Credits"],
["atlas_prop_hazard", "atlas_hazard_high"],
]},
{"kind": "leviathan", "cat": "atlas_cat_ships", "name_key": "atlas_n_leviathan",
"desc_key": "atlas_d_leviathan", "props": [
["atlas_prop_hp", "50"],
["atlas_prop_reward", "300 Credits"],
["atlas_prop_hazard", "atlas_hazard_deadly"],
]},
{"kind": "bullet", "cat": "atlas_cat_events", "name_key": "atlas_n_bullet",
"desc_key": "atlas_d_bullet", "props": [
["atlas_prop_speed", "9.6 px/f"],
["atlas_prop_life", "240 f"],
]},
{"kind": "bigwipe", "cat": "atlas_cat_events", "name_key": "atlas_n_bigwipe",
"desc_key": "atlas_d_bigwipe", "props": [
["atlas_prop_effect", "> 500 Objekte"],
["atlas_prop_reward", "25 Credits"],
]},
]
func open() -> void:
visible = true
_cursor = 0
_scroll = 0
_time = 0.0
_rebuild_preview()
queue_redraw()
func close() -> void:
visible = false
_preview_obj = null
_preview_list = []
_preview_kind = ""
closed.emit()
# ─── Loop ────────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
if not visible: return
_blink += delta
_time += delta
_update_preview(delta)
queue_redraw()
func _update_preview(delta: float) -> void:
match _preview_kind:
"star":
if _preview_obj:
_preview_obj.update(delta, 99999.0, 99999.0)
_preview_obj.x = PREV_CX
_preview_obj.y = PREV_CY
"galaxy":
if _preview_obj:
_preview_obj.update(delta, 99999.0, 99999.0)
_preview_obj.x = PREV_CX
_preview_obj.y = PREV_CY
"blackhole":
if _preview_obj:
_preview_obj.update(delta, [], 99999.0, 99999.0)
_preview_obj.x = PREV_CX
_preview_obj.y = PREV_CY
"whitehole":
if _preview_obj:
_preview_obj.update(delta)
_preview_obj.x = PREV_CX
_preview_obj.y = PREV_CY
"neutron", "quasar":
if _preview_obj:
_preview_obj.update(delta)
"antistar":
if _preview_obj:
_preview_obj.update(delta)
_preview_obj.x = PREV_CX
_preview_obj.y = PREV_CY
"antimatter":
for k: int in _preview_list.size():
var am = _preview_list[k]
var ang := float(k) * TAU / float(_preview_list.size()) + _time * 0.6
var r := 20.0 + sin(_time + float(k)) * 12.0
am.x = PREV_CX + cos(ang) * r
am.y = PREV_CY + sin(ang) * r
am.update(99999.0, 99999.0, delta, [])
# reset position after update (velocity might shift it)
am.x = PREV_CX + cos(ang) * r
am.y = PREV_CY + sin(ang) * r
"wraith", "leviathan":
if _preview_obj:
_preview_obj.update(delta, PREV_CX, PREV_CY)
"planet_terr", "planet_desert", "planet_gas", "planet_ice", "planet_lava", "planet_toxic":
if _preview_obj and _preview_obj.has_method("update"):
_preview_obj.update(delta)
func _unhandled_input(event: InputEvent) -> void:
if not visible: return
if event.is_action_pressed("ui_up"):
_cursor = (_cursor - 1 + ENTRIES.size()) % ENTRIES.size()
_ensure_cursor_visible()
_rebuild_preview()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_down"):
_cursor = (_cursor + 1) % ENTRIES.size()
_ensure_cursor_visible()
_rebuild_preview()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_cancel") or event.is_action_pressed("ui_accept"):
close()
get_viewport().set_input_as_handled()
func _ensure_cursor_visible() -> void:
var visible_rows := 14
if _cursor < _scroll:
_scroll = _cursor
elif _cursor >= _scroll + visible_rows:
_scroll = _cursor - visible_rows + 1
func _rebuild_preview() -> void:
var kind: String = ENTRIES[_cursor]["kind"]
_preview_kind = kind
_preview_obj = null
_preview_list = []
_time = 0.0
match kind:
"star":
var s := CosmicObjects.Star.new()
s.init(PREV_CX, PREV_CY, 99999.0, 99999.0)
_preview_obj = s
"nebula":
var n := CosmicObjects.Nebula.new()
n.init(99999.0, 99999.0)
n.x = PREV_CX
n.y = PREV_CY
_preview_obj = n
"comet":
var c := CosmicObjects.Comet.new()
c.init(99999.0, 99999.0)
# Position it so it travels across the preview box area
c.x = PREV_CX - 70.0
c.y = PREV_CY - 40.0
_preview_obj = c
"galaxy":
var g := CosmicObjects.Galaxy.new()
g.init(PREV_CX, PREV_CY)
_preview_obj = g
"blackhole":
var bh := BlackHoleClass.new()
bh.init(PREV_CX, PREV_CY, false)
_preview_obj = bh
"whitehole":
var wh := CosmicObjects.WhiteHole.new()
wh.init(PREV_CX, PREV_CY)
_preview_obj = wh
"neutron":
var ns := CosmicObjects.NeutronStar.new()
ns.init(PREV_CX, PREV_CY)
_preview_obj = ns
"quasar":
var q := CosmicObjects.Quasar.new()
q.init(PREV_CX, PREV_CY)
_preview_obj = q
"antimatter":
for k: int in 10:
var am := CosmicObjects.Antimatter.new()
var ang := float(k) * TAU / 10.0
var r := 22.0
am.init(PREV_CX + cos(ang) * r, PREV_CY + sin(ang) * r, ang, 0.0)
_preview_list.append(am)
"antistar":
var a := CosmicObjects.AntimatterStar.new()
a.init(PREV_CX, PREV_CY)
_preview_obj = a
"player":
var sp := Spaceship.new()
sp.init(PREV_CX, PREV_CY, PAL_NOVA1, 0)
sp.heading = -PI * 0.5 # zeigt nach oben
_preview_obj = sp
"enemy":
var en := EnemyShip.new()
en.init(PREV_CX, PREV_CY, 0) # idx=0 → roter Gegner
en.heading = PI * 0.5
_preview_obj = en
"wraith":
var boss := BossShip.new()
boss.init_miniboss(PREV_CX, PREV_CY)
_preview_obj = boss
"leviathan":
var boss := BossShip.new()
boss.init_boss(PREV_CX, PREV_CY)
_preview_obj = boss
"planet_terr", "planet_desert", "planet_gas", "planet_ice", "planet_lava", "planet_toxic":
var p := PlanetClass.new()
p.init(0.0, 0.0, 960.0, 600.0)
p.ptype = _ptype_for(kind)
p._setup_palette()
p._setup_noise()
p._setup_animation()
if kind == "planet_gas":
p.radius = 22.0
else:
p.radius = 18.0
p.initial_radius = p.radius
p.color = p.palette[0]
if kind == "planet_gas":
p.ring = true
p.ring_count = 2
p.ring_inner = p.radius + 6.0
p.ring_outer = p.ring_inner + 8.0
p.ring_tilt = 0.28
p.ring_colors = [p.palette[0], p.palette[2]]
p.ring_gaps = []
else:
p.ring = false
p.moons.clear()
_preview_obj = p
# bullet / bigwipe: no live object, drawn procedurally below
func _ptype_for(kind: String) -> int:
match kind:
"planet_terr": return PlanetClass.PType.TERRESTRIAL
"planet_desert": return PlanetClass.PType.DESERT
"planet_gas": return PlanetClass.PType.GAS_GIANT
"planet_ice": return PlanetClass.PType.ICE
"planet_lava": return PlanetClass.PType.LAVA
"planet_toxic": return PlanetClass.PType.TOXIC
return 0
# ─── Drawing ─────────────────────────────────────────────────────────────────
func _draw() -> void:
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
# Full-screen darkening
draw_rect(Rect2(0, 0, W, H), COL_BG)
# Outer brackets
_draw_brackets(12.0, 12.0, W - 12.0, H - 12.0, COL_DIM, 18.0)
# Title header
_draw_text_c(Tr.t("atlas_title"), W * 0.5, 26.0, 14, COL_PRIMARY)
var line_y := 54.0
draw_line(Vector2(W * 0.25, line_y), Vector2(W * 0.75, line_y),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.35), 1.0)
# Layout
var top := 70.0
var bottom := H - 40.0
var list_x := 24.0
var list_w := 260.0
var right_x := list_x + list_w + 16.0
var right_w := W - right_x - 24.0
_draw_list(list_x, top, list_w, bottom - top)
_draw_details(right_x, top, right_w, bottom - top)
# Footer
_draw_text_c(Tr.t("atlas_footer"), W * 0.5, H - 22.0, 8, COL_DIM)
func _draw_list(x: float, y: float, w: float, h: float) -> void:
_draw_terminal_box(x, y, w, h)
var visible_rows := 14
var row_h := 22.0
var last_cat := ""
var draw_y := y + 14.0
var i := _scroll
var rows_left := visible_rows
while i < ENTRIES.size() and rows_left > 0:
var entry: Dictionary = ENTRIES[i]
var cat: String = entry["cat"]
if cat != last_cat:
var ch := Tr.t(cat)
_draw_text(ch, x + 14.0, draw_y, 9,
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.55))
draw_y += 14.0
last_cat = cat
var is_sel := (i == _cursor)
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var col: Color
if is_sel:
col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse)
else:
col = Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.60)
var prefix := "" if is_sel else " "
_draw_text(prefix + Tr.t(entry["name_key"]), x + 20.0, draw_y, 10, col)
draw_y += row_h - 2.0
i += 1
rows_left -= 1
# Scroll indicators
if _scroll > 0:
_draw_text("", x + w - 24.0, y + 6.0, 10, COL_DIM)
if _scroll + visible_rows < ENTRIES.size():
_draw_text("", x + w - 24.0, y + h - 22.0, 10, COL_DIM)
func _draw_details(x: float, y: float, w: float, h: float) -> void:
_draw_terminal_box(x, y, w, h)
var entry: Dictionary = ENTRIES[_cursor]
# Name header
var name_y := y + 18.0
_draw_text(Tr.t(entry["name_key"]), x + 18.0, name_y, 14, COL_ACCENT)
# Preview box (left sub-area)
var pbox_sz := 180.0
var pbox_x := x + 18.0
var pbox_y := name_y + 28.0
_draw_preview_box(pbox_x, pbox_y, pbox_sz, pbox_sz, entry["kind"])
# Props (right of preview)
var prop_x := pbox_x + pbox_sz + 24.0
var prop_y := pbox_y
_draw_text(Tr.t("atlas_props"), prop_x, prop_y, 9,
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.65))
prop_y += 16.0
draw_line(Vector2(prop_x, prop_y), Vector2(x + w - 24.0, prop_y),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.25), 1.0)
prop_y += 8.0
var props: Array = entry["props"]
for pair in props:
var lbl: String = Tr.t(pair[0])
var val_raw: String = pair[1]
var val := val_raw
if val_raw.begins_with("atlas_hazard_"):
val = Tr.t(val_raw)
_draw_text(lbl, prop_x, prop_y, 10, Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b, 0.55))
_draw_text(val, prop_x + 100.0, prop_y, 10, COL_WHITE)
prop_y += 20.0
# Description (full width below)
var desc_y := pbox_y + pbox_sz + 22.0
_draw_text(Tr.t("atlas_desc"), x + 18.0, desc_y, 9,
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.65))
desc_y += 16.0
draw_line(Vector2(x + 18.0, desc_y), Vector2(x + w - 18.0, desc_y),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.25), 1.0)
desc_y += 8.0
var text: String = Tr.t(entry["desc_key"])
_draw_wrapped(text, x + 18.0, desc_y, w - 36.0, 11,
Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b, 0.85))
# ─── Preview dispatcher ───────────────────────────────────────────────────────
func _draw_preview_box(px: float, py: float, pw: float, ph: float, kind: String) -> void:
# Dark frame
draw_rect(Rect2(px, py, pw, ph), Color(0.02, 0.02, 0.06, 0.85))
draw_rect(Rect2(px, py, pw, ph), Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.25), false, 1.0)
var cx := px + pw * 0.5
var cy := py + ph * 0.5
match kind:
"antimatter":
# Multiple real Antimatter objects
for am in _preview_list:
am.draw(self)
"player":
if _preview_obj:
_preview_obj.x = cx
_preview_obj.y = cy
_preview_obj.draw(self, 0) # frame=0 → nie blinken
"comet":
# Comet trail needs manual positioning within the box
_prev_comet(px, py, pw, ph)
"bullet":
_prev_bullet(cx, cy)
"bigwipe":
_prev_bigwipe(px, py, pw, ph)
_:
# Alle anderen: echtes Spielobjekt an Preview-Mitte positionieren und zeichnen
if _preview_obj:
_preview_obj.x = cx
_preview_obj.y = cy
_preview_obj.draw(self)
# ─── Custom previews (keine Entsprechung als einzelnes Spielobjekt) ───────────
func _prev_comet(px: float, py: float, pw: float, ph: float) -> void:
var tip_x := px + 30.0 + fmod(_time * 80.0, pw - 60.0)
var tip_y := py + 30.0 + fmod(_time * 40.0, ph - 60.0)
var tail := 26
for i: int in tail:
var t := float(i) / float(tail)
var tx := tip_x - cos(0.5) * float(i) * 3.0
var ty := tip_y - sin(0.5) * float(i) * 3.0
var a := (1.0 - t)
draw_rect(Rect2(tx, ty, 2, 2), Color(0.9, 0.95, 1.0, a * 0.85))
draw_rect(Rect2(tip_x - 2, tip_y - 2, 4, 4), Color(1, 1, 1, 1.0))
func _prev_bullet(cx: float, cy: float) -> void:
# Player bullet
draw_rect(Rect2(cx - 24, cy - 12, 4, 4), Color(0.7, 0.9, 1.0, 1.0))
draw_rect(Rect2(cx - 26, cy - 13, 6, 2), Color(0.7, 0.9, 1.0, 0.4))
# Pierce (white)
draw_rect(Rect2(cx + 2, cy - 12, 4, 4), Color(1, 1, 1, 1))
draw_rect(Rect2(cx - 8, cy - 8, 6, 2), Color(1, 1, 1, 0.3))
# Enemy bullet
draw_rect(Rect2(cx - 24, cy + 10, 4, 4), Color(1.0, 0.4, 0.4, 1.0))
# Boss bullet
draw_rect(Rect2(cx + 2, cy + 10, 4, 4), Color(1.0, 0.6, 0.2, 1.0))
_draw_text("player", cx - 52, cy - 18, 8, Color(0.7, 0.9, 1.0, 0.7))
_draw_text("pierce", cx + 12, cy - 18, 8, Color(1, 1, 1, 0.7))
_draw_text("enemy", cx - 52, cy + 4, 8, Color(1, 0.4, 0.4, 0.7))
_draw_text("boss", cx + 12, cy + 4, 8, Color(1, 0.6, 0.2, 0.7))
func _prev_bigwipe(px: float, py: float, pw: float, ph: float) -> void:
var flash := 0.5 + 0.5 * sin(_time * 6.0)
draw_rect(Rect2(px + 4, py + 4, pw - 8, ph - 8), Color(0.0, 0.0, 0.05, 0.75))
for i: int in 18:
var ly := py + 6.0 + float(i) * 10.0
if ly > py + ph - 6: continue
draw_line(Vector2(px + 4, ly), Vector2(px + pw - 4, ly),
Color(1, 1, 1, 0.08 + 0.12 * flash), 1.0)
var cx := px + pw * 0.5; var cy := py + ph * 0.5
draw_circle(Vector2(cx, cy), 22.0 + flash * 8.0, Color(1, 1, 1, 0.25 * flash))
_draw_text_c("[ N ]", cx, cy + 48.0, 14, Color(1, 1, 1, 0.7 + 0.3 * flash))
_draw_text_c("HOLD", cx, cy + 66.0, 8, Color(1, 1, 1, 0.6))
# ─── Drawing helpers ──────────────────────────────────────────────────────────
func _draw_terminal_box(bx: float, by: float, bw: float, bh: float) -> void:
draw_rect(Rect2(bx, by, bw, bh), Color(0.0, 0.04, 0.02, 0.95))
var bc := Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.35)
draw_line(Vector2(bx, by), Vector2(bx+bw, by), bc, 1.0)
draw_line(Vector2(bx, by+bh), Vector2(bx+bw, by+bh), bc, 1.0)
draw_line(Vector2(bx, by), Vector2(bx, by+bh), bc, 1.0)
draw_line(Vector2(bx+bw, by), Vector2(bx+bw, by+bh), bc, 1.0)
var cl := 12.0
for cx: float in [bx, bx+bw]:
for cy: float in [by, by+bh]:
var sx := 1.0 if cx == bx else -1.0
var sy := 1.0 if cy == by else -1.0
draw_line(Vector2(cx, cy), Vector2(cx + sx*cl, cy), COL_PRIMARY, 1.5)
draw_line(Vector2(cx, cy), Vector2(cx, cy + sy*cl), COL_PRIMARY, 1.5)
func _draw_brackets(x1: float, y1: float, x2: float, y2: float, col: Color, arm: float) -> void:
var bw := 1.5
draw_line(Vector2(x1, y1), Vector2(x1+arm, y1), col, bw)
draw_line(Vector2(x1, y1), Vector2(x1, y1+arm), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2-arm, y1), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2, y1+arm), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1+arm, y2), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1, y2-arm), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2-arm, y2), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2, y2-arm), col, bw)
func _draw_text(text: String, x: float, y: float, sz: int, col: Color) -> void:
draw_string(ThemeDB.fallback_font, Vector2(x, y + sz),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_text_c(text: String, x: float, y: float, sz: int, col: Color) -> void:
var tw := _text_w(text, sz)
draw_string(ThemeDB.fallback_font, Vector2(x - tw * 0.5, y + sz),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _text_w(text: String, sz: int) -> float:
return ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
func _draw_wrapped(text: String, x: float, y: float, w: float, sz: int, col: Color) -> void:
var words := text.split(" ", false)
var line := ""
var row_y := y
for word: String in words:
var trial := (line + " " + word) if line != "" else word
if _text_w(trial, sz) > w and line != "":
_draw_text(line, x, row_y, sz, col)
row_y += float(sz) + 4.0
line = word
else:
line = trial
if line != "":
_draw_text(line, x, row_y, sz, col)
+1
View File
@@ -0,0 +1 @@
uid://dbj3vbayteqo0
+60
View File
@@ -0,0 +1,60 @@
extends RefCounted
class_name BigWipe
enum Phase { INACTIVE, COLLAPSE, FLASH, DONE }
var phase: Phase = Phase.INACTIVE
var timer: float = 0.0
var flash_timer: float = 0.0
var p1_survived: bool = false
var p2_survived: bool = false
var p1_pressed: bool = false
var p2_pressed: bool = false
const COLLAPSE_DURATION := 140.0 / 60.0 # frames to seconds
const FLASH_DURATION := 24.0 / 60.0
const WIPE_BONUS := 500
signal wipe_complete(p1_survived, p2_survived)
func start() -> void:
phase = Phase.COLLAPSE
timer = 0.0
p1_pressed = false
p2_pressed = false
p1_survived = false
p2_survived = false
func is_active() -> bool:
return phase != Phase.INACTIVE and phase != Phase.DONE
func collapse_progress() -> float:
return clamp(timer / COLLAPSE_DURATION, 0.0, 1.0)
func update(delta: float, is_multiplayer: bool) -> void:
match phase:
Phase.COLLAPSE:
timer += delta
if timer >= COLLAPSE_DURATION:
phase = Phase.FLASH
flash_timer = 0.0
p1_survived = p1_pressed
p2_survived = p2_pressed if is_multiplayer else false
Phase.FLASH:
flash_timer += delta
if flash_timer >= FLASH_DURATION:
phase = Phase.DONE
wipe_complete.emit(p1_survived, p2_survived)
func player_press_survive(player_idx: int) -> void:
if phase != Phase.COLLAPSE: return
if player_idx == 0: p1_pressed = true
else: p2_pressed = true
func draw_overlay(canvas: CanvasItem, world_w: float, world_h: float, _blink_t: float) -> void:
match phase:
Phase.FLASH:
var f := 1.0 - flash_timer / FLASH_DURATION
canvas.draw_rect(Rect2(0, 0, world_w, world_h), Color(1, 1, 1, f))
Phase.COLLAPSE:
var dim: float = collapse_progress() * 0.4
canvas.draw_rect(Rect2(0, 0, world_w, world_h), Color(0, 0, 0, dim))
+1
View File
@@ -0,0 +1 @@
uid://qe52wshn58wj
+202
View File
@@ -0,0 +1,202 @@
extends RefCounted
class_name BlackHole
const GRAVITY_STRENGTH := 0.8
# Original was 150px for ~1440px-wide window (10% of screen).
# Our canvas is 960px; 10% = 96px. Use ~160 for noticeable-but-fair gravity.
const PULL_RADIUS := 160.0
const SWALLOW_RADIUS := 14.0 # slightly smaller for fairer play
const MAX_BH_SPEED := 0.72 # 0.3 * 2.4
const DRIFT_PER_TICK := 0.012 # 0.005 * 2.4
const SUPERNOVA_AT := 30 # original value
const MAX_RADIUS := 60.0 # reasonable size at 960px
const FORCE_MULT := 45.0 # tuned: meaningful pull without instant death
const OBJ_MAX_VEL := 5.0 # velocity cap for attracted objects
# Hunting constants — slow drift, creates tension without instant death
const HUNT_DETECTION := 500.0 # roughly half-screen detection radius
const HUNT_ACCEL := 0.008 # very gentle acceleration toward player
const HUNT_MAX_SPEED := 0.38 # original 0.6 on 1440px → 0.6*(960/1440) = 0.4
const HUNT_LOSE_FRAMES := 180 # stops hunting if player out of range for 3s
const HUNT_CHANCE := 0.18
# SMBH lifespan — after SMBH_MAX_LIFE seconds it collapses
const SMBH_MAX_LIFE := 45.0
const SMBH_COLLAPSE_DURATION := 4.0
var x: float; var y: float
var vx: float = 0.0; var vy: float = 0.0
var radius: float = 12.0
var base_radius: float = 12.0
var pull_radius: float = PULL_RADIUS
var gravity: float = GRAVITY_STRENGTH
var consumed: int = 0
var pulse: float = 0.0
var dead: bool = false
var is_smbh: bool = false
var eject_timer: float = 0.0
var hunting: bool = false
var hunt_lost_timer: int = 0
var smbh_life: float = 0.0
var smbh_dying: bool = false
var smbh_collapse_timer: float = 0.0
var flash_intensity: float = 0.0
var flash_color: Color = Color(1.0, 1.0, 1.0, 0.0)
var accretion_flare: float = 0.0
func init(px: float, py: float, _mobile: bool = false) -> void:
x = px; y = py
var r := randf_range(6.0, 12.0) * 2.4 # 6..12 * 2.4
radius = r
base_radius = r
# Initial velocity: ±0.36 (original ±0.15 * 2.4)
vx = randf_range(-0.36, 0.36)
vy = randf_range(-0.36, 0.36)
hunting = randf() < HUNT_CHANCE
func become_smbh() -> void:
is_smbh = true
radius = randf_range(60.0, 90.0) # original 55-75 * ~1.4
base_radius = radius
pull_radius = min(PULL_RADIUS * 4.0, pull_radius * 2.5)
gravity = min(GRAVITY_STRENGTH * 4.0, gravity * 2.0)
func update(delta: float, players: Array, world_w: float, world_h: float) -> void:
pulse += delta * 2.0
eject_timer += delta
# Per-tick micro-drift — matches original rand(-0.005, 0.005) per frame * 2.4
vx += randf_range(-DRIFT_PER_TICK, DRIFT_PER_TICK)
vy += randf_range(-DRIFT_PER_TICK, DRIFT_PER_TICK)
# Hunting: 18% of BHs chase player within detection radius
if hunting and players.size() > 0:
var nearest_dist := INF
var nearest_p = null
for p in players:
if p.dead: continue
var dx: float = float(p.x) - x
var dy: float = float(p.y) - y
var d: float = sqrt(dx*dx + dy*dy)
if d < nearest_dist:
nearest_dist = d
nearest_p = p
if nearest_p != null and nearest_dist < HUNT_DETECTION and nearest_dist > 0.0:
var dx: float = float(nearest_p.x) - x
var dy: float = float(nearest_p.y) - y
var dist: float = sqrt(dx*dx + dy*dy)
vx += (dx/dist) * HUNT_ACCEL
vy += (dy/dist) * HUNT_ACCEL
var sp := sqrt(vx*vx + vy*vy)
if sp > HUNT_MAX_SPEED:
vx = vx/sp * HUNT_MAX_SPEED
vy = vy/sp * HUNT_MAX_SPEED
hunt_lost_timer = 0
else:
hunt_lost_timer += 1
if hunt_lost_timer >= HUNT_LOSE_FRAMES:
hunting = false
# SMBH lifespan — collapses after SMBH_MAX_LIFE seconds
if is_smbh and not dead:
smbh_life += delta
if smbh_life >= SMBH_MAX_LIFE and not smbh_dying:
smbh_dying = true
smbh_collapse_timer = SMBH_COLLAPSE_DURATION
if smbh_dying:
smbh_collapse_timer -= delta
radius = max(8.0, base_radius * (smbh_collapse_timer / SMBH_COLLAPSE_DURATION))
if smbh_collapse_timer <= 0.0:
dead = true
# Clamp wander speed
vx = clamp(vx, -MAX_BH_SPEED, MAX_BH_SPEED)
vy = clamp(vy, -MAX_BH_SPEED, MAX_BH_SPEED)
x += vx; y += vy
# Soft bounce off edges (5% / 95% margins like original)
if x < world_w * 0.05: vx = abs(vx)
elif x > world_w * 0.95: vx = -abs(vx)
if y < world_h * 0.05: vy = abs(vy)
elif y > world_h * 0.95: vy = -abs(vy)
# Consume flash/flare decay
if flash_intensity > 0.0:
flash_intensity = maxf(0.0, flash_intensity - delta * 3.0)
if accretion_flare > 0.0:
accretion_flare = maxf(0.0, accretion_flare - delta * 1.5)
func check_swallow(ox: float, oy: float) -> bool:
var dx := ox - x; var dy := oy - y
return sqrt(dx*dx + dy*dy) < SWALLOW_RADIUS + radius * 0.5
func apply_gravity(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
var dx := ox - x; var dy := oy - y
var dist := sqrt(dx*dx + dy*dy)
var nvx := ovx; var nvy := ovy
if dist < pull_radius and dist > 0.01:
var force := gravity / (dist * dist) * FORCE_MULT
nvx += (dx/dist) * -force
nvy += (dy/dist) * -force
return Vector2(nvx, nvy)
func on_swallow() -> bool:
consumed += 1
# Growth formula from original: radius = min(MAX, base + consumed * 0.4) * 2.4
radius = min(MAX_RADIUS, base_radius + consumed * 0.96) # 0.4 * 2.4
pull_radius = min(PULL_RADIUS * 2.0, PULL_RADIUS + consumed * 7.2) # 3 * 2.4
gravity = min(GRAVITY_STRENGTH * 2.0, GRAVITY_STRENGTH + consumed * 0.05)
if consumed >= SUPERNOVA_AT and not is_smbh:
return true
return false
func trigger_flash(col: Color, intensity: float = 1.0) -> void:
flash_intensity = intensity
flash_color = col
accretion_flare = intensity
func draw(canvas: CanvasItem) -> void:
var p := 0.6 + 0.4 * sin(pulse)
var cv := Vector2(x, y)
var ring_r := radius + 4.0 + 4.0 * sin(pulse)
var ring_col := Color(0.6, 0.2, 1.0, p * 0.9) if not is_smbh else Color(1.0, 0.4, 0.0, p)
# Outer glow halo — drawn first so core covers it
canvas.draw_circle(cv, radius + ring_r * 0.35, Color(ring_col.r, ring_col.g, ring_col.b, p * 0.08))
# Accretion disk: rotating segmented arcs — flare brightens + thickens on consume
var flare_mult: float = 1.0 + accretion_flare * 2.0
var disk_w: float = 2.5 + accretion_flare * 3.0
var disk_r := ring_r + 8.0
for seg in 6:
var sa := pulse * 0.55 + float(seg) / 6.0 * TAU
var arc_col := Color(ring_col.r, ring_col.g, ring_col.b, minf(ring_col.a * flare_mult, 1.0))
canvas.draw_arc(cv, ring_r, sa, sa + TAU / 6.0 * 0.75, 6, arc_col, disk_w)
for seg in 4:
var sa := -pulse * 0.3 + float(seg) / 4.0 * TAU
canvas.draw_arc(cv, disk_r, sa, sa + TAU / 4.0 * 0.6, 5,
Color(0.9, 0.5, 0.2, minf(p * 0.35 * flare_mult, 1.0)), disk_w * 0.6)
# Black core — single draw_circle replaces O(radius²) pixel loop
canvas.draw_circle(cv, radius, Color(0.0, 0.0, 0.0, 1.0))
# SMBH extra rings
if is_smbh:
for ri in 3:
var er := ring_r + float(ri + 1) * 10.0
var rot_dir := 1.0 if ri % 2 == 0 else -1.0
var sa := pulse * (0.35 + float(ri) * 0.1) * rot_dir
canvas.draw_arc(cv, er, sa, sa + TAU, 24,
Color(1.0, 0.6, 0.0, p * 0.3), 1.5)
# Consume flash — expanding ring + inner glow
if flash_intensity > 0.0:
var flash_r: float = radius + 10.0 + (1.0 - flash_intensity) * 25.0
canvas.draw_arc(cv, flash_r, 0.0, TAU, 24,
Color(flash_color.r, flash_color.g, flash_color.b, flash_intensity * 0.7), 2.5)
canvas.draw_circle(cv, radius + 4.0,
Color(flash_color.r, flash_color.g, flash_color.b, flash_intensity * 0.15))
+1
View File
@@ -0,0 +1 @@
uid://dmqsqq8etgp7e
+193
View File
@@ -0,0 +1,193 @@
extends RefCounted
class_name BossShip
# ═══════════════════════════════════════════════════════════════════════════
# BossShip — Mini-Boss "WRAITH" (Welle 5) & Boss "LEVIATHAN" (Welle 8)
# Orbitet elliptisch um die Bildschirmmitte, feuert Spread-Geschosse.
# Wird von game_world.gd verwaltet (update/draw/take_hit).
# ═══════════════════════════════════════════════════════════════════════════
var x: float = 0.0
var y: float = 0.0
var heading: float = 0.0
var orbit_angle: float = 0.0
var hp: int = 20
var max_hp: int = 20
var dead: bool = false
var fire_timer: int = 0
var is_miniboss: bool = true # false = Boss
var phase: int = 1 # 1 oder 2 (nur Boss)
var _phase2_triggered: bool = false
var _just_entered_phase2: bool = false
var pixel_size: int = 5 # Miniboss=5, Boss=7
const HULL_PIXELS: Array = [
[6, 0, "nose"],
[4, -2, "mid"], [4, 2, "mid"],
[2, 0, "dim"],
[0, -4, "accent"], [0, 4, "accent"],
[0, 0, "bright"],
[-2, -2, "dim"], [-2, 2, "dim"],
[-4, -4, "shadow"], [-4, 4, "shadow"],
[-4, 0, "edge"],
]
# Mini-Boss WRAITH: Magenta/Lila
const PAL_MINI: Dictionary = {
"nose": Color(1.0, 0.15, 0.9, 1.0),
"bright": Color(1.0, 0.5, 1.0, 1.0),
"mid": Color(0.75, 0.05, 0.7, 1.0),
"dim": Color(0.45, 0.02, 0.38, 1.0),
"accent": Color(1.0, 0.0, 0.85, 1.0),
"edge": Color(0.28, 0.0, 0.28, 1.0),
"shadow": Color(0.12, 0.0, 0.12, 1.0),
}
# Boss LEVIATHAN: Feuer-Orange
const PAL_BOSS: Dictionary = {
"nose": Color(1.0, 0.65, 0.0, 1.0),
"bright": Color(1.0, 0.9, 0.25, 1.0),
"mid": Color(0.8, 0.38, 0.0, 1.0),
"dim": Color(0.5, 0.18, 0.0, 1.0),
"accent": Color(1.0, 0.72, 0.0, 1.0),
"edge": Color(0.28, 0.08, 0.0, 1.0),
"shadow": Color(0.12, 0.03, 0.0, 1.0),
}
# ── Initialisierung ────────────────────────────────────────────────────────
func init_miniboss(cx: float, cy: float) -> void:
x = cx
y = cy - 120.0
orbit_angle = -PI * 0.5 # startet oben
hp = 20; max_hp = 20
is_miniboss = true
pixel_size = 5
fire_timer = 25 # kurze Verzögerung vor dem ersten Schuss
func init_boss(cx: float, cy: float) -> void:
x = cx
y = cy - 140.0
orbit_angle = -PI * 0.5
hp = 50; max_hp = 50
is_miniboss = false
phase = 1
pixel_size = 7
fire_timer = 25
# ── Update ─────────────────────────────────────────────────────────────────
# Gibt Array[Bullet] zurück. Wird von game_world._tick() pro Frame aufgerufen.
func update(delta: float, cx: float, cy: float) -> Array:
if dead:
return []
# Orbitalbewegung (elliptisch, um Spannung zu erzeugen)
var orbit_speed: float
if is_miniboss:
orbit_speed = 0.55
elif phase == 1:
orbit_speed = 0.44
else:
orbit_speed = 0.72 # Phase 2 schneller → gefährlicher
orbit_angle += orbit_speed * delta
var orbit_r: float = 185.0 if is_miniboss else 215.0
x = cx + cos(orbit_angle) * orbit_r
y = cy + sin(orbit_angle) * orbit_r * 0.58 # leicht elliptisch
# Heading zeigt immer zur Bildschirmmitte (schießt nach innen)
heading = atan2(cy - y, cx - x)
# Feuer-Intervall
fire_timer += 1
var fire_interval: int
if is_miniboss:
fire_interval = 72
elif phase == 1:
fire_interval = 60
else:
fire_interval = 42
var result: Array = []
if fire_timer >= fire_interval:
fire_timer = 0
result = _fire()
# Phase-2-Übergang (Boss, < 50% HP)
if not is_miniboss and not _phase2_triggered and hp <= max_hp / 2:
phase = 2
_phase2_triggered = true
_just_entered_phase2 = true
return result
func _fire() -> Array:
var ways: int
if is_miniboss:
ways = 3
elif phase == 1:
ways = 5
else:
ways = 8
var bullet_speed: float = 4.2 if is_miniboss else 5.0
var spread: float = PI / float(ways + 2)
var bullets: Array = []
for i in ways:
var angle: float = heading + (float(i) - float(ways - 1) * 0.5) * spread
var b := Bullet.new()
b.init(x, y, angle, "boss")
# Geschwindigkeit überschreiben (langsamer als Spielerkugeln für Ausweichen)
b.vx = cos(angle) * bullet_speed
b.vy = sin(angle) * bullet_speed
if is_miniboss:
b.color = Color(1.0, 0.15, 0.9, 1.0) # Magenta
elif phase == 1:
b.color = Color(1.0, 0.5, 0.0, 1.0) # Orange
else:
b.color = Color(1.0, 0.1, 0.0, 1.0) # Dunkelrot
bullets.append(b)
return bullets
func take_hit() -> void:
hp -= 1
if hp <= 0:
hp = 0
dead = true
# ── Zeichnen ───────────────────────────────────────────────────────────────
func draw(canvas: CanvasItem) -> void:
if dead:
return
var pal: Dictionary = PAL_MINI if is_miniboss else PAL_BOSS
var ps: float = float(pixel_size)
var hs: float = ps * 0.5
var ch: float = cos(heading)
var sh: float = sin(heading)
# Rumpf — gleiche Offset-Tabelle wie EnemyShip, skaliert mit pixel_size
for pix: Array in HULL_PIXELS:
var lx: float = float(pix[0]) * ps
var ly: float = float(pix[1]) * ps
var wx: float = x + lx * ch - ly * sh
var wy: float = y + lx * sh + ly * ch
canvas.draw_rect(Rect2(wx - hs, wy - hs, ps, ps), pal[pix[2]])
# Thruster-Glühen (hinter dem Schiff)
var glow_x: float = x + cos(heading + PI) * (ps * 4.2)
var glow_y: float = y + sin(heading + PI) * (ps * 4.2)
var glow_r: float = ps + 2.0
var pulse: float = 0.48 + 0.38 * sin(float(Time.get_ticks_msec()) * 0.012)
var glow_col: Color
if is_miniboss:
glow_col = Color(1.0, 0.0, 0.85, pulse)
elif phase == 1:
glow_col = Color(1.0, 0.5, 0.0, pulse)
else:
glow_col = Color(1.0, 0.15, 0.0, pulse + 0.15) # Phase 2 heller
canvas.draw_circle(Vector2(glow_x, glow_y), glow_r, glow_col)
+1
View File
@@ -0,0 +1 @@
uid://r3q2arux2t1n
+116
View File
@@ -0,0 +1,116 @@
extends RefCounted
class_name Bullet
const BULLET_SPEED := 9.6
const MAX_LIFE := 180
const FADE_START := 150
const HIT_RADIUS := 9.6
var x: float; var y: float
var vx: float; var vy: float
var life: int = 0
var owner_type: String
var color: Color
var dead: bool = false
var effective_hit_radius: float = HIT_RADIUS
var pierce: bool = false
var pierce_hits: int = 0
var style: String = "default" # set by spaceship based on equipped weapon
func init(px: float, py: float, heading: float, otype: String) -> void:
x = px; y = py
vx = cos(heading) * BULLET_SPEED
vy = sin(heading) * BULLET_SPEED
owner_type = otype
match otype:
"p1": color = Color("#88aaff")
"p2": color = Color("#44ffaa")
"enemy": color = Color("#ff4444")
_: color = Color.WHITE
func update(world_w: float, world_h: float) -> void:
x += vx; y += vy
life += 1
if x < 0: x += world_w
elif x > world_w: x -= world_w
if y < 0: y += world_h
elif y > world_h: y -= world_h
if life >= MAX_LIFE: dead = true
func get_alpha() -> float:
if life >= FADE_START:
return 1.0 - float(life - FADE_START) / float(MAX_LIFE - FADE_START)
return 1.0
func draw(canvas: CanvasItem) -> void:
var a := get_alpha()
match style:
"plasma":
# Strahl — dicke Linie entlang der Flugrichtung, orange-gelb
var angle := atan2(vy, vx)
var len := 26.0
var ca := cos(angle); var sa := sin(angle)
var bx := x - ca * len * 0.6; var by := y - sa * len * 0.6
var ex := x + ca * len * 0.4; var ey := y + sa * len * 0.4
canvas.draw_line(Vector2(bx, by), Vector2(ex, ey), Color(1.0, 0.45, 0.05, a * 0.45), 5.0)
canvas.draw_line(Vector2(bx, by), Vector2(ex, ey), Color(1.0, 0.75, 0.15, a), 2.0)
canvas.draw_rect(Rect2(x - 1, y - 1, 3, 3), Color(1.0, 1.0, 0.7, a))
"laser":
# Dünner schneller Strahl, cyan-weiß
var angle := atan2(vy, vx)
var len := 22.0
var ca := cos(angle); var sa := sin(angle)
var bx := x - ca * len; var by := y - sa * len
canvas.draw_line(Vector2(bx, by), Vector2(x, y), Color(0.3, 0.9, 1.0, a * 0.4), 3.0)
canvas.draw_line(Vector2(bx, by), Vector2(x, y), Color(0.8, 1.0, 1.0, a), 1.0)
canvas.draw_rect(Rect2(x - 1, y - 1, 2, 2), Color(1.0, 1.0, 1.0, a))
"rail":
# Lange helle Nadel mit blauem Schweif
var angle := atan2(vy, vx)
var len := 32.0
var ca := cos(angle); var sa := sin(angle)
var bx := x - ca * len; var by := y - sa * len
canvas.draw_line(Vector2(bx, by), Vector2(x, y), Color(0.4, 0.7, 1.0, a * 0.35), 4.0)
canvas.draw_line(Vector2(bx, by), Vector2(x, y), Color(0.9, 0.98, 1.0, a), 1.0)
canvas.draw_rect(Rect2(x - 1, y - 1, 2, 2), Color(1.0, 1.0, 1.0, a))
"sniper":
# Mittellange scharfe Nadel, strahlend weiß
var angle := atan2(vy, vx)
var len := 18.0
var ca := cos(angle); var sa := sin(angle)
var bx := x - ca * len; var by := y - sa * len
canvas.draw_line(Vector2(bx, by), Vector2(x, y), Color(1.0, 0.95, 0.8, a * 0.5), 2.0)
canvas.draw_line(Vector2(bx, by), Vector2(x, y), Color(1.0, 1.0, 1.0, a), 1.0)
"ion":
# Leuchtender türkiser Orb
canvas.draw_circle(Vector2(x, y), 6.0, Color(0.2, 0.9, 0.85, a * 0.3))
canvas.draw_circle(Vector2(x, y), 3.5, Color(0.4, 1.0, 0.95, a * 0.75))
canvas.draw_rect(Rect2(x - 1, y - 1, 3, 3), Color(0.9, 1.0, 1.0, a))
"scatter":
# Kleine schnelle Schrotkügelchen, orange
canvas.draw_rect(Rect2(x - 1, y - 1, 3, 3), Color(1.0, 0.65, 0.2, a * 0.5))
canvas.draw_rect(Rect2(x, y, 2, 2), Color(1.0, 0.85, 0.5, a))
"burst":
# Winzige schnelle Punkte, hellblau
canvas.draw_rect(Rect2(x - 1, y - 1, 3, 3), Color(0.5, 0.85, 1.0, a * 0.5))
canvas.draw_rect(Rect2(x, y, 1, 1), Color(1.0, 1.0, 1.0, a))
"charge":
# Großer pulsierender Orb — Radius skaliert mit effective_hit_radius
var r: float = clamp(effective_hit_radius * 0.55, 4.0, 20.0)
canvas.draw_circle(Vector2(x, y), r + 4.0, Color(1.0, 0.85, 0.2, a * 0.2))
canvas.draw_circle(Vector2(x, y), r + 1.5, Color(1.0, 0.95, 0.4, a * 0.45))
canvas.draw_circle(Vector2(x, y), r, Color(1.0, 1.0, 0.75, a * 0.85))
canvas.draw_circle(Vector2(x, y), r * 0.45, Color(1.0, 1.0, 1.0, a))
_: # "default" — klassischer Dot
canvas.draw_rect(Rect2(x - 2, y - 2, 5, 5), Color(color.r, color.g, color.b, a * 0.5))
canvas.draw_rect(Rect2(x - 1, y - 1, 3, 3), Color(color.r, color.g, color.b, a))
canvas.draw_rect(Rect2(x, y, 1, 1), Color(1, 1, 1, a * 0.9))
+1
View File
@@ -0,0 +1 @@
uid://6a8ufydt7pd8
+590
View File
@@ -0,0 +1,590 @@
extends RefCounted
class_name CosmicObjects
# Planet lives in its own file now; keep CosmicObjects.Planet working.
const Planet = preload("res://scripts/planet.gd")
# ─── Star ─────────────────────────────────────────────────────────────────────
class Star extends RefCounted:
var x: float; var y: float; var speed: float; var size: float
var color: Color; var alpha: float = 1.0; var glow: bool = false
var twinkle_phase: float; var twinkle_speed: float; var dead: bool = false
var rotation_angle: float = 0.0
var is_antimatter: bool = false
var life: float = 0.0; var max_life: float = 0.0
# Gravity-induced drift (vx/vy from BH attraction, like original)
var grav_vx: float = 0.0; var grav_vy: float = 0.0
# Spiral-into-BH state
var is_spiraling: bool = false
var spiral_timer: float = 0.0
var spiral_bh_x: float = 0.0; var spiral_bh_y: float = 0.0
var spiral_angle: float = 0.0
var spiral_dist: float = 0.0; var spiral_initial_dist: float = 0.0
var trail_points: Array = []
var original_color: Color = Color.WHITE
const SPIRAL_DURATION := 0.8
const TRAIL_MAX := 16
func init(px: float, py: float, _w: float, _h: float) -> void:
x = px; y = py
# Three tiers (matching original): supergiant / bright / faint
var tier := randf()
if tier < 0.05: # supergiant
size = randf_range(5.0, 7.0) * 2.4 / 4.0 # scale size
alpha = 0.85 + randf() * 0.15
glow = true
elif tier < 0.20: # bright main-sequence
size = randf_range(3.0, 4.0) * 2.4 / 4.0
alpha = 0.65 + randf() * 0.3
glow = true
else: # faint background
size = 1.0 if randf() > 0.4 else 2.0
alpha = 0.2 + randf() * 0.55
glow = false
speed = randf_range(0.05, 0.3) * 2.4 # original 0.05..0.3 * scale
twinkle_phase = randf() * TAU
twinkle_speed = randf_range(0.01, 0.03) * 60.0 # per-frame → per-second
var star_colors := ["#ffffff","#ffe8a0","#a0c4ff","#ffc0c0","#c0ffc0"]
color = Color(star_colors[randi() % star_colors.size()])
is_antimatter = randf() < 0.10
if is_antimatter:
color = Color("#ff88ee"); max_life = randf_range(8.0, 18.0)
func start_spiral(bh_x: float, bh_y: float) -> void:
is_spiraling = true
spiral_timer = 0.0
spiral_bh_x = bh_x; spiral_bh_y = bh_y
var dx := x - bh_x; var dy := y - bh_y
spiral_dist = sqrt(dx * dx + dy * dy)
spiral_initial_dist = maxf(spiral_dist, 1.0)
spiral_angle = atan2(dy, dx)
original_color = color
trail_points.clear()
func update(delta: float, world_w: float, world_h: float) -> bool:
if is_spiraling:
spiral_timer += delta
var t: float = clampf(spiral_timer / SPIRAL_DURATION, 0.0, 1.0)
# Radius shrinks quadratically — tightening orbit
spiral_dist = spiral_initial_dist * (1.0 - t * t)
# Angular speed increases (Kepler-like feel)
var angular_speed: float = 6.0 + 14.0 * t * t
spiral_angle += angular_speed * delta
# Position on orbit
x = spiral_bh_x + cos(spiral_angle) * spiral_dist
y = spiral_bh_y + sin(spiral_angle) * spiral_dist
# Light trail
trail_points.push_front(Vector2(x, y))
if trail_points.size() > TRAIL_MAX: trail_points.pop_back()
# Blueshift — color lerps toward blue-white
color = original_color.lerp(Color(0.6, 0.7, 1.0), clampf(t * 1.5, 0.0, 1.0))
# Alpha fades only in final 30%
if t > 0.7:
alpha = maxf(0.0, 1.0 - (t - 0.7) / 0.3)
# Spaghettification — star shrinks
size = maxf(0.5, size * (1.0 - delta * 2.0))
if spiral_timer >= SPIRAL_DURATION:
dead = true
return false
rotation_angle += twinkle_speed * delta * 0.07
twinkle_phase += twinkle_speed * delta
var flicker := sin(twinkle_phase) * 0.25
alpha = max(0.05, min(1.0, alpha + flicker * delta * 3.0))
# Upward drift + wavy horizontal (matching original: x += sin(y*0.02)*0.15)
y -= speed
x += grav_vx + sin(y * 0.0085) * 0.36 # 0.02/2.4 freq, 0.15*2.4 amplitude
y += grav_vy
if y < -10.0: y += world_h + 10.0
if x < -10.0: x += world_w + 10.0
elif x > world_w + 10.0: x -= world_w + 10.0
if is_antimatter:
life += delta
if life >= max_life: return true
return false
func draw(canvas: CanvasItem) -> void:
# Light trail when spiraling into BH
if is_spiraling and trail_points.size() > 1:
for i in range(trail_points.size() - 1):
var fade: float = 1.0 - float(i) / float(TRAIL_MAX)
var tc := Color(color.r, color.g, color.b, alpha * fade * 0.6)
canvas.draw_line(trail_points[i], trail_points[i + 1], tc,
maxf(1.0, size * 0.5 * fade))
var c := color
if glow:
# Soft halo around bright stars
var halo_r := size + 2.0
canvas.draw_rect(Rect2(x - halo_r, y - halo_r, halo_r*2.0+1.0, halo_r*2.0+1.0),
Color(c.r, c.g, c.b, alpha * 0.18))
# Rotating cross flare for large stars
if size >= 4.0:
var fl := size + 4.0
for arm_i in 4:
var arm_a := rotation_angle + float(arm_i) * PI * 0.5
for dist in range(1, int(fl) + 1):
var ax := x + cos(arm_a) * dist
var ay := y + sin(arm_a) * dist
var fade := 1.0 - float(dist) / fl
canvas.draw_rect(Rect2(ax, ay, 1, 1), Color(c.r, c.g, c.b, alpha * 0.5 * fade))
var sz: float = maxf(1.0, size)
canvas.draw_rect(Rect2(x, y, sz, sz), Color(c.r, c.g, c.b, alpha))
# ─── Comet ────────────────────────────────────────────────────────────────────
class Comet extends RefCounted:
var x: float; var y: float; var vx: float; var vy: float
var alpha: float = 1.0; var dead: bool = false
var trail_pts: Array = []
const TRAIL_MAX := 24
func init(world_w: float, world_h: float) -> void:
var side := randi() % 4
match side:
0: x = randf()*world_w; y = 0; vx = randf_range(-3.0,3.0); vy = randf_range(1.0,4.0)
1: x = randf()*world_w; y = world_h; vx = randf_range(-3.0,3.0); vy = randf_range(-4.0,-1.0)
2: x = 0; y = randf()*world_h; vx = randf_range(1.0,4.0); vy = randf_range(-3.0,3.0)
3: x = world_w; y = randf()*world_h; vx = randf_range(-4.0,-1.0); vy = randf_range(-3.0,3.0)
alpha = 1.0
func update(world_w: float, world_h: float) -> void:
trail_pts.push_front(Vector2(x, y))
if trail_pts.size() > TRAIL_MAX: trail_pts.pop_back()
x += vx; y += vy
if x < -40 or x > world_w+40 or y < -40 or y > world_h+40:
dead = true
func draw(canvas: CanvasItem) -> void:
for i in trail_pts.size():
var t := 1.0 - float(i) / float(TRAIL_MAX)
var c := Color(1.0, 1.0, 1.0, t * 0.85)
canvas.draw_rect(Rect2(trail_pts[i].x, trail_pts[i].y, 2, 2), c)
canvas.draw_rect(Rect2(x-2, y-2, 5, 5), Color(1,1,1,alpha))
# ─── Nebula ───────────────────────────────────────────────────────────────────
class Nebula extends RefCounted:
var x: float; var y: float; var radius: float; var color: Color
var vx: float; var vy: float
func init(world_w: float, world_h: float) -> void:
x = randf() * world_w; y = randf() * world_h
radius = randf_range(80.0, 160.0)
var neb_colors := ["#6633cc80","#cc336680","#33669980","#66993380"]
color = Color(neb_colors[randi() % neb_colors.size()])
vx = randf_range(-0.2, 0.2); vy = randf_range(-0.2, 0.2)
func update(world_w: float, world_h: float) -> void:
x += vx; y += vy
if x < -radius: x += world_w + radius*2
elif x > world_w+radius: x -= world_w + radius*2
if y < -radius: y += world_h + radius*2
elif y > world_h+radius: y -= world_h + radius*2
func draw(canvas: CanvasItem) -> void:
var ir := int(radius)
for py in range(-ir, ir+1, 8):
for px in range(-ir, ir+1, 8):
var dist := sqrt(float(px*px + py*py))
if dist <= radius:
var fade := 1.0 - dist / radius
fade = floor(fade * 4.0) / 4.0
if fade <= 0.0: continue
var c := Color(color.r, color.g, color.b, color.a * fade * 0.45)
canvas.draw_rect(Rect2(x+px, y+py, 8, 8), c)
# ─── Quasar ───────────────────────────────────────────────────────────────────
class Quasar extends RefCounted:
var x: float; var y: float; var pulse: float = 0.0
var jet_angle: float; var dead: bool = false
var life: float = 0.0; const MAX_LIFE := 30.0
var boost_sound_cd: float = 0.0
var radius: float = 22.0
const JET_LENGTH := 220.0
const JET_HALF_ANG := 0.25 # ~14° half-width — wide enough to feel generous
const JET_FORCE := 0.55 # added velocity per frame while inside beam
func init(px: float, py: float) -> void:
x = px; y = py
jet_angle = randf() * TAU
func update(delta: float) -> void:
pulse += delta * 3.0
jet_angle += delta * 0.4
life += delta
if boost_sound_cd > 0.0: boost_sound_cd = max(0.0, boost_sound_cd - delta)
if life >= MAX_LIFE: dead = true
func push_object(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
const PUSH_RADIUS := 150.0
const PUSH_STRENGTH := 0.45
var dx := ox - x; var dy := oy - y
var dist := sqrt(dx*dx + dy*dy)
if dist < PUSH_RADIUS and dist > 0.5:
var force := PUSH_STRENGTH / (dist * 0.05 + 1.0)
return Vector2(ovx + (dx/dist)*force, ovy + (dy/dist)*force)
return Vector2(ovx, ovy)
# Returns modified velocity if the position is inside one of the two jets.
func boost_if_in_jet(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
var dx := ox - x; var dy := oy - y
var dist := sqrt(dx*dx + dy*dy)
if dist > JET_LENGTH or dist < 4.0:
return Vector2(ovx, ovy)
var angle := atan2(dy, dx)
for jd: float in [0.0, PI]:
var ja := jet_angle + jd
var diff := fmod(abs(angle - ja) + PI, TAU) - PI
if abs(diff) < JET_HALF_ANG:
var fade := 1.0 - dist / JET_LENGTH
var force := JET_FORCE * (0.3 + 0.7 * fade)
return Vector2(ovx + cos(ja) * force, ovy + sin(ja) * force)
return Vector2(ovx, ovy)
func draw(canvas: CanvasItem) -> void:
var p := 0.7 + 0.3 * sin(pulse)
var cv := Vector2(x, y)
var R := radius
# Luminosity halos — quasars outshine entire galaxies
canvas.draw_circle(cv, R * 5.5, Color(1.0, 0.95, 0.4, 0.025))
canvas.draw_circle(cv, R * 3.2, Color(1.0, 0.9, 0.3, 0.06 * p))
canvas.draw_circle(cv, R * 2.0, Color(1.0, 0.95, 0.6, 0.11 * p))
# Relativistic jets — the defining quasar feature; cyan beams in opposite directions
for jd: float in [0.0, PI]:
var ja := jet_angle + jd
for jl in range(6, 220, 4):
var fade := 1.0 - float(jl) / 220.0
var jx := x + cos(ja) * float(jl)
var jy := y + sin(ja) * float(jl)
canvas.draw_circle(Vector2(jx, jy), maxf(0.5, fade * 3.2),
Color(0.3, 0.85, 1.0, fade * p * 0.85))
# Accretion disk — much brighter and denser than SMBH (yellow/white palette)
var ring_r := R + 3.0 + 3.0 * sin(pulse)
for seg in 8:
var sa := pulse * 0.55 + float(seg) / 8.0 * TAU
canvas.draw_arc(cv, ring_r, sa, sa + TAU / 8.0 * 0.8, 6,
Color(1.0, 0.92, 0.3, minf(p * 0.95, 1.0)), 3.5)
for seg in 5:
var sa := -pulse * 0.4 + float(seg) / 5.0 * TAU
canvas.draw_arc(cv, ring_r + 10.0, sa, sa + TAU / 5.0 * 0.7, 5,
Color(1.0, 1.0, 0.75, p * 0.6), 2.0)
# Innermost corona ring
canvas.draw_arc(cv, ring_r - 5.0, 0.0, TAU, 24, Color(1.0, 1.0, 0.9, p * 0.45), 1.5)
# Black core (event horizon — same as BH/SMBH, links the visual family)
canvas.draw_circle(cv, R * 0.55, Color(0.0, 0.0, 0.0, 1.0))
# Inner corona glow
canvas.draw_circle(cv, R * 0.85, Color(1.0, 1.0, 0.8, 0.18 * p))
# ─── WhiteHole ────────────────────────────────────────────────────────────────
class WhiteHole extends RefCounted:
var x: float; var y: float; var radius: float = 16.0
var push_strength: float = 0.9; var push_radius: float = 340.0
var alpha: float = 1.0; var dead: bool = false
var pulse: float = 0.0; var life: float = 0.0
const MAX_LIFE := 60.0; const EJECT_INTERVAL := 3.0
var eject_timer: float = 0.0
func init(px: float, py: float) -> void:
x = px; y = py
func update(delta: float) -> Dictionary:
pulse += delta * 2.5
life += delta
eject_timer += delta
var new_stars: Array = []
var new_planets: Array = []
if eject_timer >= EJECT_INTERVAL:
eject_timer = 0.0
# Eject stars outward in all directions
for _i in randi_range(4, 8):
var s := Star.new()
var eject_angle := randf() * TAU
var eject_dist := randf_range(8.0, 20.0)
s.x = x + cos(eject_angle) * eject_dist
s.y = y + sin(eject_angle) * eject_dist
s.speed = randf_range(0.4, 1.5)
s.size = randf_range(1.0, 3.5)
s.alpha = 0.7 + randf() * 0.3
s.glow = s.size >= 2.5
s.color = Color("#aaddff")
s.twinkle_phase = randf() * TAU
s.twinkle_speed = randf_range(0.5, 2.0)
s.grav_vx = cos(eject_angle) * randf_range(0.3, 1.2)
s.grav_vy = sin(eject_angle) * randf_range(0.3, 1.2)
new_stars.append(s)
# Occasionally eject a planet
if randf() < 0.4:
var p := Planet.new()
p.init(x + randf_range(-25.0, 25.0), y + randf_range(-25.0, 25.0), 960.0, 600.0)
new_planets.append(p)
# Fade out and die after MAX_LIFE (unless a BH consumes it first)
if life > MAX_LIFE:
alpha = max(0.0, alpha - delta * 0.4)
if alpha <= 0.05: dead = true
return {"stars": new_stars, "planets": new_planets}
func push_object(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
var dx := ox - x; var dy := oy - y
var dist := sqrt(dx*dx + dy*dy)
if dist < push_radius and dist > 0.01:
var force := push_strength / (dist * 0.05 + 1.0)
return Vector2(ovx + (dx/dist)*force, ovy + (dy/dist)*force)
return Vector2(ovx, ovy)
func draw(canvas: CanvasItem) -> void:
var p := 0.7 + 0.3 * sin(pulse)
var cv := Vector2(x, y)
# Outer glow
canvas.draw_circle(cv, radius + 8.0, Color(0.5, 0.85, 1.0, alpha * 0.12))
# White core — single draw_circle replaces O(r²) pixel loop
canvas.draw_circle(cv, radius, Color(1.0, 1.0, 1.0, alpha * p))
# Pulsing ring — draw_arc replaces 48 individual draw_rects
var ring_r := radius + 6.0 + 4.0 * sin(pulse)
canvas.draw_arc(cv, ring_r, 0.0, TAU, 32, Color(0.6, 0.9, 1.0, alpha * 0.8), 2.0)
# ─── NeutronStar ──────────────────────────────────────────────────────────────
class NeutronStar extends RefCounted:
var x: float; var y: float; var beam_angle: float = 0.0
var dead: bool = false; var life: float = 0.0; const MAX_LIFE := 40.0
const BEAM_HALF_WIDTH := 0.18; const BEAM_LENGTH := 440.0
const PULL_RADIUS := 120.0; const GRAVITY := 0.6
func init(px: float, py: float) -> void:
x = px; y = py; beam_angle = randf() * TAU
func update(delta: float) -> void:
beam_angle += delta * 1.8
life += delta
if life > MAX_LIFE: dead = true
func in_beam(ox: float, oy: float) -> bool:
var dx := ox - x; var dy := oy - y
var angle := atan2(dy, dx)
var diff := fmod(abs(angle - beam_angle) + PI, TAU) - PI
var diff2 := fmod(abs(angle - beam_angle - PI) + PI, TAU) - PI
return abs(diff) < BEAM_HALF_WIDTH or abs(diff2) < BEAM_HALF_WIDTH
func push_if_in_beam(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
var dx := ox - x; var dy := oy - y
var dist := sqrt(dx*dx + dy*dy)
if dist < BEAM_LENGTH and in_beam(ox, oy):
var force := 1.5 / (dist * 0.05 + 1.0)
return Vector2(ovx + (dx/dist)*force, ovy + (dy/dist)*force)
return Vector2(ovx, ovy)
func draw(canvas: CanvasItem) -> void:
for bd in [0.0, PI]:
var ba: float = beam_angle + float(bd)
for bl in range(0, int(BEAM_LENGTH), 4):
var bx := x + cos(ba) * bl
var by := y + sin(ba) * bl
var ba2 := 1.0 - float(bl) / BEAM_LENGTH
canvas.draw_rect(Rect2(bx, by, 2, 2), Color(0.8, 1.0, 0.8, ba2 * 0.7))
canvas.draw_rect(Rect2(x-3, y-3, 7, 7), Color(0.9, 1.0, 0.9, 1.0))
# ─── Antimatter ───────────────────────────────────────────────────────────────
class Antimatter extends RefCounted:
var x: float; var y: float; var vx: float; var vy: float
var dead: bool = false; var pulse: float = 0.0
func init(px: float, py: float, angle: float, speed: float) -> void:
x = px; y = py
vx = cos(angle) * speed; vy = sin(angle) * speed
pulse = randf() * TAU
func update(world_w: float, world_h: float, delta: float, nearby: Array = []) -> void:
pulse += delta * 5.0
# Magnetic attraction to nearby antimatter particles
const ATTRACT_RADIUS := 60.0
const ATTRACT_FORCE := 0.008
for other in nearby:
if other == self: continue
var dx: float = float(other.x) - x
var dy: float = float(other.y) - y
var d: float = sqrt(dx*dx + dy*dy)
if d < ATTRACT_RADIUS and d > 1.0:
vx += (dx / d) * ATTRACT_FORCE
vy += (dy / d) * ATTRACT_FORCE
# Clamp speed so clustering doesn't cause runaway velocity
var spd := sqrt(vx*vx + vy*vy)
if spd > 1.8:
vx = vx / spd * 1.8
vy = vy / spd * 1.8
x += vx; y += vy
if x < 0: x += world_w
elif x > world_w: x -= world_w
if y < 0: y += world_h
elif y > world_h: y -= world_h
func draw(canvas: CanvasItem) -> void:
var p := 0.7 + 0.3 * sin(pulse)
canvas.draw_rect(Rect2(x-3, y-3, 7, 7), Color(1.0, 0.2, 0.8, p * 0.3))
canvas.draw_rect(Rect2(x-1, y-1, 3, 3), Color(1.0, 0.2, 0.8, p))
canvas.draw_rect(Rect2(x, y, 1, 1), Color(1.0, 0.8, 1.0, p * 0.9))
# ─── Galaxy ───────────────────────────────────────────────────────────────────
class Galaxy extends RefCounted:
var x: float; var y: float; var angle: float = 0.0
var rotation_speed: float = 0.3 # rad/s, increases during consumption
var radius: float; var vx: float = 0.0; var vy: float = 0.0
var color: Color; var num_arms: int = 3
var star_points: Array = []
var dead: bool = false; var alpha: float = 1.0
var respawn_timer: float = 0.0; var respawning: bool = false
# Gradual consumption (drifting into a BH)
var being_consumed: bool = false
var consume_timer: float = 0.0
const CONSUME_DURATION := 2.5
var consume_bh_x: float = 0.0
var consume_bh_y: float = 0.0
var consuming_bh = null # BlackHole ref — set by game_world
var consume_initial_radius: float = 0.0
var consume_initial_alpha: float = 0.0
var original_color: Color = Color.WHITE
func init(px: float, py: float) -> void:
x = px; y = py
vx = randf_range(-0.08, 0.08) * 2.4 # original ±0.08 * scale
vy = randf_range(-0.05, 0.05) * 2.4
radius = randf_range(35.0, 65.0) * 2.4 / 2.0 # scale to 2.4x but not full
angle = randf() * TAU
rotation_speed = randf_range(0.001, 0.003) * 60.0 # rad/s
num_arms = 2 if randf() < 0.4 else 3
var gal_colors := ["#ffeeaa","#aaddff","#ffaacc","#aaffcc","#ddaaff"]
color = Color(gal_colors[randi() % gal_colors.size()])
alpha = randf_range(0.25, 0.55)
_generate_stars()
func _generate_stars() -> void:
star_points.clear()
for arm in num_arms:
var arm_offset := (TAU / num_arms) * arm
for i in 26:
var t := float(i) / 25.0
var a := angle + arm_offset + t * 3.5
var r := 4.0 + t * (radius - 4.0)
var scatter := randf_range(-6.0, 6.0)
star_points.append(Vector2(cos(a)*r + scatter, sin(a)*r + scatter))
func update(delta: float, world_w: float, world_h: float) -> void:
if being_consumed:
consume_timer += delta
var t: float = clampf(consume_timer / CONSUME_DURATION, 0.0, 1.0)
# Drift toward BH with accelerating speed
var target_dx: float = consume_bh_x - x
var target_dy: float = consume_bh_y - y
var target_dist: float = sqrt(target_dx * target_dx + target_dy * target_dy)
if target_dist > 2.0:
var drift_speed: float = 30.0 * t * t
x += (target_dx / target_dist) * drift_speed * delta
y += (target_dy / target_dist) * drift_speed * delta
# Spin accelerates as it's consumed
angle += rotation_speed * delta * (1.0 + t * 8.0)
# Compress radius — tidal compression
radius = maxf(3.0, consume_initial_radius * (1.0 - t * 0.85))
# Regenerate star points with compressed radius periodically
if int(consume_timer * 5.0) > int((consume_timer - delta) * 5.0):
_generate_stars()
# Color warms toward orange/white (accretion heating)
color = original_color.lerp(Color(1.0, 0.7, 0.3), t * 0.6)
# Alpha fades in final 40%
if t > 0.6:
alpha = maxf(0.0, consume_initial_alpha * (1.0 - (t - 0.6) / 0.4))
if consume_timer >= CONSUME_DURATION or alpha <= 0.0:
dead = true
return
angle += rotation_speed * delta
x += vx; y += vy
# Screen wrap
if x < -radius: x += world_w + radius*2.0
elif x > world_w + radius: x -= world_w + radius*2.0
if y < -radius: y += world_h + radius*2.0
elif y > world_h + radius: y -= world_h + radius*2.0
if respawning:
respawn_timer -= delta
if respawn_timer <= 0.0:
respawning = false; dead = false; alpha = randf_range(0.25, 0.55)
being_consumed = false; consume_timer = 0.0; consuming_bh = null
radius = randf_range(35.0, 65.0) * 2.4 / 2.0
rotation_speed = randf_range(0.001, 0.003) * 60.0
x = randf() * world_w; y = randf() * world_h
angle = randf() * TAU
num_arms = 2 if randf() < 0.4 else 3
_generate_stars()
func draw(canvas: CanvasItem) -> void:
if dead or respawning: return
var cos_a := cos(angle); var sin_a := sin(angle)
# Core halo
canvas.draw_rect(Rect2(x - 5.0, y - 5.0, 11.0, 11.0),
Color(color.r, color.g, color.b, alpha * 0.3))
# Spiral arms
for pt in star_points:
var rx: float = x + float(pt.x) * cos_a - float(pt.y) * sin_a
var ry: float = y + float(pt.x) * sin_a + float(pt.y) * cos_a
var dot_size := 2.0 if abs(float(pt.x)) < radius * 0.3 else 1.0
canvas.draw_rect(Rect2(rx, ry, dot_size, 1.0),
Color(color.r, color.g, color.b, alpha * 0.75))
# Bright core
canvas.draw_rect(Rect2(x - 2.0, y - 2.0, 5.0, 5.0),
Color(color.r, color.g, color.b, alpha * 0.6))
canvas.draw_rect(Rect2(x - 1.0, y - 1.0, 3.0, 3.0),
Color(1.0, 1.0, 1.0, alpha * 0.9))
# ─── AntimatterStar ───────────────────────────────────────────────────────────
class AntimatterStar extends RefCounted:
const MAX_LIFE := 50.0
const REPEL_RADIUS := 90.0
const REPEL_FORCE := 0.055
var x: float; var y: float
var radius: float = 18.0
var life: float = MAX_LIFE
var pulse: float = 0.0
var dead: bool = false
func init(px: float, py: float) -> void:
x = px; y = py
pulse = randf() * TAU
func update(delta: float) -> void:
life -= delta
pulse += delta * 3.0
if life <= 0.0:
dead = true
func apply_repulsion(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
var dx := ox - x; var dy := oy - y
var d := sqrt(dx*dx + dy*dy)
if d < REPEL_RADIUS and d > 0.01:
var force := REPEL_FORCE * (1.0 - d / REPEL_RADIUS)
return Vector2(ovx + (dx / d) * force, ovy + (dy / d) * force)
return Vector2(ovx, ovy)
func draw(canvas: CanvasItem) -> void:
var p: float = 0.6 + 0.4 * sin(pulse)
var alpha: float = clamp(life / MAX_LIFE, 0.0, 1.0)
var cv := Vector2(x, y)
# Outer magenta glow
canvas.draw_circle(cv, radius + 8.0, Color(0.75, 0.0, 0.9, p * 0.10 * alpha))
canvas.draw_circle(cv, radius + 3.0, Color(0.6, 0.0, 0.85, p * 0.18 * alpha))
# Dark antimatter core
canvas.draw_circle(cv, radius, Color(0.15, 0.0, 0.22, alpha))
# Rotating ring
var ring_r := radius + 5.0 + 2.0 * sin(pulse)
canvas.draw_arc(cv, ring_r, pulse * 0.7, pulse * 0.7 + TAU * 0.8, 14,
Color(1.0, 0.2, 1.0, p * 0.85 * alpha), 2.0)
canvas.draw_arc(cv, ring_r - 3.0, -pulse * 0.5, -pulse * 0.5 + TAU * 0.6, 10,
Color(0.8, 0.0, 1.0, p * 0.5 * alpha), 1.5)
# Bright centre dot
canvas.draw_circle(cv, 3.0, Color(1.0, 0.5, 1.0, alpha * p))
+1
View File
@@ -0,0 +1 @@
uid://j7pdrterps08
+248
View File
@@ -0,0 +1,248 @@
extends RefCounted
class_name EnemyShip
const THRUST := 0.19
const DRAG := 0.988
const MAX_SPEED := 4.8
const TRAIL_LEN := 16
const ATTACK_RANGE := 600.0
const FIRE_INTERVAL := 90
const RESPAWN_MIN := 4.0
const RESPAWN_MAX := 8.0
const SEP_RADIUS := 110.0 # anti-clump separation distance
const ORBIT_RADIUS := 180.0 # circle-strafe orbit distance
# Three attack roles that prevent enemies from all rushing from the same angle.
enum Role { AGGRO, CIRCLE, FLANK }
var x: float; var y: float
var vx: float = 0.0; var vy: float = 0.0
var heading: float = 0.0
var trail: Array = []
var dead: bool = false
var respawn_timer: float = 0.0
var fire_timer: int = 0
var enemy_index: int = 0 # 0=red, 1=cyan (colour variant)
var role_id: int = 0 # sequential id, persists across respawns
var role: int = Role.AGGRO
var orbit_offset: float = 0.0
var patrol_timer: int = 0
var patrol_target: Vector2 = Vector2.ZERO
var stats: ShipStats = null
var current_shields: int = 0
var invuln_timer: int = 0
func apply_stats(s: ShipStats) -> void:
stats = s
current_shields = s.shield_charges
# Hull offsets ×2, rendered as 3×3 rects
const HULL_PIXELS := [
[6, 0, "nose"],
[4, -2, "mid"], [4, 2, "mid"],
[2, 0, "dim"],
[0, -4, "accent"],[0, 4, "accent"],
[0, 0, "bright"],
[-2,-2, "dim"], [-2, 2, "dim"],
[-4,-4, "shadow"],[-4, 4, "shadow"],
[-4, 0, "edge"],
]
var palette: Dictionary
func init(px: float, py: float, idx: int, rid: int = 0) -> void:
x = px; y = py
enemy_index = idx
role_id = rid
role = rid % 3
# Each role_id starts at a different point on the orbit so enemies spread out
orbit_offset = float(rid) * (TAU / 3.0)
dead = false
respawn_timer = 0.0
# Stagger fire timers so enemies don't all shoot at the same frame
fire_timer = FIRE_INTERVAL + rid * 17
patrol_target = Vector2(px + randf_range(-250.0, 250.0), py + randf_range(-250.0, 250.0))
patrol_timer = randi_range(60, 180)
invuln_timer = 60
if stats: current_shields = stats.shield_charges
if idx == 0:
palette = {
"nose": Color("#ff8888"), "bright": Color("#ff4444"), "mid": Color("#cc2222"),
"dim": Color("#882222"), "accent": Color("#ffaaaa"), "edge": Color("#441111"),
"shadow": Color("#220000"), "trail": Color(1.0, 0.2, 0.2, 0.25),
"thrustHot": Color("#ffcc44"), "thrustCool": Color(1.0, 0.4, 0.0, 0.5)
}
else:
palette = {
"nose": Color("#88ffff"), "bright": Color("#44ccff"), "mid": Color("#2288cc"),
"dim": Color("#224488"), "accent": Color("#aaccff"), "edge": Color("#112244"),
"shadow": Color("#001122"), "trail": Color(0.2, 0.8, 1.0, 0.25),
"thrustHot": Color("#aaffcc"), "thrustCool": Color(0.2, 0.8, 1.0, 0.5)
}
# enemies array is passed in so each enemy can compute separation from allies.
func update(players: Array, black_holes: Array, enemies: Array, world_w: float, world_h: float, delta: float) -> Bullet:
if dead:
respawn_timer -= delta
return null
if invuln_timer > 0:
invuln_timer -= 1
var eff_thrust: float = THRUST * (stats.speed_mult if stats else 1.0)
var eff_max: float = MAX_SPEED * (stats.speed_mult if stats else 1.0)
var eff_turn: float = 0.1 * (stats.turn_mult if stats else 1.0)
var eff_interval: int = max(10, int(float(FIRE_INTERVAL) / (stats.fire_rate_mult if stats else 1.0)))
# Find nearest living player
var nearest_player = null
var nearest_dist := INF
for p in players:
if p.dead: continue
var dx: float = float(p.x) - x
var dy: float = float(p.y) - y
var d: float = sqrt(dx*dx + dy*dy)
if d < nearest_dist:
nearest_dist = d
nearest_player = p
# Black-hole avoidance — steer away when too close to pull radius
var avoid_vx := 0.0; var avoid_vy := 0.0
for bh in black_holes:
var dx: float = x - float(bh.x)
var dy: float = y - float(bh.y)
var d: float = sqrt(dx*dx + dy*dy)
if d < bh.pull_radius * 1.3 and d > 0.01:
avoid_vx += (dx / d) * 3.0
avoid_vy += (dy / d) * 3.0
# Separation force — push away from nearby allies to prevent clumping
var sep_vx := 0.0; var sep_vy := 0.0
for en in enemies:
var oe: EnemyShip = en
if oe == self or oe.dead: continue
var dx: float = x - oe.x
var dy: float = y - oe.y
var d: float = sqrt(dx*dx + dy*dy)
if d < SEP_RADIUS and d > 0.01:
var strength: float = (SEP_RADIUS - d) / SEP_RADIUS
sep_vx += (dx / d) * strength * 2.0
sep_vy += (dy / d) * strength * 2.0
# Role-based movement
if nearest_player != null and nearest_dist < ATTACK_RANGE:
var pdx: float = float(nearest_player.x) - x
var pdy: float = float(nearest_player.y) - y
_move_attack(pdx, pdy, eff_thrust, eff_turn)
else:
_move_patrol(eff_thrust, eff_turn, world_w, world_h)
vx += (avoid_vx + sep_vx) * 0.05
vy += (avoid_vy + sep_vy) * 0.05
vx *= DRAG; vy *= DRAG
var spd := sqrt(vx*vx + vy*vy)
if spd > eff_max:
vx = vx / spd * eff_max
vy = vy / spd * eff_max
trail.push_front(Vector2(x, y))
if trail.size() > TRAIL_LEN: trail.pop_back()
x += vx; y += vy
if x < 0: x += world_w
elif x > world_w: x -= world_w
if y < 0: y += world_h
elif y > world_h: y -= world_h
fire_timer -= 1
if fire_timer <= 0 and nearest_player != null and nearest_dist < ATTACK_RANGE:
fire_timer = eff_interval
# Aim toward player with distance-based spread:
# accurate at close range (~0.08 rad), less so at max range (~0.32 rad).
var fdx: float = float(nearest_player.x) - x
var fdy: float = float(nearest_player.y) - y
var spread: float = lerp(0.08, 0.32, clamp(nearest_dist / ATTACK_RANGE, 0.0, 1.0))
var fire_angle: float = atan2(fdy, fdx) + randf_range(-spread, spread)
var b := Bullet.new()
b.init(x + cos(fire_angle)*8.0, y + sin(fire_angle)*8.0, fire_angle, "enemy")
if stats:
b.vx *= stats.bullet_speed_mult
b.vy *= stats.bullet_speed_mult
b.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult
return b
return null
# Role.AGGRO: rush straight at player.
# Role.CIRCLE: maintain orbit around player at ORBIT_RADIUS.
# Role.FLANK: approach from a perpendicular angle left or right.
func _move_attack(pdx: float, pdy: float, eff_thrust: float, eff_turn: float) -> void:
match role:
Role.AGGRO:
var ta := atan2(pdy, pdx)
var ad := fmod(ta - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn, eff_turn)
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
Role.CIRCLE:
orbit_offset += 0.012
# Target = player position + point on orbit circle
var player_x: float = x + pdx
var player_y: float = y + pdy
var orbit_x: float = player_x + cos(orbit_offset) * ORBIT_RADIUS
var orbit_y: float = player_y + sin(orbit_offset) * ORBIT_RADIUS
var tdx: float = orbit_x - x
var tdy: float = orbit_y - y
var ta: float = atan2(tdy, tdx)
var ad: float = fmod(ta - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn * 1.2, eff_turn * 1.2)
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
Role.FLANK:
# Approach from +90° or 90° relative to the direct line to player
var direct: float = atan2(pdy, pdx)
var flank_side: float = PI * 0.5 if (role_id % 2 == 0) else -PI * 0.5
var player_x: float = x + pdx
var player_y: float = y + pdy
var flank_x: float = player_x + cos(direct + flank_side) * 80.0
var flank_y: float = player_y + sin(direct + flank_side) * 80.0
var tdx: float = flank_x - x
var tdy: float = flank_y - y
var ta: float = atan2(tdy, tdx)
var ad: float = fmod(ta - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn, eff_turn)
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
func _move_patrol(eff_thrust: float, eff_turn: float, world_w: float, world_h: float) -> void:
patrol_timer -= 1
var to_target := Vector2(patrol_target.x - x, patrol_target.y - y)
# Pick a new target when close enough or timer expires → spreads enemies across map
if patrol_timer <= 0 or to_target.length() < 40.0:
patrol_target = Vector2(
randf_range(world_w * 0.1, world_w * 0.9),
randf_range(world_h * 0.1, world_h * 0.9))
patrol_timer = randi_range(120, 300)
var angle: float = atan2(to_target.y, to_target.x)
var ad: float = fmod(angle - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn * 0.8, eff_turn * 0.8)
vx += cos(heading) * eff_thrust * 0.6
vy += sin(heading) * eff_thrust * 0.6
func draw(canvas: CanvasItem) -> void:
if dead: return
var cos_h := cos(heading); var sin_h := sin(heading)
for i in trail.size():
var t_alpha := (1.0 - float(i) / float(TRAIL_LEN)) * 0.5
var tc := Color(palette["trail"].r, palette["trail"].g, palette["trail"].b, t_alpha)
canvas.draw_rect(Rect2(trail[i].x - 1, trail[i].y - 1, 2, 2), tc)
for px_data in HULL_PIXELS:
var lx: float = px_data[0]
var ly: float = px_data[1]
var col: Color = palette.get(px_data[2], Color.WHITE)
var rx := x + lx * cos_h - ly * sin_h
var ry := y + lx * sin_h + ly * cos_h
canvas.draw_rect(Rect2(rx - 1, ry - 1, 3, 3), col)
+1
View File
@@ -0,0 +1 @@
uid://fycowvc7jmfy
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://dgjwy8o1rgfd7
+101
View File
@@ -0,0 +1,101 @@
extends CanvasLayer
const W := 400.0
const H := 300.0
var countdown_value: int = 0
var show_countdown_flag: bool = false
var score_data: Dictionary = {}
var is_gameover: bool = false
var blink_phase: float = 0.0
var gameover_blink: bool = true
var gameover_blink_timer: float = 0.0
var is_returning: bool = false
var wipe_warning: bool = false
var credits: int = 0
var lives: int = 3
var wave_number: int = 1
var wave_cleared: bool = false
# References to game_world for live data
var game_world: Node = null
# Name entry after game over
var awaiting_name: bool = false
var name_input: String = ""
var name_saved: bool = false
var _name_saved_timer: float = 0.0
var _pending_score: int = 0
var _pending_wave: int = 0
var _online_lb: Node = null
func _ready() -> void:
var hud_draw := Node2D.new()
hud_draw.set_script(load("res://scripts/hud_draw.gd"))
hud_draw.name = "HUDDraw"
add_child(hud_draw)
hud_draw.hud = self
_online_lb = load("res://scripts/online_leaderboard.gd").new()
add_child(_online_lb)
func show_countdown(val: int) -> void:
show_countdown_flag = true
countdown_value = val
func show_gameover(data: Dictionary) -> void:
score_data = data
is_gameover = true
is_returning = false
var total := int(data.get("p1_time", 0.0)) + int(data.get("p1_kills", 0)) * 50 + int(data.get("p1_wipe", 0))
if data.get("multiplayer", false):
total += int(data.get("p2_time", 0.0)) + int(data.get("p2_kills", 0)) * 50 + int(data.get("p2_wipe", 0))
_pending_score = total
_pending_wave = wave_number
if total > 0:
awaiting_name = true
name_input = ""
name_saved = false
func _confirm_name() -> void:
var n := name_input.strip_edges()
if n.length() == 0:
return
Leaderboard.add_score(n, _pending_score, _pending_wave)
if _online_lb:
_online_lb.submit_score(n, _pending_score, _pending_wave)
name_saved = true
_name_saved_timer = 0.0
awaiting_name = false
func _unhandled_input(event: InputEvent) -> void:
if not awaiting_name:
return
if not (event is InputEventKey and (event as InputEventKey).pressed):
return
var phk := (event as InputEventKey).physical_keycode
if phk == KEY_BACKSPACE or phk == KEY_DELETE:
if name_input.length() > 0:
name_input = name_input.left(name_input.length() - 1)
elif phk == KEY_ENTER or phk == KEY_KP_ENTER:
if name_input.strip_edges().length() > 0:
_confirm_name()
elif phk == KEY_ESCAPE:
awaiting_name = false
elif name_input.length() < 12:
var uc := (event as InputEventKey).unicode
if uc >= 32 and uc < 127:
name_input += char(uc)
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
blink_phase += delta
gameover_blink_timer += delta
if gameover_blink_timer >= 0.5:
gameover_blink_timer = 0.0
gameover_blink = not gameover_blink
if name_saved:
_name_saved_timer += delta
if _name_saved_timer > 1.5:
name_saved = false
if game_world and game_world.big_wipe:
wipe_warning = game_world.big_wipe.is_active() and game_world.big_wipe.phase == BigWipe.Phase.COLLAPSE
+1
View File
@@ -0,0 +1 @@
uid://kn3e6fiywxr7
+232
View File
@@ -0,0 +1,232 @@
extends Node2D
# Minimal cockpit HUD — maximum screen space for the game.
# During play: only 4 corner L-brackets + tiny top-right status line.
# On gameover: full terminal overlay.
var W: float = 960.0
var H: float = 600.0
# Cockpit palette
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 0.55) # phosphor green, semi-dim
const COL_ACCENT := Color(0.27, 1.0, 0.8, 0.80)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.45)
const COL_WARNING := Color(1.0, 0.67, 0.0, 0.90)
const COL_DANGER := Color(1.0, 0.2, 0.0, 0.90)
const COL_WHITE := Color(1.0, 1.0, 1.0, 0.90)
const BRACKET_LEN := 14.0
const BRACKET_W := 1.5
const MARGIN := 10.0
var hud: Node = null
func _process(_delta: float) -> void:
queue_redraw()
func _draw() -> void:
if hud == null:
return
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
var font := ThemeDB.fallback_font
var gw: Node = hud.game_world
# ── Countdown ────────────────────────────────────────────────────────────
if hud.show_countdown_flag and hud.countdown_value > 0:
_draw_corner_brackets()
var ct := str(hud.countdown_value)
_draw_text_centered(font, ct, W * 0.5, H * 0.5 - 36.0, 64, COL_ACCENT)
_draw_text_centered(font, Tr.t("hud_launching"), W * 0.5, H * 0.5 + 28.0, 10, COL_DIM)
return
if not hud.is_gameover and gw == null:
return
# ── Normal gameplay HUD ──────────────────────────────────────────────────
if not hud.is_gameover and gw != null:
_draw_corner_brackets()
# ── Wave timer bar ────────────────────────────────────────────────
var wave_dur: float = float(gw.get("wave_duration") if gw.get("wave_duration") != null else 60.0)
var wave_t: float = float(gw.get("wave_timer") if gw.get("wave_timer") != null else 0.0)
var wave_frac: float = clamp(wave_t / wave_dur, 0.0, 1.0)
var wave_remaining: float = max(0.0, wave_dur - wave_t)
var last_10: bool = wave_remaining <= 10.0 and gw.get("is_playing")
# Thin bar at top — shrinks from right as time passes
var bar_col: Color
if last_10:
bar_col = Color(COL_WARNING.r, COL_WARNING.g, COL_WARNING.b,
0.55 + 0.45 * sin(hud.blink_phase * 8.0))
else:
bar_col = Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.35)
draw_rect(Rect2(0.0, 0.0, W * (1.0 - wave_frac), 2.0), bar_col)
# Wave label top-left: "W1 0:42"
var w_mins: int = int(wave_remaining / 60.0)
var w_secs: int = int(wave_remaining) % 60
var wave_label := "W%d %d:%02d" % [hud.wave_number, w_mins, w_secs]
var wlabel_col: Color = COL_WARNING if last_10 else COL_DIM
_draw_text(font, wave_label, MARGIN, 10.0, 9, wlabel_col)
# ── Wave cleared overlay ──────────────────────────────────────────
if hud.wave_cleared:
var wc_a: float = 0.7 + 0.3 * sin(hud.blink_phase * 5.0)
_draw_text_centered(font, Tr.t("hud_wave_clear") % (hud.wave_number - 1),
W * 0.5, H * 0.5 - 22.0, 16, Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, wc_a))
_draw_text_centered(font, Tr.t("hud_to_shop"), W * 0.5, H * 0.5 + 8.0, 11,
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, wc_a * 0.7))
# Top-right micro status — lives dots + credits + time + kills
var players: Array = gw.players
var lives_str := ""
for li in 3:
lives_str += ("" if li < hud.lives else "")
var cr_str := "CR:%d" % hud.credits
if not gw.is_multiplayer:
if players.size() > 0:
var p = players[0]
var mins: int = int(p.survival_time / 60.0)
var secs: int = int(p.survival_time) % 60
var time_str := "%02d:%02d" % [mins, secs]
var shields_str := ""
for _si in p.current_shields:
shields_str += ""
var status_str := "%s %s %s %s x%d" % [lives_str, cr_str, shields_str, time_str, p.kills]
_draw_text(font, status_str, W - 200.0, 10.0, 9, COL_PRIMARY)
else:
if players.size() >= 1:
var p1 = players[0]
var t1 := "%02d:%02d" % [int(p1.survival_time / 60.0), int(p1.survival_time) % 60]
_draw_text(font, "P1 %s x%d %s" % [t1, p1.kills, cr_str], 10.0, 10.0, 9, COL_PRIMARY)
if players.size() >= 2:
var p2 = players[1]
var t2 := "%02d:%02d" % [int(p2.survival_time / 60.0), int(p2.survival_time) % 60]
_draw_text(font, "P2 %s x%d" % [t2, p2.kills], W - 110.0, 10.0, 9,
Color(0.27, 1.0, 0.67, 0.55))
# ── Boost-Cooldown-Leisten (nur Schiffe mit has_boost) ────────────
for player in players:
var sp: Spaceship = player
if sp == null or sp.dead: continue
if sp.stats == null or not sp.stats.has_boost: continue
var ratio: float = sp.boost_ratio()
var bar_w := 40.0; var bar_h := 4.0
var bx2: float = sp.x - bar_w * 0.5
var by2: float = sp.y + 18.0
# Background
draw_rect(Rect2(bx2, by2, bar_w, bar_h), Color(0.0, 0.04, 0.02, 0.7))
# Fill — remaining cooldown shown as shrinking bar (ratio 1 = full cd, 0 = ready)
var fill_w: float = bar_w * (1.0 - ratio)
var is_ready: bool = ratio <= 0.0
var fill_col: Color
if is_ready:
fill_col = Color(1.0, 0.9, 0.3, 0.7 + 0.3 * sin(hud.blink_phase * 6.0))
else:
fill_col = Color(COL_WARNING.r, COL_WARNING.g, COL_WARNING.b, 0.75)
draw_rect(Rect2(bx2, by2, fill_w, bar_h), fill_col)
# Border
draw_rect(Rect2(bx2, by2, bar_w, bar_h),
Color(COL_DIM.r, COL_DIM.g, COL_DIM.b, 0.7), false, 1.0)
# Big wipe warning — bold centred flash, no decoration
if hud.wipe_warning:
if sin(hud.blink_phase * 8.0) > 0.0:
_draw_text_centered(font, Tr.hint("hud_wipe_key"), W * 0.5, H * 0.5 - 8.0, 13, COL_WARNING)
return
# ── Gameover / Returning ─────────────────────────────────────────────────
if hud.is_gameover or hud.is_returning:
# Dim overlay
draw_rect(get_viewport_rect(), Color(0.0, 0.0, 0.04, 0.62))
var sd: Dictionary = hud.score_data
var p1_time: float = sd.get("p1_time", 0.0)
var p1_kills: int = sd.get("p1_kills", 0)
var total: int = int(p1_time) + p1_kills * 50 + int(sd.get("p1_wipe", 0))
if sd.get("multiplayer", false):
total += int(sd.get("p2_time", 0.0)) + int(sd.get("p2_kills", 0)) * 50 + int(sd.get("p2_wipe", 0))
# Central terminal box — taller when asking for name
var bx := W * 0.5 - 160.0
var by := H * 0.5 - 100.0
var bw := 320.0
var bh2 := 215.0 if hud.awaiting_name else 200.0
_draw_terminal_box(bx, by, bw, bh2, COL_PRIMARY)
_draw_text_centered(font, Tr.t("hud_mission_over"), W * 0.5, by + 22.0, 12, COL_ACCENT)
_draw_hline(bx + 16.0, bx + bw - 16.0, by + 42.0, COL_DIM)
_draw_text_centered(font, Tr.t("hud_score") % total, W * 0.5, by + 60.0, 18, COL_WHITE)
var mins1: int = int(p1_time / 60.0)
var secs1: int = int(p1_time) % 60
_draw_text_centered(font, "P1 %02d:%02d KILLS %d" % [mins1, secs1, p1_kills],
W * 0.5, by + 92.0, 10, COL_PRIMARY)
if sd.get("multiplayer", false):
var p2t: float = sd.get("p2_time", 0.0)
var mins2: int = int(p2t / 60.0); var secs2: int = int(p2t) % 60
_draw_text_centered(font, "P2 %02d:%02d KILLS %d" % [mins2, secs2, sd.get("p2_kills", 0)],
W * 0.5, by + 112.0, 10, Color(0.27, 1.0, 0.67, 0.8))
_draw_hline(bx + 16.0, bx + bw - 16.0, by + 135.0, COL_DIM)
if hud.is_returning:
_draw_text_centered(font, Tr.t("hud_transfer"), W * 0.5, by + 155.0, 10, COL_WARNING)
elif hud.awaiting_name:
_draw_text_centered(font, Tr.t("lb_enter_name"), W * 0.5, by + 148.0, 10, COL_DIM)
var cursor_vis: bool = fmod(hud.blink_phase, 1.0) < 0.6
var field_str: String = hud.name_input + ("_" if cursor_vis else " ")
var display_str: String = field_str if field_str.strip_edges().length() > 0 else "_"
_draw_text_centered(font, display_str, W * 0.5, by + 163.0, 13, COL_ACCENT)
_draw_text_centered(font, Tr.hint("lb_name_hint"), W * 0.5, by + 186.0, 8, COL_DIM)
elif hud.name_saved:
_draw_text_centered(font, Tr.t("lb_saved"), W * 0.5, by + 155.0, 11, COL_ACCENT)
elif hud.gameover_blink:
_draw_text_centered(font, Tr.hint("hud_press_key"), W * 0.5, by + 155.0, 11, COL_ACCENT)
# ── Drawing helpers ───────────────────────────────────────────────────────────
func _draw_corner_brackets() -> void:
# Four L-shaped corner brackets as the only permanent chrome
var m := MARGIN
var bl := BRACKET_LEN
var bw := BRACKET_W
# Top-left
draw_line(Vector2(m, m), Vector2(m + bl, m), COL_DIM, bw)
draw_line(Vector2(m, m), Vector2(m, m + bl), COL_DIM, bw)
# Top-right
draw_line(Vector2(W-m, m), Vector2(W-m-bl, m), COL_DIM, bw)
draw_line(Vector2(W-m, m), Vector2(W-m, m+bl), COL_DIM, bw)
# Bottom-left
draw_line(Vector2(m, H-m), Vector2(m+bl, H-m), COL_DIM, bw)
draw_line(Vector2(m, H-m), Vector2(m, H-m-bl), COL_DIM, bw)
# Bottom-right
draw_line(Vector2(W-m, H-m), Vector2(W-m-bl, H-m), COL_DIM, bw)
draw_line(Vector2(W-m, H-m), Vector2(W-m, H-m-bl), COL_DIM, bw)
func _draw_terminal_box(bx: float, by: float, bw: float, bh2: float, col: Color) -> void:
# Filled dark panel
draw_rect(Rect2(bx, by, bw, bh2), Color(0.0, 0.04, 0.02, 0.88))
# Border lines
var bc := Color(col.r, col.g, col.b, col.a * 0.6)
draw_line(Vector2(bx, by), Vector2(bx+bw, by), bc, 1.0)
draw_line(Vector2(bx, by+bh2), Vector2(bx+bw, by+bh2), bc, 1.0)
draw_line(Vector2(bx, by), Vector2(bx, by+bh2), bc, 1.0)
draw_line(Vector2(bx+bw, by), Vector2(bx+bw, by+bh2), bc, 1.0)
# Corner L-brackets (bright)
var cl := 10.0
for cx in [bx, bx+bw]:
for cy in [by, by+bh2]:
var sx := 1.0 if cx == bx else -1.0
var sy := 1.0 if cy == by else -1.0
draw_line(Vector2(cx, cy), Vector2(cx + sx*cl, cy), col, 1.5)
draw_line(Vector2(cx, cy), Vector2(cx, cy + sy*cl), col, 1.5)
func _draw_hline(x1: float, x2: float, y: float, col: Color) -> void:
draw_line(Vector2(x1, y), Vector2(x2, y), col, 1.0)
func _draw_text(font: Font, text: String, x: float, y: float, size: int, col: Color) -> void:
draw_string(font, Vector2(x, y + size), text, HORIZONTAL_ALIGNMENT_LEFT, -1, size, col)
func _draw_text_centered(font: Font, text: String, x: float, y: float, size: int, col: Color) -> void:
var tw := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
draw_string(font, Vector2(x - tw * 0.5, y + size), text, HORIZONTAL_ALIGNMENT_LEFT, -1, size, col)
+1
View File
@@ -0,0 +1 @@
uid://dhqnupvfv7tnt
+176
View File
@@ -0,0 +1,176 @@
extends RefCounted
class_name ItemDB
# ─── Legacy-System (alter Shop / Enemy-Upgrades in game_world.gd) ──────────────
# Bleibt unverändert für Backward-Compat. Wird nur von game_world.gd für
# Enemy-Stat-Boosts ab Welle 10 verwendet.
enum Rarity { COMMON, UNCOMMON, RARE, EPIC }
const RARITY_WEIGHTS: Array = [60, 25, 12, 3]
const RARITY_COLORS: Dictionary = {
0: Color("#aaaaaa"), # COMMON
1: Color("#44ff88"), # UNCOMMON
2: Color("#4488ff"), # RARE
3: Color("#cc44ff") # EPIC
}
const RARITY_NAMES: Dictionary = {
0: "STANDARD",
1: "SELTEN",
2: "EPISCH",
3: "LEGENDÄR"
}
const ITEMS: Array = [
{
"id": "thrust_1", "name": "Schubverstärker", "rarity": 0, "cost": 50,
"desc": "+12% Geschwindigkeit",
"effects": { "speed_mult": 1.12 }
},
{
"id": "firerate_1", "name": "Schnellfeuer I", "rarity": 0, "cost": 50,
"desc": "+15% Feuerrate",
"effects": { "fire_rate_mult": 1.15 }
},
{
"id": "damage_1", "name": "Schwere Ladung I", "rarity": 0, "cost": 50,
"desc": "+15% Trefferzone",
"effects": { "damage_mult": 1.15 }
},
{
"id": "shield_1", "name": "Panzerplatte", "rarity": 0, "cost": 55,
"desc": "+1 Schutzladung",
"effects": { "shield_charges": 1 }
},
{
"id": "firerate_2", "name": "Schnellfeuer II", "rarity": 1, "cost": 90,
"desc": "+22% Feuerrate",
"effects": { "fire_rate_mult": 1.22 }
},
{
"id": "damage_2", "name": "Schwere Ladung II", "rarity": 1, "cost": 100,
"desc": "+22% Trefferzone +10% Projektilgeschwindigkeit",
"effects": { "damage_mult": 1.22, "bullet_speed_mult": 1.10 }
},
]
# Weighted random roll — Legacy-Shop (wird nicht mehr für Werkstatt verwendet)
static func roll_shop(count: int, _exclude_ids: Array = []) -> Array:
var pool: Array = []
for item: Dictionary in ITEMS:
var w: int = RARITY_WEIGHTS[int(item["rarity"])]
for _i in w:
pool.append(item)
pool.shuffle()
var result: Array = []
var seen_ids: Dictionary = {}
for item: Dictionary in pool:
var iid: String = item["id"]
if iid in seen_ids: continue
seen_ids[iid] = true
result.append(item)
if result.size() >= count:
break
return result
# ─── Werkstatt-System (Plugin-basiert) ─────────────────────────────────────────
# Auto-Discovery: alle *.gd unter res://items/ die ItemDef erweitern werden
# beim ersten Zugriff registriert.
static var _registry: Array = []
static var _registry_loaded: bool = false
# Static preload list — DirAccess doesn't work in exported builds (compiled scripts
# aren't raw files in the PCK). preload() is resolved at compile time and always works.
const _ITEM_SCRIPTS: Array = [
preload("res://items/weapons/wk_burst.gd"),
preload("res://items/weapons/wk_charge.gd"),
preload("res://items/weapons/wk_ion.gd"),
preload("res://items/weapons/wk_laser.gd"),
preload("res://items/weapons/wk_plasma.gd"),
preload("res://items/weapons/wk_rail.gd"),
preload("res://items/weapons/wk_scatter.gd"),
preload("res://items/weapons/wk_shotgun.gd"),
preload("res://items/weapons/wk_sniper.gd"),
preload("res://items/hull/hull_giant.gd"),
preload("res://items/hull/hull_nullfeld.gd"),
preload("res://items/hull/hull_plating.gd"),
preload("res://items/hull/hull_reaktor.gd"),
preload("res://items/drives/drive_overdrive.gd"),
preload("res://items/drives/drive_quantum.gd"),
preload("res://items/drives/drive_steer.gd"),
preload("res://items/special/special_credit_mag.gd"),
preload("res://items/special/special_wipe_core.gd"),
]
static func _ensure_loaded() -> void:
if _registry_loaded: return
_registry_loaded = true
for script: Script in _ITEM_SCRIPTS:
if script == null or not script.can_instantiate(): continue
var inst = script.new()
if inst is ItemDef and (inst as ItemDef).id != "":
_registry.append(inst)
static func get_all_defs() -> Array:
_ensure_loaded()
return _registry
static func get_def_by_id(id: String) -> ItemDef:
_ensure_loaded()
for item in _registry:
if (item as ItemDef).id == id:
return item
return null
# Rolls `count` random items from the Werkstatt pool, excluding given IDs.
static func roll_werkstatt(count: int, exclude_ids: Array = []) -> Array:
_ensure_loaded()
var pool: Array = []
for item in _registry:
var def := item as ItemDef
if exclude_ids.has(def.id):
continue
pool.append(def)
pool.shuffle()
var n: int = min(count, pool.size())
return pool.slice(0, n)
# ─── Effect-Klassifizierung (für UI-Farbkodierung) ────────────────────────────
static func is_positive_effect(key: String, val) -> bool:
match key:
"speed_mult", "turn_mult", "fire_rate_mult", "damage_mult", \
"bullet_speed_mult", "invuln_mult", "credit_bonus":
return float(val) >= 1.0
"bullet_count", "shield_charges":
return int(val) > 0
"bh_resist":
return float(val) > 0.0
"wipe_mult":
return float(val) <= 1.0 # kleiner = schneller = besser
return true
# Menschlich lesbarer Stat-Name für die Vorschau.
static func stat_display_name(key: String) -> String:
match key:
"speed_mult": return "Speed"
"turn_mult": return "Wendung"
"fire_rate_mult": return "Feuerrate"
"damage_mult": return "Schaden"
"bullet_speed_mult": return "Proj.-Speed"
"bullet_count": return "Projektile"
"shield_charges": return "Schilde"
"invuln_mult": return "Unverwundbar"
"bh_resist": return "BH-Resistenz"
"wipe_mult": return "Wipe-Ladezeit"
"credit_bonus": return "Kreditgewinn"
return key
static func get_by_id(id: String) -> Dictionary:
for item: Dictionary in ITEMS:
if item["id"] == id:
return item
return {}
+1
View File
@@ -0,0 +1 @@
uid://sfb1ybt3r8ne
+29
View File
@@ -0,0 +1,29 @@
extends RefCounted
class_name ItemDef
# Base class for all Werkstatt items. Each item is its own .gd file in res://items/
# that extends ItemDef and fills the fields in _init().
# ItemDB auto-discovers all .gd files in res://items/ at first access.
# ─── Pflichtfelder ────────────────────────────────────────────────────────────
var id: String = ""
var name: String = ""
var name_en: String = ""
var desc: String = ""
var desc_en: String = ""
var category: String = "" # "WAFFENMODUL" | "HÜLLENMOD" | "ANTRIEBSMOD" | "SPEZIAL"
var category_en: String = "" # "WEAPON MOD" | "HULL MOD" | "DRIVE MOD" | "SPECIAL"
var icon: String = ""
var cost: int = 50
var rarity: int = 0 # 0=Common, 1=Uncommon, 2=Rare, 3=Epic
var effects: Dictionary = {} # ShipStats keys → multipliers/additives
# ─── Visuelle Anhänge am Schiff ───────────────────────────────────────────────
# Format: [[x_offset, y_offset, "color_key"], ...]
# color_key muss ein Schlüssel in der Schiffs-Palette sein (nose, bright, mid,
# dim, accent, edge, shadow) oder "white" / "red" / "yellow" / "green" als Fallback.
var visual_pixels: Array = []
# ─── Schiffswachstum ──────────────────────────────────────────────────────────
# Nur Items mit hull_size_bonus > 0 vergrößern das Schiff.
var hull_size_bonus: float = 0.0
+1
View File
@@ -0,0 +1 @@
uid://cxa4d1ome3iap
+56
View File
@@ -0,0 +1,56 @@
extends Node
const MAX_ENTRIES := 10
const SAVE_PATH := "user://leaderboard.cfg"
var _scores: Array = []
func _ready() -> void:
_load()
func add_score(player_name: String, score: int, wave: int) -> void:
var entry := {
"name": player_name.strip_edges().left(12),
"score": score,
"wave": wave,
"date": Time.get_date_string_from_system()
}
_scores.append(entry)
_scores.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["score"] > b["score"])
if _scores.size() > MAX_ENTRIES:
_scores.resize(MAX_ENTRIES)
_save()
func get_scores() -> Array:
return _scores
func is_highscore(score: int) -> bool:
if _scores.size() < MAX_ENTRIES:
return true
return score > (_scores[-1] as Dictionary).get("score", 0)
func _save() -> void:
var cfg := ConfigFile.new()
for i: int in _scores.size():
var e: Dictionary = _scores[i]
cfg.set_value("entry_%d" % i, "name", e.get("name", "???"))
cfg.set_value("entry_%d" % i, "score", e.get("score", 0))
cfg.set_value("entry_%d" % i, "wave", e.get("wave", 1))
cfg.set_value("entry_%d" % i, "date", e.get("date", ""))
cfg.save(SAVE_PATH)
func _load() -> void:
_scores = []
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return
var i := 0
while cfg.has_section("entry_%d" % i):
_scores.append({
"name": cfg.get_value("entry_%d" % i, "name", "???"),
"score": cfg.get_value("entry_%d" % i, "score", 0),
"wave": cfg.get_value("entry_%d" % i, "wave", 1),
"date": cfg.get_value("entry_%d" % i, "date", "")
})
i += 1
+1
View File
@@ -0,0 +1 @@
uid://cs0jfxgljs4rl
+367
View File
@@ -0,0 +1,367 @@
extends Node2D
# ─── State Machine ───────────────────────────────────────────────────────────
enum State { MAIN_MENU, SELECT, SELECT_P2, LAUNCHING, PLAYING, RETURNING, WAVE_CLEAR, SHOP, GAMEOVER, PAUSED }
var state: State = State.SELECT
# ─── Ship data ───────────────────────────────────────────────────────────────
const SHIPS := [
{ "id": "classic", "name": "NOVA-1",
"nose": Color("#ffffff"), "bright": Color("#dddddd"), "mid": Color("#cccccc"),
"dim": Color("#aaaaaa"), "accent": Color("#88aaff"), "edge": Color("#888888"),
"shadow": Color("#666688"), "trail": Color(0.533,0.667,1.0,0.251),
"thrustHot": Color("#ffcc44"), "thrustCool": Color(1.0,0.533,0.267,0.533) },
{ "id": "inferno", "name": "INFERNO",
"nose": Color("#ffeecc"), "bright": Color("#ffaa66"), "mid": Color("#ee6633"),
"dim": Color("#aa3322"), "accent": Color("#ff4444"), "edge": Color("#882211"),
"shadow": Color("#551111"), "trail": Color(1.0,0.4,0.251,0.251),
"thrustHot": Color("#ffee44"), "thrustCool": Color(1.0,0.267,0.0,0.533) },
{ "id": "aurora", "name": "AURORA",
"nose": Color("#ddffee"), "bright": Color("#aaffcc"), "mid": Color("#66cc99"),
"dim": Color("#338866"), "accent": Color("#44ccff"), "edge": Color("#226655"),
"shadow": Color("#114433"), "trail": Color(0.267,0.8,1.0,0.251),
"thrustHot": Color("#aaffcc"), "thrustCool": Color(0.267,0.8,1.0,0.533) },
{ "id": "titan", "name": "TITAN",
"nose": Color("#ffffdd"), "bright": Color("#ddcc88"), "mid": Color("#aa9944"),
"dim": Color("#776633"), "accent": Color("#ffdd44"), "edge": Color("#554422"),
"shadow": Color("#332211"), "trail": Color(1.0,0.85,0.25,0.251),
"thrustHot": Color("#ffffff"), "thrustCool": Color(1.0,0.67,0.0,0.533) },
]
# ─── Game state ───────────────────────────────────────────────────────────────
var selected_ship_p1 := 0
var selected_ship_p2 := 0
var is_multiplayer := false
var launch_timer := 0.0
var return_timer := 0.0
var blink_phase := 0.0
# ─── Run state (roguelite) ────────────────────────────────────────────────────
const MAX_LIVES := 3
var lives_p1: int = MAX_LIVES
var credits_p1: int = 0
var stats_p1: ShipStats = null
var owned_items_p1: Array = []
var credits_p2: int = 0
var stats_p2: ShipStats = null
var owned_items_p2: Array = []
var wave_number: int = 1
var _shop_player: int = 1
# Reroll-Counter persistiert über den gesamten Run (nicht pro Shop zurückgesetzt).
# Jeder Reroll verteuert den nächsten auch welleübergreifend — Snowball-Schutz.
var reroll_count_p1: int = 0
var reroll_count_p2: int = 0
# ─── References ──────────────────────────────────────────────────────────────
@onready var game_world: Node2D = $GameWorld
@onready var ship_select_ui: Node2D = $ShipSelectUI
@onready var hud: CanvasLayer = $HUD
@onready var shop_ui: Node2D = $ShopUI
@onready var pause_menu: Node2D = $PauseMenu
@onready var main_menu: Node2D = $MainMenu
@onready var atlas_ui: Node2D = $AtlasUI
@onready var music_player: Node = $MusicPlayer
@onready var touch_controls: Node2D = $HUD/TouchControls
func start_boss_music(is_final: bool = false) -> void:
if music_player:
if is_final:
music_player.play_boss_leviathan()
else:
music_player.play_boss()
func stop_boss_music() -> void:
if music_player:
music_player.play_normal()
func boss_phase_changed(new_phase: int) -> void:
if music_player and new_phase == 2:
music_player.enter_phase2()
func _ready() -> void:
game_world.main_node = self
hud.game_world = game_world
shop_ui.shop_closed.connect(_on_shop_closed)
pause_menu.resume_requested.connect(_on_pause_resume)
pause_menu.quit_to_menu_requested.connect(_on_pause_quit_menu)
main_menu.mode_selected.connect(_on_mode_selected)
main_menu.atlas_requested.connect(_on_atlas_requested)
main_menu.quit_requested.connect(func(): get_tree().quit())
atlas_ui.closed.connect(_on_atlas_closed)
touch_controls.game_world = game_world
touch_controls.direct_touch_ui = shop_ui
stats_p1 = ShipStats.new()
stats_p2 = ShipStats.new()
_set_state(State.MAIN_MENU)
# Sets ship-specific base stats (e.g. TITAN starts slower + has boost).
# Only applied on fresh ShipStats (no previously-bought items yet).
static func _apply_ship_base_stats(stats: ShipStats, ship_id: String) -> void:
if stats == null: return
stats.ship_id = ship_id
match ship_id:
"classic":
stats.shield_charges = 1
"inferno":
stats.speed_mult = 1.28
stats.turn_mult = 0.72
stats.fire_rate_mult = 1.55
"aurora":
stats.speed_mult = 0.80
stats.turn_mult = 1.55
stats.invuln_mult = 1.80
stats.shield_charges = 2
stats.bh_resist = 0.55
"titan":
stats.speed_mult = 0.72
stats.turn_mult = 0.85
stats.has_boost = true
stats.boost_cooldown_max = 5.0
func _set_state(new_state: State) -> void:
state = new_state
match state:
State.MAIN_MENU:
main_menu.visible = true
ship_select_ui.visible = false
shop_ui.visible = false
pause_menu.visible = false
hud.visible = false
game_world.visible = true
game_world.init_menu_mode()
touch_controls.set_mode(touch_controls.Mode.MENU)
State.SELECT:
main_menu.visible = false
ship_select_ui.visible = true
shop_ui.visible = false
game_world.visible = true
hud.visible = false
game_world.init_menu_mode()
ship_select_ui.start_select(false, selected_ship_p1, SHIPS)
touch_controls.set_mode(touch_controls.Mode.MENU)
State.SELECT_P2:
ship_select_ui.start_select(true, selected_ship_p2, SHIPS)
touch_controls.set_mode(touch_controls.Mode.MENU)
State.LAUNCHING:
ship_select_ui.visible = false
shop_ui.visible = false
game_world.visible = true
hud.visible = true
hud.is_gameover = false
hud.is_returning = false
hud.show_countdown_flag = false
hud.credits = credits_p1
hud.lives = lives_p1
launch_timer = 3.0
# Apply ship-base-stats once on first launch (wave 1). Later waves
# preserve the stats the player earned from Werkstatt upgrades.
if wave_number == 1:
_apply_ship_base_stats(stats_p1, SHIPS[selected_ship_p1]["id"])
if is_multiplayer:
_apply_ship_base_stats(stats_p2, SHIPS[selected_ship_p2]["id"])
var s2 = SHIPS[selected_ship_p2] if is_multiplayer else null
game_world.init_world(SHIPS[selected_ship_p1], s2, is_multiplayer, stats_p1, stats_p2, wave_number)
touch_controls.set_mode(touch_controls.Mode.GAME)
State.PLAYING:
get_tree().paused = false
pause_menu.visible = false
game_world.start_playing()
touch_controls.set_mode(touch_controls.Mode.GAME)
State.PAUSED:
pause_menu.open()
touch_controls.set_mode(touch_controls.Mode.MENU)
State.RETURNING:
lives_p1 -= 1
hud.is_returning = true
hud.lives = lives_p1
return_timer = 0.85
game_world.on_player_died()
touch_controls.set_mode(touch_controls.Mode.HIDDEN)
State.WAVE_CLEAR:
hud.wave_cleared = true
return_timer = 2.0
game_world.on_player_died()
touch_controls.set_mode(touch_controls.Mode.HIDDEN)
State.SHOP:
ship_select_ui.visible = false
hud.visible = false
game_world.visible = true
shop_ui.visible = true
if _shop_player == 1:
shop_ui.open(lives_p1, credits_p1, stats_p1, owned_items_p1,
"SPIELER 1" if is_multiplayer else "",
SHIPS[selected_ship_p1], wave_number, reroll_count_p1, 1)
else:
shop_ui.open(lives_p1, credits_p2, stats_p2, owned_items_p2, "SPIELER 2",
SHIPS[selected_ship_p2], wave_number, reroll_count_p2, 2)
touch_controls.set_mode(touch_controls.Mode.MENU)
State.GAMEOVER:
shop_ui.visible = false
hud.show_gameover(game_world.get_score_data())
touch_controls.set_mode(touch_controls.Mode.HIDDEN)
func _process(delta: float) -> void:
blink_phase += delta
match state:
State.LAUNCHING:
launch_timer -= delta
hud.show_countdown(ceil(launch_timer))
if launch_timer <= 0.0:
_set_state(State.PLAYING)
State.RETURNING:
return_timer -= delta
if return_timer <= 0.0:
if lives_p1 > 0:
_set_state(State.SHOP)
else:
_set_state(State.GAMEOVER)
State.WAVE_CLEAR:
return_timer -= delta
if return_timer <= 0.0:
hud.wave_cleared = false
_set_state(State.SHOP)
# Detect which type of input device the player is currently using.
# Drives adaptive UI labels via Tr.hint().
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
if Settings.last_input_device != "pad":
Settings.last_input_device = "pad"
elif event is InputEventKey or event is InputEventMouseButton:
if Settings.last_input_device != "keyboard":
Settings.last_input_device = "keyboard"
# "touch" is set by touch_controls.gd when a screen touch arrives.
func _unhandled_input(event: InputEvent) -> void:
match state:
State.PLAYING:
if event.is_action_pressed("ui_cancel"):
get_tree().paused = true
_set_state(State.PAUSED)
return
State.SELECT:
if event.is_action_pressed("ui_left"):
selected_ship_p1 = (selected_ship_p1 - 1 + SHIPS.size()) % SHIPS.size()
ship_select_ui.set_selection(selected_ship_p1)
elif event.is_action_pressed("ui_right"):
selected_ship_p1 = (selected_ship_p1 + 1) % SHIPS.size()
ship_select_ui.set_selection(selected_ship_p1)
elif event.is_action_pressed("ui_accept"):
if is_multiplayer:
_set_state(State.SELECT_P2)
else:
_set_state(State.LAUNCHING)
elif event.is_action_pressed("ui_cancel"):
_set_state(State.MAIN_MENU)
State.SELECT_P2:
if event.is_action_pressed("ui_left"):
selected_ship_p2 = (selected_ship_p2 - 1 + SHIPS.size()) % SHIPS.size()
ship_select_ui.set_selection(selected_ship_p2)
elif event.is_action_pressed("ui_right"):
selected_ship_p2 = (selected_ship_p2 + 1) % SHIPS.size()
ship_select_ui.set_selection(selected_ship_p2)
elif event.is_action_pressed("ui_accept"):
_set_state(State.LAUNCHING)
elif event.is_action_pressed("ui_cancel"):
_set_state(State.SELECT)
State.GAMEOVER:
if event.is_pressed():
_reset_game()
func _reset_game() -> void:
stop_boss_music() # Boss-Musik stoppen falls aktiv
selected_ship_p1 = 0
selected_ship_p2 = 0
is_multiplayer = false
hud.is_gameover = false
hud.is_returning = false
hud.show_countdown_flag = false
# Reset roguelite run state
lives_p1 = MAX_LIVES
credits_p1 = 0
stats_p1 = ShipStats.new()
owned_items_p1 = []
credits_p2 = 0
stats_p2 = ShipStats.new()
owned_items_p2 = []
reroll_count_p1 = 0
reroll_count_p2 = 0
_shop_player = 1
wave_number = 1
hud.wave_number = 1
hud.wave_cleared = false
_set_state(State.MAIN_MENU)
func add_credits(player_idx: int, amount: int) -> void:
if player_idx == 0:
credits_p1 += amount
hud.credits = credits_p1
elif player_idx == 1:
credits_p2 += amount
func on_wave_complete() -> void:
_set_state(State.WAVE_CLEAR)
func _on_shop_closed(remaining_credits: int, new_stats: ShipStats, items: Array, reroll_count: int) -> void:
if _shop_player == 1:
credits_p1 = remaining_credits
stats_p1 = new_stats
owned_items_p1 = items
reroll_count_p1 = reroll_count
if is_multiplayer:
_shop_player = 2
_set_state(State.SHOP)
return
else:
credits_p2 = remaining_credits
stats_p2 = new_stats
owned_items_p2 = items
reroll_count_p2 = reroll_count
_shop_player = 1
wave_number += 1
hud.wave_number = wave_number
_set_state(State.LAUNCHING)
func _on_mode_selected(multi: bool) -> void:
is_multiplayer = multi
main_menu.visible = false
# Reset run state + apply fresh ship-base stats (TITAN starts slower etc.).
# The base stats are applied AFTER ship selection in _set_state(LAUNCHING).
lives_p1 = MAX_LIVES
credits_p1 = 0
stats_p1 = ShipStats.new()
owned_items_p1 = []
credits_p2 = 0
stats_p2 = ShipStats.new()
owned_items_p2 = []
reroll_count_p1 = 0
reroll_count_p2 = 0
_shop_player = 1
wave_number = 1
hud.wave_number = 1
_set_state(State.SELECT)
func _on_atlas_requested() -> void:
main_menu.visible = false
atlas_ui.visible = true
if atlas_ui.has_method("open"):
atlas_ui.open()
touch_controls.set_mode(touch_controls.Mode.MENU)
func _on_atlas_closed() -> void:
atlas_ui.visible = false
main_menu.visible = true
touch_controls.set_mode(touch_controls.Mode.MENU)
func _on_pause_resume() -> void:
get_tree().paused = false
state = State.PLAYING
func _on_pause_quit_menu() -> void:
get_tree().paused = false
_reset_game()
# Called by game_world when all players are dead
func on_game_over() -> void:
stop_boss_music() # Boss-Musik stoppen falls aktiv
_set_state(State.RETURNING)
+1
View File
@@ -0,0 +1 @@
uid://ctbcvqj0aoo3v
+510
View File
@@ -0,0 +1,510 @@
extends Node2D
# Hauptmenü — erscheint beim Start.
# Hintergrund-Simulation läuft durch (game_world im Menu-Modus).
signal mode_selected(is_multiplayer: bool)
signal atlas_requested
signal quit_requested
var W: float = 960.0
var H: float = 600.0
const COL_BG := Color(0.0, 0.0, 0.04, 0.65)
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 1.0)
const COL_ACCENT := Color(0.27, 1.0, 0.8, 1.0)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.55)
const COL_WHITE := Color(1.0, 1.0, 1.0, 0.90)
const COL_WARN := Color(1.0, 0.67, 0.0, 0.90)
# 0 = Hauptmenü, 1 = Optionen, 2 = Steuerung, 3 = Leaderboard
var _screen: int = 0
var _cursor: int = 0
var _blink: float = 0.0
var _opt_cursor: int = 0
var _ctrl_cursor: int = 0
var _ctrl_waiting: bool = false # wartet auf Taste-Drücken zum Binden
# Leaderboard state
var _lb_view: int = 0 # 0=lokal, 1=online
var _lb_online_data: Array = []
var _lb_loading: bool = false
var _lb_error: String = ""
var _online_lb_node: Node = null
# Abwärtskompatibilität
var _show_options: bool:
get: return _screen == 1
set(v): _screen = 1 if v else 0
func _ready() -> void:
pass
func _items() -> Array:
return [Tr.t("menu_single"), Tr.t("menu_vs"), Tr.t("menu_leaderboard"), Tr.t("menu_options"), Tr.t("menu_atlas"), Tr.t("menu_quit")]
func _opt_items() -> Array:
return [Tr.t("opt_sfx"), Tr.t("opt_music"), Tr.t("opt_mute"), Tr.t("opt_fullscreen"),
Tr.t("opt_nebula"), Tr.t("opt_stars"), Tr.t("opt_language"), Tr.t("opt_touch"),
Tr.t("opt_controls"), Tr.t("opt_back")]
func _ctrl_labels() -> Array:
return [Tr.t("ctrl_thrust"), Tr.t("ctrl_left"), Tr.t("ctrl_right"),
Tr.t("ctrl_shoot"), Tr.t("ctrl_wipe")]
func _process(delta: float) -> void:
if not visible: return
_blink += delta
queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
if not visible: return
match _screen:
0: _input_menu(event)
1: _input_options(event)
2: _input_controls(event)
3: _input_leaderboard(event)
func _input_menu(event: InputEvent) -> void:
var items := _items()
if event.is_action_pressed("ui_up"):
_cursor = (_cursor - 1 + items.size()) % items.size()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_down"):
_cursor = (_cursor + 1) % items.size()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_accept"):
# Consume the event FIRST so the same ENTER doesn't bubble up to
# main.gd._unhandled_input and auto-confirm the next state's UI.
get_viewport().set_input_as_handled()
match _cursor:
0: mode_selected.emit(false)
1: mode_selected.emit(true)
2: _screen = 3; _lb_view = 0; _lb_online_data = []; _lb_error = ""; _lb_loading = false
3: _screen = 1; _opt_cursor = 0
4: atlas_requested.emit()
5: quit_requested.emit()
func _input_options(event: InputEvent) -> void:
var opts := _opt_items()
if event.is_action_pressed("ui_up"):
_opt_cursor = (_opt_cursor - 1 + opts.size()) % opts.size()
elif event.is_action_pressed("ui_down"):
_opt_cursor = (_opt_cursor + 1) % opts.size()
elif event.is_action_pressed("ui_left"):
_change_option(_opt_cursor, -1)
elif event.is_action_pressed("ui_right"):
_change_option(_opt_cursor, 1)
elif event.is_action_pressed("ui_accept"):
if _opt_cursor == opts.size() - 1: # Back
_screen = 0
elif _opt_cursor == opts.size() - 2: # Controls
_screen = 2; _ctrl_cursor = 0; _ctrl_waiting = false
else:
_change_option(_opt_cursor, 1)
elif event.is_action_pressed("ui_cancel"):
_screen = 0
func _input_controls(event: InputEvent) -> void:
var labels := _ctrl_labels()
var total := labels.size() + 2 # actions + Reset + Back
var reset_idx := labels.size()
var back_idx := labels.size() + 1
if _ctrl_waiting:
if event is InputEventKey and event.pressed:
var kc: int = event.physical_keycode
# Ignore pure modifier keys
if kc in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META, KEY_CAPSLOCK]:
return
if kc == KEY_ESCAPE:
_ctrl_waiting = false
return
_rebind_key(Settings.REBIND_ACTIONS[_ctrl_cursor], kc)
_ctrl_waiting = false
get_viewport().set_input_as_handled()
elif event is InputEventJoypadButton and event.pressed:
_rebind_joy(Settings.REBIND_ACTIONS[_ctrl_cursor], event.button_index)
_ctrl_waiting = false
get_viewport().set_input_as_handled()
return # block all other input while waiting
if event.is_action_pressed("ui_up"):
_ctrl_cursor = (_ctrl_cursor - 1 + total) % total
elif event.is_action_pressed("ui_down"):
_ctrl_cursor = (_ctrl_cursor + 1) % total
elif event.is_action_pressed("ui_accept"):
if _ctrl_cursor == back_idx:
_screen = 1
elif _ctrl_cursor == reset_idx:
Settings.reset_key_bindings()
else:
_ctrl_waiting = true
elif event.is_action_pressed("ui_cancel"):
_screen = 1
func _input_leaderboard(event: InputEvent) -> void:
if _lb_loading:
return
if event.is_action_pressed("ui_cancel"):
_screen = 0
get_viewport().set_input_as_handled()
return
# 'O' key toggles online/local view
if event is InputEventKey and (event as InputEventKey).pressed:
var phk := (event as InputEventKey).physical_keycode
if phk == KEY_O:
if _lb_view == 0:
_fetch_online()
else:
_lb_view = 0
_lb_error = ""
get_viewport().set_input_as_handled()
func _fetch_online() -> void:
if _online_lb_node == null:
_online_lb_node = load("res://scripts/online_leaderboard.gd").new()
add_child(_online_lb_node)
_online_lb_node.scores_fetched.connect(_on_online_scores_fetched)
_lb_loading = true
_lb_error = ""
_online_lb_node.fetch_scores()
func _on_online_scores_fetched(scores: Array, error: String) -> void:
_lb_loading = false
if error != "":
_lb_error = Tr.t(error)
_lb_view = 1
else:
_lb_online_data = scores
_lb_view = 1
func _rebind_key(action: String, physical_keycode: int) -> void:
if not InputMap.has_action(action): return
# Remove existing keyboard events
for ev in InputMap.action_get_events(action):
if ev is InputEventKey:
InputMap.action_erase_event(action, ev)
var new_ev := InputEventKey.new()
new_ev.physical_keycode = physical_keycode
InputMap.action_add_event(action, new_ev)
Settings.key_bindings[action] = physical_keycode
Settings.save_settings()
func _rebind_joy(action: String, button_index: int) -> void:
if not InputMap.has_action(action): return
for ev in InputMap.action_get_events(action):
if ev is InputEventJoypadButton:
InputMap.action_erase_event(action, ev)
var new_ev := InputEventJoypadButton.new()
new_ev.button_index = button_index
InputMap.action_add_event(action, new_ev)
Settings.key_bindings[action + "_joy"] = button_index
Settings.save_settings()
func _change_option(idx: int, dir: int) -> void:
match idx:
0: # SFX
Settings.master_volume = clamp(Settings.master_volume + dir * 0.1, 0.0, 1.0)
Settings.apply_volume()
1: # Music
Settings.music_volume = clamp(Settings.music_volume + dir * 0.1, 0.0, 1.0)
Settings.apply_music_volume()
2: # Mute
Settings.sfx_muted = not Settings.sfx_muted
Settings.apply_volume()
3: # Fullscreen
Settings.fullscreen = not Settings.fullscreen
if Settings.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
4: # Nebula
Settings.nebula_enabled = not Settings.nebula_enabled
5: # Stars
Settings.star_density = (Settings.star_density + dir + 3) % 3
6: # Language
Settings.language = "en" if Settings.language == "de" else "de"
7: # Touch mode
Settings.touch_mode = (Settings.touch_mode + 1) % 3
# 8 = Controls → handled in _input_options (opens sub-screen)
Settings.save_settings()
# ── Drawing ───────────────────────────────────────────────────────────────────
func _draw() -> void:
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
draw_rect(get_viewport_rect(), COL_BG)
match _screen:
0: _draw_main()
1: _draw_options()
2: _draw_controls()
3: _draw_leaderboard()
func _draw_main() -> void:
# Title
var title_y := H * 0.22
_draw_text_c("S P A C E L", W * 0.5, title_y, 38, COL_PRIMARY)
var sub_alpha := 0.4 + 0.2 * sin(_blink * 1.5)
_draw_text_c(Tr.t("subtitle"), W * 0.5, title_y + 52.0, 9,
Color(COL_DIM.r, COL_DIM.g, COL_DIM.b, sub_alpha))
# Separator line
var lx1 := W * 0.5 - 120.0; var lx2 := W * 0.5 + 120.0
draw_line(Vector2(lx1, title_y + 70.0), Vector2(lx2, title_y + 70.0),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.25), 1.0)
# Menu entries
var items := _items()
var menu_y := H * 0.50
for i in items.size():
var is_sel := i == _cursor
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var col: Color
if i == items.size() - 1: # Quit in warning colour
col = Color(COL_WARN.r, COL_WARN.g, COL_WARN.b,
pulse if is_sel else 0.45)
elif is_sel:
col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse)
else:
col = Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.55)
var sz := 16 if is_sel else 13
var prefix := "" if is_sel else " "
_draw_text_c(prefix + items[i], W * 0.5, menu_y + i * 42.0, sz, col)
# Corner brackets
_draw_brackets(12.0, 12.0, W - 12.0, H - 12.0, COL_DIM, 18.0)
# Footer — adapts to active input device
_draw_text_c(Tr.hint("footer_nav"), W * 0.5, H - 22.0, 8, COL_DIM)
func _draw_options() -> void:
var bw := 480.0; var bh := 380.0
var bx := (W - bw) * 0.5; var by := (H - bh) * 0.5
_draw_terminal_box(bx, by, bw, bh)
_draw_text_c(Tr.t("opt_title"), W * 0.5, by + 16.0, 10, COL_DIM)
var star_labels := [Tr.t("star_low"), Tr.t("star_mid"), Tr.t("star_high")]
var touch_labels := [Tr.t("touch_auto"), Tr.t("touch_on"), Tr.t("touch_off")]
var opts := _opt_items()
var values: Array = [
_volume_bar(Settings.master_volume),
_volume_bar(Settings.music_volume),
Tr.t("yes") if Settings.sfx_muted else Tr.t("no"),
Tr.t("yes") if Settings.fullscreen else Tr.t("no"),
Tr.t("yes") if Settings.nebula_enabled else Tr.t("no"),
star_labels[Settings.star_density],
Settings.language.to_upper(),
touch_labels[Settings.touch_mode],
"", # Controls sub-screen
"" # Back (rendered specially)
]
var item_y := by + 52.0
for i in opts.size():
var is_sel := i == _opt_cursor
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var row_y := item_y + i * 36.0
if i == opts.size() - 1: # Back
_draw_text_c("[ %s ]" % opts[i], W * 0.5, row_y, 12,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b,
pulse if is_sel else 0.4))
else:
var label_col := Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b,
pulse) if is_sel else Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.70)
var val_col := Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b,
0.90 if is_sel else 0.50)
var prefix := "" if is_sel else " "
_draw_text(prefix + opts[i], bx + 28.0, row_y, 11, label_col)
if values[i] != "":
var val_str: String
if values[i] == "":
val_str = "" # Sub-screen indicator — no ◄► arrows
else:
val_str = "%s" % values[i]
_draw_text(val_str, bx + bw - 28.0 - _text_w(val_str, 11), row_y, 11, val_col)
_draw_text_c(Tr.hint("opt_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
func _draw_controls() -> void:
var bw := 400.0; var bh := 310.0
var bx := (W - bw) * 0.5; var by := (H - bh) * 0.5
_draw_terminal_box(bx, by, bw, bh)
_draw_text_c(Tr.t("ctrl_title"), W * 0.5, by + 16.0, 10, COL_DIM)
var labels := _ctrl_labels()
var actions := Settings.REBIND_ACTIONS
var reset_idx := labels.size()
var back_idx := labels.size() + 1
var total := labels.size() + 2
var item_y := by + 52.0
for i in total:
var is_sel := i == _ctrl_cursor
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var row_y := item_y + i * 36.0
if i == back_idx:
_draw_text_c("[ %s ]" % Tr.t("opt_back"), W * 0.5, row_y, 12,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse if is_sel else 0.4))
elif i == reset_idx:
_draw_text_c("[ %s ]" % Tr.t("ctrl_reset"), W * 0.5, row_y, 12,
Color(COL_WARN.r, COL_WARN.g, COL_WARN.b, pulse if is_sel else 0.35))
else:
var label_col := Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b,
pulse) if is_sel else Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.70)
var val_col := Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b,
0.90 if is_sel else 0.50)
var prefix := "" if is_sel else " "
var key_str: String
if _ctrl_waiting and is_sel:
key_str = Tr.t("ctrl_waiting")
val_col = Color(COL_WARN.r, COL_WARN.g, COL_WARN.b,
0.5 + 0.5 * sin(_blink * 6.0))
else:
key_str = "[ %s ]" % _action_key_name(actions[i])
_draw_text(prefix + labels[i], bx + 28.0, row_y, 11, label_col)
_draw_text(key_str, bx + bw - 28.0 - _text_w(key_str, 11), row_y, 11, val_col)
_draw_text_c(Tr.hint("ctrl_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
func _draw_leaderboard() -> void:
var bw := 500.0; var bh := 420.0
var bx := (W - bw) * 0.5; var by := (H - bh) * 0.5
_draw_terminal_box(bx, by, bw, bh)
# Header
var view_label := Tr.t("lb_online") if _lb_view == 1 else Tr.t("lb_local")
_draw_text_c(Tr.t("lb_title") + " " + view_label, W * 0.5, by + 16.0, 10, COL_DIM)
_draw_hline(bx + 16.0, bx + bw - 16.0, by + 38.0)
# Loading / error
if _lb_loading:
var pulse := 0.5 + 0.5 * sin(_blink * 4.0)
_draw_text_c(Tr.t("lb_loading"), W * 0.5, by + 200.0, 13,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
_draw_text_c(Tr.hint("lb_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
return
if _lb_error != "":
_draw_text_c(_lb_error, W * 0.5, by + 200.0, 11, COL_WARN)
_draw_text_c(Tr.hint("lb_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
return
# Column headers
var col_x_rank := bx + 30.0
var col_x_name := bx + 70.0
var col_x_score := bx + 260.0
var col_x_wave := bx + 390.0
var header_y := by + 50.0
var dim := Color(COL_DIM.r, COL_DIM.g, COL_DIM.b, 0.8)
_draw_text("#", col_x_rank, header_y, 9, dim)
_draw_text("NAME", col_x_name, header_y, 9, dim)
_draw_text("SCORE", col_x_score, header_y, 9, dim)
_draw_text("WAVE", col_x_wave, header_y, 9, dim)
_draw_hline(bx + 16.0, bx + bw - 16.0, by + 66.0)
# Rows
var scores: Array = Leaderboard.get_scores() if _lb_view == 0 else _lb_online_data
if scores.is_empty():
_draw_text_c(Tr.t("lb_empty"), W * 0.5, by + 200.0, 11,
Color(COL_DIM.r, COL_DIM.g, COL_DIM.b, 0.6))
else:
for i: int in min(scores.size(), 10):
var e: Dictionary = scores[i]
var row_y := by + 76.0 + i * 30.0
var row_col := COL_ACCENT if i == 0 else \
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.85 - i * 0.05)
_draw_text("%d." % (i + 1), col_x_rank, row_y, 10, row_col)
_draw_text(str(e.get("name", "???")), col_x_name, row_y, 10, row_col)
_draw_text(str(e.get("score", 0)), col_x_score, row_y, 10, row_col)
var wave_str := Tr.t("lb_wave") + str(e.get("wave", 1))
_draw_text(wave_str, col_x_wave, row_y, 10, row_col)
# Footer
_draw_hline(bx + 16.0, bx + bw - 16.0, by + bh - 36.0)
_draw_text_c(Tr.hint("lb_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
# ── Helpers ───────────────────────────────────────────────────────────────────
func _draw_hline(x1: float, x2: float, y: float) -> void:
draw_line(Vector2(x1, y), Vector2(x2, y),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.25), 1.0)
# Returns a short human-readable name for the first keyboard or joypad event of an action.
func _action_key_name(action: String) -> String:
if not InputMap.has_action(action): return "?"
for ev in InputMap.action_get_events(action):
if ev is InputEventKey:
var kev := ev as InputEventKey
var kc: int = kev.physical_keycode if kev.physical_keycode != KEY_NONE else kev.keycode
return OS.get_keycode_string(kc)
elif ev is InputEventJoypadButton:
var jev := ev as InputEventJoypadButton
return _joy_btn_label(jev.button_index)
return "?"
func _joy_btn_label(idx: int) -> String:
match idx:
JOY_BUTTON_A: return "A"
JOY_BUTTON_B: return "B"
JOY_BUTTON_X: return "X"
JOY_BUTTON_Y: return "Y"
JOY_BUTTON_LEFT_SHOULDER: return "LB"
JOY_BUTTON_RIGHT_SHOULDER: return "RB"
JOY_BUTTON_LEFT_STICK: return "L3"
JOY_BUTTON_RIGHT_STICK: return "R3"
_: return "BTN%d" % idx
func _volume_bar(vol: float) -> String:
var filled := int(round(vol * 10.0))
var bar := ""
for i in 10:
bar += "" if i < filled else ""
return bar
# ── Helpers ───────────────────────────────────────────────────────────────────
func _draw_terminal_box(bx: float, by: float, bw: float, bh: float) -> void:
draw_rect(Rect2(bx, by, bw, bh), Color(0.0, 0.04, 0.02, 0.95))
var bc := Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.35)
draw_line(Vector2(bx, by), Vector2(bx+bw, by), bc, 1.0)
draw_line(Vector2(bx, by+bh), Vector2(bx+bw, by+bh), bc, 1.0)
draw_line(Vector2(bx, by), Vector2(bx, by+bh), bc, 1.0)
draw_line(Vector2(bx+bw, by), Vector2(bx+bw, by+bh), bc, 1.0)
var cl := 12.0
for cx: float in [bx, bx+bw]:
for cy: float in [by, by+bh]:
var sx := 1.0 if cx == bx else -1.0
var sy := 1.0 if cy == by else -1.0
draw_line(Vector2(cx, cy), Vector2(cx + sx*cl, cy), COL_PRIMARY, 1.5)
draw_line(Vector2(cx, cy), Vector2(cx, cy + sy*cl), COL_PRIMARY, 1.5)
func _draw_brackets(x1: float, y1: float, x2: float, y2: float, col: Color, arm: float) -> void:
var bw := 1.5
draw_line(Vector2(x1, y1), Vector2(x1+arm, y1), col, bw)
draw_line(Vector2(x1, y1), Vector2(x1, y1+arm), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2-arm, y1), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2, y1+arm), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1+arm, y2), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1, y2-arm), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2-arm, y2), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2, y2-arm), col, bw)
func _draw_text(text: String, x: float, y: float, sz: int, col: Color) -> void:
draw_string(ThemeDB.fallback_font, Vector2(x, y + sz),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_text_c(text: String, x: float, y: float, sz: int, col: Color) -> void:
var tw := _text_w(text, sz)
draw_string(ThemeDB.fallback_font, Vector2(x - tw * 0.5, y + sz),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _text_w(text: String, sz: int) -> float:
return ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
+1
View File
@@ -0,0 +1 @@
uid://cp36y6rjo8aqs
+623
View File
@@ -0,0 +1,623 @@
extends Node
# ═══════════════════════════════════════════════════════════════════════════
# "STELLAR DRIFT" · Normal-OST (A-Moll, 140 BPM, Am→G→F→Em)
# "CRITICAL MASS" · Boss-OST #1 WRAITH (A-Moll, 175 BPM, Super Hexagon)
# "EVENT HORIZON" · Boss-OST #2 LEVIATHAN (E-Phrygisch, 140 BPM, Cosmic Dread)
#
# Voices in einem AudioStreamPlayer auf dem "Music"-Bus:
# Voice 0 · Melodie · Triangle (normal/boss2), Square (boss1)
# Voice 1 · Arpeggio · Square (normal/boss1), Triangle leise (boss2)
# Voice 2 · Bass · Sawtooth (boss2: +detuneter 2. Layer für Schwebung)
# Voice 3 · Schlagzeug · Hi-Hat+Kick+Snare (normal/boss1) ·
# Kick+Floor-Tom+Mid-Tom+Reverse-Whoosh (boss2)
# Voice 4 · Phase-2-Layer · Triangle 1 Oktave über Melodie (nur boss2 Phase 2)
# ═══════════════════════════════════════════════════════════════════════════
@export var volume_db: float = -8.0
@export var loop: bool = true
@export var autoplay: bool = true
const SAMPLE_RATE := 44100
const BUFFER_SIZE := 0.1
# ── Normale Musik ─────────────────────────────────────────────────────────
const BPM := 140.0
const BEAT := 60.0 / BPM
# ── Boss-Musik #1 (WRAITH) ────────────────────────────────────────────────
const BOSS_BPM := 175.0
const BOSS_BEAT := 60.0 / BOSS_BPM
# ── Boss-Musik #2 (LEVIATHAN) ─────────────────────────────────────────────
const BOSS2_BPM := 140.0
const BOSS2_BEAT := 60.0 / BOSS2_BPM
# ── Frequenztabelle ───────────────────────────────────────────────────────
const FREQ := {
"R" : 0.0,
"D2" : 73.42, "E2" : 82.41, "F2" : 87.31, "A2" : 110.00,
"C3" : 130.81, "D3" : 146.83, "E3" : 164.81,
"F3" : 174.61, "G3" : 196.00, "A3" : 220.00, "B3" : 246.94,
"C4" : 261.63, "D4" : 293.66, "E4" : 329.63,
"F4" : 349.23, "G4" : 392.00, "A4" : 440.00, "B4" : 493.88,
"C5" : 523.25, "D5" : 587.33, "E5" : 659.25,
"F5" : 698.46, "G5" : 783.99, "A5" : 880.00, "B5" : 987.77,
"C6" :1046.50, "E6" :1318.51,
}
# ── Normal-Akkorde (chord: 0=Am 1=G 2=F 3=Em) ─────────────────────────
const ARP := {
0: ["A4","C5","E5","C5"],
1: ["G4","B4","D5","B4"],
2: ["F4","A4","C5","A4"],
3: ["E4","G4","B4","G4"],
}
const BASS := { 0:"A3", 1:"G3", 2:"F3", 3:"E3" }
# ── Boss-Akkorde (chord: 0=Am 1=Em) ─────────────────────────────────────
const BOSS_ARP := {
0: ["A4","E5","A5","E5"], # Am — Power-Arpeggio
1: ["E4","B4","E5","B4"], # Em — Moll-Arpeggio
}
const BOSS_BASS := { 0:"A2", 1:"E2" } # tiefer Sub-Bass
# ── Boss2-Akkorde (chord: 0=E 1=F 2=Dm) — E-Phrygisch ──────────────────
const BOSS2_ARP := {
0: ["E4","B4","E5","G4"], # E (phrygisch, mit kleiner Terz G)
1: ["F4","C5","F5","A4"], # F — der phrygische II-Akkord (F→E ist der Killer)
2: ["D4","A4","D5","F4"], # Dm — dunkler iv-Akkord
}
const BOSS2_BASS := { 0:"E2", 1:"F2", 2:"D2" } # Drone-Bass Grundtöne
# ═══════════════════════════════════════════════════════════════════════════
# NORMAL-SCORE — "STELLAR DRIFT"
# Format: [mel_note, beats, chord_idx, section]
# section 0=Intro 1=Build 2=Main
# ═══════════════════════════════════════════════════════════════════════════
const SCORE: Array = [
# ═══ INTRO ═══
["A4", 2.0, 0, 0], ["E5", 2.0, 0, 0],
["C5", 2.0, 0, 0], ["A4", 2.0, 0, 0],
["E5", 1.0, 0, 0], ["A5", 1.0, 0, 0], ["E5", 1.0, 0, 0], ["C5", 1.0, 0, 0],
["A4", 4.0, 0, 0],
# ═══ BUILD ═══
["A4",0.5,0,1],["C5",0.25,0,1],["E5",0.25,0,1],
["A5",0.5,0,1],["G5",0.25,0,1],["E5",0.25,0,1],
["D5",0.5,0,1],["C5",0.25,0,1],["A4",0.25,0,1],
["C5",0.5,0,1],["R", 0.5, 0,1],
["C5",0.5,0,1],["E5",0.25,0,1],["A5",0.25,0,1],
["C6",0.5,0,1],["A5",0.25,0,1],["E5",0.25,0,1],
["G5",0.5,0,1],["E5",0.25,0,1],["C5",0.25,0,1],["A4",1.0,0,1],
["G4",0.5,1,1],["B4",0.25,1,1],["D5",0.25,1,1],
["G5",0.5,1,1],["D5",0.25,1,1],["B4",0.25,1,1],
["G4",0.5,1,1],["R", 0.25,1,1],["B4",0.25,1,1],
["D5",0.5,1,1],["G5",0.5,1,1],
["F4",0.5,2,1],["A4",0.25,2,1],["C5",0.25,2,1],
["F5",0.5,2,1],["C5",0.25,2,1],["A4",0.25,2,1],
["F5",0.5,2,1],["A5",0.25,2,1],["C6",0.25,2,1],
["A5",0.5,2,1],["F5",0.5, 2,1],
["E5",0.5,3,1],["G5",0.25,3,1],["B5",0.25,3,1],
["E6",0.5,3,1],["B5",0.25,3,1],["G5",0.25,3,1],
["A5",0.5,3,1],["G5",0.25,3,1],["E5",0.25,3,1],["B4",1.0,3,1],
# ═══ MAIN ═══
["A4",0.5,0,2],["C5",0.25,0,2],["E5",0.25,0,2],
["A5",0.5,0,2],["G5",0.25,0,2],["E5",0.25,0,2],
["D5",0.5,0,2],["C5",0.25,0,2],["A4",0.25,0,2],
["C5",0.5,0,2],["R", 0.5, 0,2],
["C5",0.5,0,2],["E5",0.25,0,2],["A5",0.25,0,2],
["C6",0.5,0,2],["A5",0.25,0,2],["E5",0.25,0,2],
["G5",0.5,0,2],["E5",0.25,0,2],["C5",0.25,0,2],["A4",1.0,0,2],
["G4",0.5,1,2],["B4",0.25,1,2],["D5",0.25,1,2],
["G5",0.5,1,2],["D5",0.25,1,2],["B4",0.25,1,2],
["G4",0.5,1,2],["R", 0.25,1,2],["B4",0.25,1,2],
["D5",0.5,1,2],["G5",0.5,1,2],
["F4",0.5,2,2],["A4",0.25,2,2],["C5",0.25,2,2],
["F5",0.5,2,2],["C5",0.25,2,2],["A4",0.25,2,2],
["F5",0.5,2,2],["A5",0.25,2,2],["C6",0.25,2,2],
["A5",0.5,2,2],["F5",0.5, 2,2],
["E5",0.5,3,2],["G5",0.25,3,2],["B5",0.25,3,2],
["E6",0.5,3,2],["B5",0.25,3,2],["G5",0.25,3,2],
["A5",0.5,3,2],["G5",0.25,3,2],["E5",0.25,3,2],["B4",1.0,3,2],
]
const LOOP_START := 9 # Loop ab BUILD (Intro überspringen)
# ═══════════════════════════════════════════════════════════════════════════
# BOSS-SCORE — "CRITICAL MASS"
# Format: [mel_note, beats, chord_idx, section]
# chord 0=Am 1=Em · section=3 (volle Intensität immer)
# BPM=175, Stil: Super Hexagon — stakkato, getrieben, 4-Bar-Loop
# ═══════════════════════════════════════════════════════════════════════════
const BOSS_SCORE: Array = [
# ══ Bar 1 (Am) — Aufstiegs-Stabs ══
["E5", 0.25, 0, 3], ["R", 0.25, 0, 3], ["G5", 0.25, 0, 3], ["A5", 0.25, 0, 3],
["E5", 0.5, 0, 3], ["R", 0.25, 0, 3], ["C5", 0.25, 0, 3],
["E5", 0.25, 0, 3], ["R", 0.25, 0, 3], ["A4", 0.5, 0, 3],
["R", 1.0, 0, 3],
# ══ Bar 2 (Am) — Synkopierter Lauf ══
["G5", 0.25, 0, 3], ["A5", 0.25, 0, 3], ["G5", 0.25, 0, 3], ["E5", 0.25, 0, 3],
["C5", 0.25, 0, 3], ["E5", 0.25, 0, 3], ["G5", 0.5, 0, 3],
["A5", 0.25, 0, 3], ["R", 0.25, 0, 3], ["G5", 0.25, 0, 3], ["E5", 0.25, 0, 3],
["C5", 0.5, 0, 3], ["R", 0.5, 0, 3],
# ══ Bar 3 (Em) — Dunkle Dringlichkeit ══
["E5", 0.25, 1, 3], ["R", 0.25, 1, 3], ["B4", 0.25, 1, 3], ["R", 0.25, 1, 3],
["G5", 0.25, 1, 3], ["E5", 0.25, 1, 3], ["D5", 0.5, 1, 3],
["B4", 0.25, 1, 3], ["R", 0.25, 1, 3], ["G5", 0.5, 1, 3],
["E5", 0.25, 1, 3], ["D5", 0.25, 1, 3], ["B4", 0.5, 1, 3],
# ══ Bar 4 (Em) — Klimax ══
["G5", 0.25, 1, 3], ["B5", 0.25, 1, 3], ["A5", 0.25, 1, 3], ["G5", 0.25, 1, 3],
["E5", 0.5, 1, 3], ["R", 0.5, 1, 3],
["D5", 0.25, 1, 3], ["R", 0.25, 1, 3], ["B4", 0.25, 1, 3], ["R", 0.25, 1, 3],
["E5", 1.0, 1, 3],
]
const BOSS_LOOP_START := 0 # Voller Loop, kein Intro
# ═══════════════════════════════════════════════════════════════════════════
# BOSS2-SCORE — "EVENT HORIZON"
# Format: [mel_note, beats, chord_idx, section]
# chord 0=E 1=F 2=Dm · section=3 (Bass+Drums immer aktiv)
# BPM=140 · E-Phrygisch · 16-Bar-Struktur: 8 Bars Phase 1 + 8 Bars Phase 2
# Melodie: lange, sparsame Töne in Phase 1 — verdichtet sich in Phase 2
# ═══════════════════════════════════════════════════════════════════════════
const BOSS2_SCORE: Array = [
# ══ PHASE 1 — Bars 18 (Index 0–14) ═══════════════════════════════════
# Bar 1 (E) — langer Tonika-Drone
["E5", 3.0, 0, 3], ["R", 1.0, 0, 3],
# Bar 2 (E) — leichte Bewegung
["G5", 2.0, 0, 3], ["E5", 2.0, 0, 3],
# Bar 3 (F) — phrygische Spannung bricht ein
["F5", 3.0, 1, 3], ["C5", 1.0, 1, 3],
# Bar 4 (F) — hält die Spannung
["F5", 2.0, 1, 3], ["A5", 2.0, 1, 3],
# Bar 5 (Dm) — dunkler Abstieg
["D5", 2.0, 2, 3], ["F5", 2.0, 2, 3],
# Bar 6 (Dm) — Weite, Atem
["A5", 3.0, 2, 3], ["R", 1.0, 2, 3],
# Bar 7 (E) — Rückkehr
["B4", 2.0, 0, 3], ["E5", 2.0, 0, 3],
# Bar 8 (E) — ganze Note als Übergang
["E5", 4.0, 0, 3],
# ══ PHASE 2 — Bars 916 (Index 1547) ═════════════════════════════════
# Verdichtung: mehr Achtel, aufsteigende Linien, Klimax-Oktav
# Bar 9 (E) — Phrygisch-Lauf aufwärts
["E5", 1.0, 0, 3], ["G5", 1.0, 0, 3], ["B5", 1.0, 0, 3], ["G5", 1.0, 0, 3],
# Bar 10 (E) — Stakkato-Antwort
["E5", 0.5, 0, 3], ["R", 0.5, 0, 3], ["B4", 1.0, 0, 3], ["G5", 1.0, 0, 3], ["E5", 1.0, 0, 3],
# Bar 11 (F) — hochklettern, der F→E-Halbton beißt
["F5", 1.0, 1, 3], ["A5", 1.0, 1, 3], ["C6", 1.0, 1, 3], ["A5", 1.0, 1, 3],
# Bar 12 (F) — absteigende Variation
["F5", 0.5, 1, 3], ["R", 0.5, 1, 3], ["C5", 1.0, 1, 3], ["A5", 1.0, 1, 3], ["F5", 1.0, 1, 3],
# Bar 13 (Dm) — Dm-Arpeggio-Lick
["D5", 1.0, 2, 3], ["F5", 1.0, 2, 3], ["A5", 1.0, 2, 3], ["F5", 1.0, 2, 3],
# Bar 14 (Dm) — Dreiklangs-Dichte
["D5", 0.5, 2, 3], ["A4", 0.5, 2, 3], ["D5", 1.0, 2, 3], ["F5", 1.0, 2, 3], ["A5", 1.0, 2, 3],
# Bar 15 (E) — finaler Aufstieg
["B4", 1.0, 0, 3], ["E5", 1.0, 0, 3], ["G5", 1.0, 0, 3], ["B5", 1.0, 0, 3],
# Bar 16 (E) — Klimax-Oktavsprung E5→E4
["E5", 2.0, 0, 3], ["E4", 2.0, 0, 3],
]
const BOSS2_LOOP_START := 0 # bei Phase 1 loopt der ganze Track
const BOSS2_PHASE2_OFFSET := 15 # Index wo Bar 9 (Phase 2) beginnt
# ── Player-Zustand ────────────────────────────────────────────────────────
var _player: AudioStreamPlayer
var _generator: AudioStreamGenerator
var _playback: AudioStreamGeneratorPlayback
var _playing := false
# Modus: 0=normal, 1=WRAITH (CRITICAL MASS), 2=LEVIATHAN (EVENT HORIZON)
var _boss_mode: int = 0
var _cur_beat: float = BEAT
# Phase-2-Umschaltung (nur boss_mode=2)
var _phase2_active: bool = false
var _pending_phase2_jump: bool = false
# Melody
var _mel_idx := 0
var _mel_pos := 0
var _mel_samples := 0
var _mel_freq := 0.0
var _mel_phase := 0.0
# Arpeggio (16tel-Noten)
var _arp_step := 0
var _arp_pos := 0
var _arp_samples := 0
var _arp_freq := 0.0
var _arp_phase := 0.0
# Bass
var _bass_chord := -1
var _bass_freq := 0.0
var _bass_phase := 0.0
# Bass-Zweit-Oszillator (nur boss_mode=2, detunet für Schwebung)
var _bass2_phase := 0.0
# Globaler Zustand
var _section := 0
var _chord := 0
var _sample_g := 0
# ── Initialisierung ───────────────────────────────────────────────────────
func _ready() -> void:
var bus_idx := AudioServer.get_bus_index("Music")
if bus_idx == -1:
AudioServer.add_bus()
bus_idx = AudioServer.get_bus_count() - 1
AudioServer.set_bus_name(bus_idx, "Music")
AudioServer.set_bus_send(bus_idx, "Master")
AudioServer.set_bus_volume_db(bus_idx,
linear_to_db(Settings.music_volume) if Settings.music_volume > 0.0 else -80.0)
_generator = AudioStreamGenerator.new()
_generator.mix_rate = SAMPLE_RATE
_generator.buffer_length = BUFFER_SIZE
_player = AudioStreamPlayer.new()
_player.stream = _generator
_player.volume_db = volume_db
_player.bus = "Music"
add_child(_player)
if autoplay:
play()
func play() -> void:
_boss_mode = 0
_cur_beat = BEAT
_phase2_active = false; _pending_phase2_jump = false
_mel_idx = 0; _mel_pos = 0; _mel_phase = 0.0
_arp_step = 0; _arp_pos = 0; _arp_phase = 0.0
_bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0
_sample_g = 0; _section = 0; _chord = 0
_playing = true
_player.play()
_playback = _player.get_stream_playback()
_load_mel()
func play_boss() -> void:
_boss_mode = 1
_cur_beat = BOSS_BEAT
_phase2_active = false; _pending_phase2_jump = false
_mel_idx = 0; _mel_pos = 0; _mel_phase = 0.0
_arp_step = 0; _arp_pos = 0; _arp_phase = 0.0
_bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0
_sample_g = 0; _section = 3; _chord = 0
if not _playing:
_playing = true
_player.play()
_playback = _player.get_stream_playback()
_load_mel()
func play_boss_leviathan(phase: int = 1) -> void:
_boss_mode = 2
_cur_beat = BOSS2_BEAT
_phase2_active = (phase >= 2)
_pending_phase2_jump = false
_mel_idx = BOSS2_PHASE2_OFFSET if _phase2_active else 0
_mel_pos = 0; _mel_phase = 0.0
_arp_step = 0; _arp_pos = 0; _arp_phase = 0.0
_bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0
_sample_g = 0; _section = 3; _chord = 0
if not _playing:
_playing = true
_player.play()
_playback = _player.get_stream_playback()
_load_mel()
# Wird vom game_world.gd aufgerufen, wenn LEVIATHAN Phase 2 erreicht (< 50% HP).
# Setzt nur ein Flag — der Sprung geschieht beim nächsten Note-Wechsel,
# damit der Rhythmus nicht bricht.
func enter_phase2() -> void:
if _boss_mode == 2 and not _phase2_active:
_pending_phase2_jump = true
func play_normal() -> void:
if _boss_mode == 0:
return # already playing normal — don't restart
_boss_mode = 0
_cur_beat = BEAT
_phase2_active = false; _pending_phase2_jump = false
_mel_idx = LOOP_START; _mel_pos = 0; _mel_phase = 0.0
_arp_step = 0; _arp_pos = 0; _arp_phase = 0.0
_bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0
_sample_g = 0
_load_mel()
func stop_music() -> void:
_playing = false
_player.stop()
func set_music_volume(linear: float) -> void:
var idx := AudioServer.get_bus_index("Music")
if idx != -1:
AudioServer.set_bus_volume_db(idx,
linear_to_db(linear) if linear > 0.0 else -80.0)
func set_muted(muted: bool) -> void:
var idx := AudioServer.get_bus_index("Music")
if idx != -1:
AudioServer.set_bus_mute(idx, muted)
# ── Hauptschleife ─────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if _playing:
_fill_buffer()
func _fill_buffer() -> void:
var frames := _playback.get_frames_available()
for _i in frames:
# ── Melodie-Note weiterschalten ──────────────────────────────
if _mel_pos >= _mel_samples:
# Phase-2-Sprung bei LEVIATHAN: smooth am Note-Ende
if _pending_phase2_jump and _boss_mode == 2:
_mel_idx = BOSS2_PHASE2_OFFSET
_phase2_active = true
_pending_phase2_jump = false
else:
_mel_idx += 1
var score: Array
var loop_start: int
match _boss_mode:
2:
score = BOSS2_SCORE
# Phase-2 loopt nur die zweite Hälfte (endlose Eskalation)
loop_start = BOSS2_PHASE2_OFFSET if _phase2_active else BOSS2_LOOP_START
1:
score = BOSS_SCORE
loop_start = BOSS_LOOP_START
_:
score = SCORE
loop_start = LOOP_START
if _mel_idx >= score.size():
if loop:
_mel_idx = loop_start
else:
_playing = false
_player.stop()
return
_mel_pos = 0
_load_mel()
# ── Arp-Note weiterschalten (16tel) ──────────────────────────
if _arp_pos >= _arp_samples:
_arp_step = (_arp_step + 1) % 4
_arp_pos = 0
_load_arp()
# ── Samples mischen ──────────────────────────────────────────
var s := 0.0
# Voice 0: Melodie
# Normal: Triangle warm · Boss1 (WRAITH): Square scharf · Boss2 (LEVIATHAN): Triangle mit langem Attack
if _mel_freq > 0.0:
var attack: int
var release: int
match _boss_mode:
2: attack = 800; release = 1500
1: attack = 100; release = 500
_: attack = 200; release = 1200
var env := _env(_mel_pos, _mel_samples, attack, release)
var ph := fmod(_mel_phase, 1.0)
if _boss_mode == 1:
# Square-Wave: schärfer, beißender Klang
var sq := 1.0 if ph < 0.5 else -1.0
s += sq * env * 0.26
else:
# Triangle-Wave: warm, vibraphone-artig (normal & boss2)
var tri := 1.0 - absf(4.0 * ph - 2.0)
var mel_gain := 0.32 if _boss_mode == 2 else 0.38
s += tri * env * mel_gain
# Phase-2-Oktav-Layer: zusätzliche Triangle eine Oktave höher
if _boss_mode == 2 and _phase2_active:
var ph2 := fmod(_mel_phase * 2.0, 1.0)
var tri2 := 1.0 - absf(4.0 * ph2 - 2.0)
s += tri2 * env * 0.14
_mel_phase += _mel_freq / SAMPLE_RATE
# Voice 1: Arpeggio
# Normal/Boss1: Square 50% · Boss2: leise Triangle (Sternengeflimmer)
if _arp_freq > 0.0:
var env := _env(_arp_pos, _arp_samples, 40, 400)
var ph := fmod(_arp_phase, 1.0)
if _boss_mode == 2:
var tri := 1.0 - absf(4.0 * ph - 2.0)
var gain := 0.09 if _phase2_active else 0.06
s += tri * env * gain
else:
var sq := 1.0 if ph < 0.5 else -1.0
var gain: float
if _section == 0:
gain = 0.05
elif _boss_mode == 1:
gain = 0.20
else:
gain = 0.11
s += sq * env * gain
_arp_phase += _arp_freq / SAMPLE_RATE
# Voice 2: Bass — Sawtooth (ab section 1)
# Boss2: zusätzlicher 2. Saw-Oszillator, detunet für Drone-Schwebung
if _section >= 1 and _bass_freq > 0.0:
var ph := fmod(_bass_phase, 1.0)
var bass_gain: float
if _boss_mode == 2:
bass_gain = 0.22
elif _boss_mode == 1:
bass_gain = 0.30
elif _section == 1:
bass_gain = 0.15
else:
bass_gain = 0.25
s += (2.0 * ph - 1.0) * bass_gain
_bass_phase += _bass_freq / SAMPLE_RATE
if _boss_mode == 2:
# 2. Oszillator detunet (~3‰ = leichte Schwebung bei ~82 Hz: ~0.25 Hz)
var ph2 := fmod(_bass2_phase, 1.0)
s += (2.0 * ph2 - 1.0) * 0.22
_bass2_phase += (_bass_freq * 1.003) / SAMPLE_RATE
# Voice 3: Schlagzeug (ab section 2 oder Boss-Modus)
if _section >= 2:
if _boss_mode == 2:
# ── LEVIATHAN: Tom-lastig, keine Hi-Hats ─────────────────
# Kick auf Beat 1 jedes Bars (in Phase 2 zusätzlich auf Beat 3)
var bar_period := int(4.0 * _cur_beat * SAMPLE_RATE)
var bar_pos := _sample_g % bar_period
var kick_len := 900
if bar_pos < kick_len:
var kick_env := 1.0 - float(bar_pos) / float(kick_len)
var kick_f := 1.0 - float(bar_pos) / float(kick_len)
s += sin(kick_f * kick_f * 55.0) * kick_env * 0.26
if _phase2_active:
var beat3_offset := int(2.0 * _cur_beat * SAMPLE_RATE)
var kick2_pos := (_sample_g + bar_period - beat3_offset) % bar_period
if kick2_pos < kick_len:
var kick2_env := 1.0 - float(kick2_pos) / float(kick_len)
var kick2_f := 1.0 - float(kick2_pos) / float(kick_len)
s += sin(kick2_f * kick2_f * 55.0) * kick2_env * 0.22
# Floor-Tom auf Beats 1 & 3 (period = 2 beats, tiefer Sinus mit Pitch-Drop)
var tom_period := int(2.0 * _cur_beat * SAMPLE_RATE)
var tom_pos := _sample_g % tom_period
var tom_len := 2200
if tom_pos < tom_len:
var tom_t := float(tom_pos) / float(tom_len)
var tom_env := 1.0 - tom_t
# Pitch-Envelope: startet bei 130 Hz, fällt auf 70 Hz
var tom_freq := 130.0 - 60.0 * tom_t
var tom_ph := float(tom_pos) / float(SAMPLE_RATE) * tom_freq
s += sin(tom_ph * TAU) * tom_env * 0.18
# Mid-Tom-Fill: Beat 4 jedes 4. Bars (= Ende jeder 4-Bar-Phrase)
var phrase_period := int(16.0 * _cur_beat * SAMPLE_RATE)
var phrase_pos := _sample_g % phrase_period
var mid_tom_start := int(15.0 * _cur_beat * SAMPLE_RATE)
var mid_tom_rel := phrase_pos - mid_tom_start
if mid_tom_rel >= 0:
# 4 schnelle Mid-Tom-Hits auf dem letzten Beat
var hit_len: int = int(_cur_beat * SAMPLE_RATE * 0.25)
@warning_ignore("integer_division")
var hit_idx: int = mid_tom_rel / hit_len
if hit_idx < 4:
var hit_pos := mid_tom_rel % hit_len
if hit_pos < 1400:
var mt_t := float(hit_pos) / 1400.0
var mt_env := 1.0 - mt_t
var mt_freq := 220.0 - 80.0 * mt_t
var mt_ph := float(hit_pos) / float(SAMPLE_RATE) * mt_freq
s += sin(mt_ph * TAU) * mt_env * 0.14
# Reverse-Whoosh alle 8 Bars: aufsteigende Noise-Envelope als Übergang
var whoosh_period := int(32.0 * _cur_beat * SAMPLE_RATE)
var whoosh_pos := _sample_g % whoosh_period
var whoosh_start := whoosh_period - int(2.0 * _cur_beat * SAMPLE_RATE)
if whoosh_pos >= whoosh_start:
var w_t := float(whoosh_pos - whoosh_start) / float(whoosh_period - whoosh_start)
var w_env := w_t * w_t # aufsteigend
s += randf_range(-1.0, 1.0) * w_env * 0.09
else:
# ── Normal & WRAITH: Hi-Hats + Kick + (Boss1) Snare ──────
var hat_beat_frac := 0.25 if _boss_mode == 1 else 0.5
var hat_period := int(hat_beat_frac * _cur_beat * SAMPLE_RATE)
var hat_pos := _sample_g % hat_period
var hat_len := 200 if _boss_mode == 1 else 600
if hat_pos < hat_len:
var hat_env := 1.0 - float(hat_pos) / float(hat_len)
var hat_vol := 0.08 if _boss_mode == 1 else 0.07
s += randf_range(-1.0, 1.0) * hat_env * hat_vol
var kick_period := int(_cur_beat * SAMPLE_RATE)
var kick_pos := _sample_g % kick_period
var kick_len := 750 if _boss_mode == 1 else 800
if kick_pos < kick_len:
var kick_env := 1.0 - float(kick_pos) / float(kick_len)
var kick_f := 1.0 - float(kick_pos) / float(kick_len)
var kick_vol := 0.22 if _boss_mode == 1 else 0.18
s += sin(kick_f * kick_f * 60.0) * kick_env * kick_vol
if _boss_mode == 1:
var snare_period := int(2.0 * _cur_beat * SAMPLE_RATE)
var snare_offset := int(1.0 * _cur_beat * SAMPLE_RATE)
var snare_pos := (_sample_g + snare_offset) % snare_period
if snare_pos < 340:
var snare_env := 1.0 - float(snare_pos) / 340.0
s += randf_range(-1.0, 1.0) * snare_env * 0.13
_playback.push_frame(Vector2(s, s))
_mel_pos += 1
_arp_pos += 1
_sample_g += 1
# ── Note laden ────────────────────────────────────────────────────────────
func _load_mel() -> void:
var score: Array
match _boss_mode:
2: score = BOSS2_SCORE
1: score = BOSS_SCORE
_: score = SCORE
var e: Array = score[_mel_idx]
_mel_freq = FREQ.get(e[0], 0.0)
_mel_samples = int(float(e[1]) * _cur_beat * SAMPLE_RATE)
_mel_phase = 0.0
_section = e[3]
if e[2] != _chord:
_chord = e[2]
_arp_step = 0
_arp_pos = 0
_load_arp()
if e[2] != _bass_chord:
_bass_chord = e[2]
var bass_dict: Dictionary
match _boss_mode:
2: bass_dict = BOSS2_BASS
1: bass_dict = BOSS_BASS
_: bass_dict = BASS
_bass_freq = FREQ.get(bass_dict[_bass_chord], 0.0)
_bass_phase = 0.0
_bass2_phase = 0.0
func _load_arp() -> void:
var arp_dict: Dictionary
var arp_step_count: int = 4
match _boss_mode:
2:
arp_dict = BOSS2_ARP
# Arp-Geschwindigkeit: Phase 1 = 8tel, Phase 2 = 16tel (verdichtet)
var arp_frac := 0.25 if _phase2_active else 0.5
_arp_samples = int(arp_frac * _cur_beat * SAMPLE_RATE)
1:
arp_dict = BOSS_ARP
_arp_samples = int(0.25 * _cur_beat * SAMPLE_RATE)
_:
arp_dict = ARP
_arp_samples = int(0.25 * _cur_beat * SAMPLE_RATE)
_arp_freq = FREQ.get(arp_dict[_chord][_arp_step % arp_step_count], 0.0)
_arp_phase = 0.0
# ── Hüllkurve ─────────────────────────────────────────────────────────────
func _env(pos: int, total: int, attack: int, release: int) -> float:
if pos < attack:
return float(pos) / maxf(attack, 1.0)
elif pos > total - release:
return float(total - pos) / maxf(release, 1.0)
return 1.0
+1
View File
@@ -0,0 +1 @@
uid://dxun4apx8cxf1
+64
View File
@@ -0,0 +1,64 @@
extends Node
# Change SERVER_URL to your server address before deploying.
# Set the same HMAC_SECRET on the server via env var SPACEL_SECRET.
const SERVER_URL := "https://lb.alpacaman.de"
const HMAC_SECRET := "63f4945d921d599f27ae4fdf5bada3f1"
signal scores_fetched(scores: Array, error: String)
signal submit_done(ok: bool)
var _http: HTTPRequest
var _mode: String = ""
func _ready() -> void:
_http = HTTPRequest.new()
add_child(_http)
_http.request_completed.connect(_on_completed)
func fetch_scores() -> void:
if _mode != "":
return
_mode = "fetch"
_http.request(SERVER_URL + "/scores")
func submit_score(player_name: String, score: int, wave: int) -> void:
if _mode != "":
return
var ts := int(Time.get_unix_time_from_system())
var raw := player_name + str(score) + str(wave) + str(ts)
var hmac := Crypto.new().hmac_digest(
HashingContext.HASH_SHA256,
HMAC_SECRET.to_utf8_buffer(),
raw.to_utf8_buffer()
).hex_encode()
var body := JSON.stringify({
"name": player_name,
"score": score,
"wave": wave,
"timestamp": ts,
"hmac": hmac
})
_mode = "submit"
_http.request(
SERVER_URL + "/scores",
PackedStringArray(["Content-Type: application/json"]),
HTTPClient.METHOD_POST,
body
)
func _on_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var ok := result == HTTPRequest.RESULT_SUCCESS and code == 200
if _mode == "fetch":
if ok:
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK:
var data = json.get_data()
if data is Array:
scores_fetched.emit(data as Array, "")
_mode = ""
return
scores_fetched.emit([], "lb_error")
elif _mode == "submit":
submit_done.emit(ok)
_mode = ""
+1
View File
@@ -0,0 +1 @@
uid://bs401f5368qos
+380
View File
@@ -0,0 +1,380 @@
extends Node2D
# Pause-Menü + Optionen-Menü im Cockpit-Stil.
# Node braucht process_mode = PROCESS_MODE_ALWAYS (wird in _ready gesetzt).
signal resume_requested
signal quit_to_menu_requested
var W: float = 960.0
var H: float = 600.0
const COL_BG := Color(0.0, 0.0, 0.04, 0.75)
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 1.0)
const COL_ACCENT := Color(0.27, 1.0, 0.8, 1.0)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.55)
const COL_WHITE := Color(1.0, 1.0, 1.0, 0.90)
const COL_WARN := Color(1.0, 0.67, 0.0, 0.90)
# 0=Pause, 1=Optionen, 2=Steuerung
var _screen: int = 0
var _cursor: int = 0
var _blink: float = 0.0
var _ctrl_cursor: int = 0
var _ctrl_waiting: bool = false
func _pause_items() -> Array:
return [Tr.t("pause_resume"), Tr.t("menu_options"), Tr.t("pause_main_menu"), Tr.t("menu_quit")]
func _opt_items() -> Array:
return [Tr.t("opt_sfx"), Tr.t("opt_music"), Tr.t("opt_mute"), Tr.t("opt_fullscreen"),
Tr.t("opt_nebula"), Tr.t("opt_stars"), Tr.t("opt_language"), Tr.t("opt_touch"),
Tr.t("opt_controls"), Tr.t("opt_back")]
func _ctrl_labels() -> Array:
return [Tr.t("ctrl_thrust"), Tr.t("ctrl_left"), Tr.t("ctrl_right"),
Tr.t("ctrl_shoot"), Tr.t("ctrl_wipe")]
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func open() -> void:
_screen = 0
_cursor = 0
_blink = 0.0
_ctrl_waiting = false
visible = true
func _process(delta: float) -> void:
if not visible: return
_blink += delta
queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
if not visible: return
match _screen:
0: _input_pause(event)
1: _input_options(event)
2: _input_controls(event)
func _input_pause(event: InputEvent) -> void:
var pitems := _pause_items()
if event.is_action_pressed("ui_up"):
_cursor = (_cursor - 1 + pitems.size()) % pitems.size()
elif event.is_action_pressed("ui_down"):
_cursor = (_cursor + 1) % pitems.size()
elif event.is_action_pressed("ui_accept"):
match _cursor:
0: _do_resume()
1: _screen = 1; _cursor = 0
2: _do_quit_menu()
3: get_tree().quit()
elif event.is_action_pressed("ui_cancel"):
_do_resume()
func _input_options(event: InputEvent) -> void:
var opts := _opt_items()
if event.is_action_pressed("ui_up"):
_cursor = (_cursor - 1 + opts.size()) % opts.size()
elif event.is_action_pressed("ui_down"):
_cursor = (_cursor + 1) % opts.size()
elif event.is_action_pressed("ui_left"):
_change_option(_cursor, -1)
elif event.is_action_pressed("ui_right"):
_change_option(_cursor, 1)
elif event.is_action_pressed("ui_accept"):
if _cursor == opts.size() - 1: # Back
_screen = 0; _cursor = 1
elif _cursor == opts.size() - 2: # Controls
_screen = 2; _ctrl_cursor = 0; _ctrl_waiting = false
else:
_change_option(_cursor, 1)
elif event.is_action_pressed("ui_cancel"):
_close_options()
func _input_controls(event: InputEvent) -> void:
var labels := _ctrl_labels()
var reset_idx := labels.size()
var back_idx := labels.size() + 1
var total := labels.size() + 2
if _ctrl_waiting:
if event is InputEventKey and event.pressed:
var kc: int = event.physical_keycode
if kc in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META, KEY_CAPSLOCK]:
return
if kc == KEY_ESCAPE:
_ctrl_waiting = false
return
_rebind_key(Settings.REBIND_ACTIONS[_ctrl_cursor], kc)
_ctrl_waiting = false
get_viewport().set_input_as_handled()
elif event is InputEventJoypadButton and event.pressed:
_rebind_joy(Settings.REBIND_ACTIONS[_ctrl_cursor], event.button_index)
_ctrl_waiting = false
get_viewport().set_input_as_handled()
return
if event.is_action_pressed("ui_up"):
_ctrl_cursor = (_ctrl_cursor - 1 + total) % total
elif event.is_action_pressed("ui_down"):
_ctrl_cursor = (_ctrl_cursor + 1) % total
elif event.is_action_pressed("ui_accept"):
if _ctrl_cursor == back_idx:
_screen = 1
elif _ctrl_cursor == reset_idx:
Settings.reset_key_bindings()
else:
_ctrl_waiting = true
elif event.is_action_pressed("ui_cancel"):
_screen = 1
func _rebind_key(action: String, physical_keycode: int) -> void:
if not InputMap.has_action(action): return
for ev in InputMap.action_get_events(action):
if ev is InputEventKey:
InputMap.action_erase_event(action, ev)
var new_ev := InputEventKey.new()
new_ev.physical_keycode = physical_keycode
InputMap.action_add_event(action, new_ev)
Settings.key_bindings[action] = physical_keycode
Settings.save_settings()
func _rebind_joy(action: String, button_index: int) -> void:
if not InputMap.has_action(action): return
for ev in InputMap.action_get_events(action):
if ev is InputEventJoypadButton:
InputMap.action_erase_event(action, ev)
var new_ev := InputEventJoypadButton.new()
new_ev.button_index = button_index
InputMap.action_add_event(action, new_ev)
Settings.key_bindings[action + "_joy"] = button_index
Settings.save_settings()
func _change_option(idx: int, dir: int) -> void:
match idx:
0: # SFX volume
Settings.master_volume = clamp(Settings.master_volume + dir * 0.1, 0.0, 1.0)
Settings.apply_volume()
1: # Music volume
Settings.music_volume = clamp(Settings.music_volume + dir * 0.1, 0.0, 1.0)
Settings.apply_music_volume()
2: # Mute
Settings.sfx_muted = not Settings.sfx_muted
Settings.apply_volume()
3: # Fullscreen
Settings.fullscreen = not Settings.fullscreen
if Settings.fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
4: # Nebula
Settings.nebula_enabled = not Settings.nebula_enabled
5: # Stars
Settings.star_density = (Settings.star_density + dir + 3) % 3
6: # Language
Settings.language = "en" if Settings.language == "de" else "de"
7: # Touch mode
Settings.touch_mode = (Settings.touch_mode + 1) % 3
# 8 = Controls → handled in _input_options
Settings.save_settings()
func _do_resume() -> void:
visible = false
resume_requested.emit()
func _close_options() -> void:
_screen = 0
_cursor = 1
func _do_quit_menu() -> void:
visible = false
quit_to_menu_requested.emit()
# ── Drawing ───────────────────────────────────────────────────────────────────
func _draw() -> void:
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
draw_rect(get_viewport_rect(), COL_BG)
match _screen:
0: _draw_pause()
1: _draw_options()
2: _draw_controls()
func _draw_pause() -> void:
var bw := 300.0; var bh := 255.0
var bx := (W - bw) * 0.5; var by := (H - bh) * 0.5
_draw_terminal_box(bx, by, bw, bh)
_draw_text_c(Tr.t("pause_title"), W * 0.5, by + 16.0, 10, COL_DIM)
var pitems := _pause_items()
var item_y := by + 55.0
for i in pitems.size():
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var col: Color
if i == _cursor:
col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse)
else:
col = Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.65)
var prefix := "" if i == _cursor else " "
_draw_text_c(prefix + pitems[i], W * 0.5, item_y + i * 40.0, 14, col)
_draw_text_c(Tr.hint("pause_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
func _draw_options() -> void:
var bw := 480.0; var bh := 380.0
var bx := (W - bw) * 0.5; var by := (H - bh) * 0.5
_draw_terminal_box(bx, by, bw, bh)
_draw_text_c(Tr.t("opt_title"), W * 0.5, by + 16.0, 10, COL_DIM)
var star_labels := [Tr.t("star_low"), Tr.t("star_mid"), Tr.t("star_high")]
var touch_labels := [Tr.t("touch_auto"), Tr.t("touch_on"), Tr.t("touch_off")]
var opts := _opt_items()
var values: Array = [
_volume_bar(Settings.master_volume),
_volume_bar(Settings.music_volume),
Tr.t("yes") if Settings.sfx_muted else Tr.t("no"),
Tr.t("yes") if Settings.fullscreen else Tr.t("no"),
Tr.t("yes") if Settings.nebula_enabled else Tr.t("no"),
star_labels[Settings.star_density],
Settings.language.to_upper(),
touch_labels[Settings.touch_mode],
"", # Controls sub-screen
"" # Back (rendered specially)
]
var item_y := by + 52.0
for i in opts.size():
var is_sel := i == _cursor
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var row_y := item_y + i * 36.0
if i == opts.size() - 1: # Back
var p := pulse if is_sel else 0.4
_draw_text_c("[ %s ]" % opts[i], W * 0.5, row_y, 12,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, p))
else:
var label_col: Color
if is_sel:
label_col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse)
else:
label_col = Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.70)
var val_col := Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b,
0.90 if is_sel else 0.50)
var prefix := "" if is_sel else " "
_draw_text(prefix + opts[i], bx + 28.0, row_y, 11, label_col)
if values[i] != "":
var val_str: String
if values[i] == "":
val_str = ""
else:
val_str = "%s" % values[i]
_draw_text(val_str,
bx + bw - 28.0 - _text_w(val_str, 11), row_y, 11, val_col)
_draw_text_c(Tr.hint("opt_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
func _draw_controls() -> void:
var bw := 400.0; var bh := 310.0
var bx := (W - bw) * 0.5; var by := (H - bh) * 0.5
_draw_terminal_box(bx, by, bw, bh)
_draw_text_c(Tr.t("ctrl_title"), W * 0.5, by + 16.0, 10, COL_DIM)
var labels := _ctrl_labels()
var actions := Settings.REBIND_ACTIONS
var reset_idx := labels.size()
var back_idx := labels.size() + 1
var total := labels.size() + 2
var item_y := by + 52.0
for i in total:
var is_sel := i == _ctrl_cursor
var pulse := 0.6 + 0.4 * sin(_blink * 3.0)
var row_y := item_y + i * 36.0
if i == back_idx:
_draw_text_c("[ %s ]" % Tr.t("opt_back"), W * 0.5, row_y, 12,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse if is_sel else 0.4))
elif i == reset_idx:
_draw_text_c("[ %s ]" % Tr.t("ctrl_reset"), W * 0.5, row_y, 12,
Color(COL_WARN.r, COL_WARN.g, COL_WARN.b, pulse if is_sel else 0.35))
else:
var label_col := Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b,
pulse) if is_sel else Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.70)
var val_col := Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b,
0.90 if is_sel else 0.50)
var prefix := "" if is_sel else " "
var key_str: String
if _ctrl_waiting and is_sel:
key_str = Tr.t("ctrl_waiting")
val_col = Color(COL_WARN.r, COL_WARN.g, COL_WARN.b,
0.5 + 0.5 * sin(_blink * 6.0))
else:
key_str = "[ %s ]" % _action_key_name(actions[i])
_draw_text(prefix + labels[i], bx + 28.0, row_y, 11, label_col)
_draw_text(key_str, bx + bw - 28.0 - _text_w(key_str, 11), row_y, 11, val_col)
_draw_text_c(Tr.hint("ctrl_footer"), W * 0.5, by + bh - 20.0, 8, COL_DIM)
func _action_key_name(action: String) -> String:
if not InputMap.has_action(action): return "?"
for ev in InputMap.action_get_events(action):
if ev is InputEventKey:
var kev := ev as InputEventKey
var kc: int = kev.physical_keycode if kev.physical_keycode != KEY_NONE else kev.keycode
return OS.get_keycode_string(kc)
elif ev is InputEventJoypadButton:
var jev := ev as InputEventJoypadButton
return _joy_btn_label(jev.button_index)
return "?"
func _joy_btn_label(idx: int) -> String:
match idx:
JOY_BUTTON_A: return "A"
JOY_BUTTON_B: return "B"
JOY_BUTTON_X: return "X"
JOY_BUTTON_Y: return "Y"
JOY_BUTTON_LEFT_SHOULDER: return "LB"
JOY_BUTTON_RIGHT_SHOULDER: return "RB"
JOY_BUTTON_LEFT_STICK: return "L3"
JOY_BUTTON_RIGHT_STICK: return "R3"
_: return "BTN%d" % idx
func _volume_bar(vol: float) -> String:
var filled := int(round(vol * 10.0))
var bar := ""
for i in 10:
bar += "" if i < filled else ""
return bar
# ── Drawing helpers ───────────────────────────────────────────────────────────
func _draw_terminal_box(bx: float, by: float, bw: float, bh: float) -> void:
draw_rect(Rect2(bx, by, bw, bh), Color(0.0, 0.04, 0.02, 0.95))
var bc := Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.35)
draw_line(Vector2(bx, by), Vector2(bx+bw, by), bc, 1.0)
draw_line(Vector2(bx, by+bh), Vector2(bx+bw, by+bh), bc, 1.0)
draw_line(Vector2(bx, by), Vector2(bx, by+bh), bc, 1.0)
draw_line(Vector2(bx+bw, by), Vector2(bx+bw, by+bh), bc, 1.0)
var cl := 12.0
for cx: float in [bx, bx+bw]:
for cy: float in [by, by+bh]:
var sx := 1.0 if cx == bx else -1.0
var sy := 1.0 if cy == by else -1.0
draw_line(Vector2(cx, cy), Vector2(cx + sx*cl, cy), COL_PRIMARY, 1.5)
draw_line(Vector2(cx, cy), Vector2(cx, cy + sy*cl), COL_PRIMARY, 1.5)
func _draw_text(text: String, x: float, y: float, sz: int, col: Color) -> void:
draw_string(ThemeDB.fallback_font, Vector2(x, y + sz),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_text_c(text: String, x: float, y: float, sz: int, col: Color) -> void:
var tw := _text_w(text, sz)
draw_string(ThemeDB.fallback_font, Vector2(x - tw * 0.5, y + sz),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _text_w(text: String, sz: int) -> float:
return ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
+1
View File
@@ -0,0 +1 @@
uid://lmuic5md3b5p
+416
View File
@@ -0,0 +1,416 @@
extends RefCounted
# Accessed as CosmicObjects.Planet via preload in cosmic_objects.gd.
# No class_name to avoid outer-scope shadowing inside CosmicObjects' inner classes.
enum PType { TERRESTRIAL, DESERT, GAS_GIANT, ICE, LAVA, TOXIC }
# Orbit / position
var orbit_cx: float; var orbit_cy: float
var orbit_radius: float; var orbit_angle: float; var orbit_speed: float
var x: float; var y: float; var radius: float
# Legacy public fields (kept for compatibility with game_world.gd / debris)
var color: Color
var ring: bool = false
var moons: Array = []
var alpha: float = 1.0
var dead: bool = false
# Capture / tidal state (unchanged)
var captured: bool = false
var capture_bh_x: float = 0.0; var capture_bh_y: float = 0.0
var capture_angle: float = 0.0; var capture_dist: float = 0.0
var capture_initial_dist: float = 0.0; var capture_timer: float = 0.0
var initial_radius: float = 0.0
var debris_trail: Array = []
const CAPTURE_DURATION := 1.2
const DEBRIS_MAX := 20
# New: type & palette
var ptype: int = PType.TERRESTRIAL
var palette: Array = [] # [base, accent, highlight]
# New: surface noise & animation
var surface_noise: FastNoiseLite
var cloud_noise: FastNoiseLite
var has_clouds: bool = false
var spin_angle: float = 0.0
var spin_speed: float = 0.25
var cloud_drift: float = 0.0
var cloud_speed: float = 0.5
# Gas giant spot
var has_spot: bool = false
var spot_longitude: float = 0.0 # 0..TAU
var spot_latitude: float = 0.0 # in -radius..+radius
var spot_radius: float = 2.5
var spot_band_offset: float = 0.0 # for band index match
# Rings (extended)
var ring_count: int = 0 # 0 = none
var ring_inner: float = 0.0
var ring_outer: float = 0.0
var ring_tilt: float = 0.25
var ring_colors: Array = []
var ring_gaps: Array = [] # indices of skipped "bands"
func init(cx: float, cy: float, _world_w: float, _world_h: float) -> void:
orbit_cx = cx; orbit_cy = cy
orbit_radius = randf_range(19.0, 72.0)
orbit_angle = randf() * TAU
orbit_speed = randf_range(0.001, 0.004) * (1.0 if randf() > 0.5 else -1.0)
var is_gas_giant := randf() < 0.35
radius = randf_range(12.0, 19.0) if is_gas_giant else randf_range(5.0, 10.0)
initial_radius = radius
# Type selection
if is_gas_giant:
ptype = PType.GAS_GIANT
else:
var r := randf()
if r < 0.35: ptype = PType.TERRESTRIAL
elif r < 0.65: ptype = PType.DESERT
elif r < 0.80: ptype = PType.ICE
elif r < 0.90: ptype = PType.LAVA
else: ptype = PType.TOXIC
_setup_palette()
_setup_noise()
_setup_animation()
_setup_rings(is_gas_giant)
_setup_moons(is_gas_giant)
color = palette[0] # debris / legacy compatibility
func _setup_palette() -> void:
match ptype:
PType.TERRESTRIAL:
palette = [Color("#2a5aa0"), Color("#3a8a4a"), Color("#e8f0ff")]
PType.DESERT:
palette = [Color("#c87848"), Color("#7a3e25"), Color("#f0d8b0")]
PType.GAS_GIANT:
# Two palette variants — warm (Jupiter-like) or cool (Neptune-like)
if randf() < 0.6:
palette = [Color("#d8b078"), Color("#a06040"), Color("#f0d8a0")]
else:
palette = [Color("#88a8d8"), Color("#506090"), Color("#c8d8f0")]
PType.ICE:
palette = [Color("#b8d8f0"), Color("#5080a0"), Color("#ffffff")]
PType.LAVA:
palette = [Color("#2a0a00"), Color("#ff4408"), Color("#ffc040")]
PType.TOXIC:
palette = [Color("#4a8030"), Color("#d0d040"), Color("#204018")]
func _setup_noise() -> void:
surface_noise = FastNoiseLite.new()
surface_noise.seed = randi()
surface_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX
match ptype:
PType.TERRESTRIAL: surface_noise.frequency = 0.25
PType.DESERT: surface_noise.frequency = 0.22
PType.GAS_GIANT: surface_noise.frequency = 0.15
PType.ICE: surface_noise.frequency = 0.50
PType.LAVA: surface_noise.frequency = 0.35
PType.TOXIC: surface_noise.frequency = 0.18
func _setup_animation() -> void:
spin_angle = randf() * TAU
spin_speed = randf_range(0.15, 0.55) * (1.0 if randf() > 0.5 else -1.0)
if ptype == PType.TERRESTRIAL and randf() < 0.7:
has_clouds = true
cloud_noise = FastNoiseLite.new()
cloud_noise.seed = randi()
cloud_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX
cloud_noise.frequency = 0.30
cloud_drift = randf() * 100.0
cloud_speed = randf_range(0.4, 0.9)
if ptype == PType.GAS_GIANT:
has_spot = randf() < 0.55
if has_spot:
spot_longitude = randf() * TAU
spot_latitude = randf_range(-radius * 0.4, radius * 0.4)
spot_radius = randf_range(2.0, 3.0)
func _setup_rings(is_gas_giant: bool) -> void:
var has_ring := (is_gas_giant and randf() < 0.7) or (not is_gas_giant and randf() < 0.15)
if not has_ring:
ring = false
return
ring = true
ring_count = (randi() % 2) + 1 # 1..2 visible bands
ring_inner = radius + randf_range(3.0, 5.0)
ring_outer = ring_inner + randf_range(4.0, 8.0)
ring_tilt = randf_range(0.15, 0.35)
# Slight variation in ring color per band
ring_colors.clear()
for i in ring_count:
var base: Color = palette[0] if randf() < 0.5 else palette[2]
var shade: float = randf_range(0.65, 1.0)
ring_colors.append(Color(base.r * shade, base.g * shade, base.b * shade))
# Random gap bands (visual holes); we use radii, so gaps are "dark" arcs
ring_gaps.clear()
if randf() < 0.4:
ring_gaps.append(randf_range(ring_inner + 1.0, ring_outer - 1.0))
func _setup_moons(is_gas_giant: bool) -> void:
var max_moons := 4 if is_gas_giant else 3
var moon_count := randi() % (max_moons + 1)
for _i in moon_count:
var mtype := randi() % 3
var mcolor: Color
match mtype:
0: mcolor = Color(0.75, 0.75, 0.78) # rocky grey
1: mcolor = Color(0.85, 0.92, 1.0) # icy
2: mcolor = Color(0.85, 0.35, 0.25) # volcanic
_: mcolor = Color(0.8, 0.8, 0.8)
var msize: float = randf_range(2.0, 6.0) if is_gas_giant else randf_range(2.0, 4.0)
moons.append({
"angle": randf() * TAU,
"dist": radius + randf_range(8.0, 22.0),
"speed": randf_range(0.03, 0.08),
"size": msize,
"color": mcolor,
"mtype": mtype,
})
func start_capture(bh_x: float, bh_y: float) -> void:
captured = true
capture_bh_x = bh_x; capture_bh_y = bh_y
var dx := x - bh_x; var dy := y - bh_y
capture_dist = sqrt(dx * dx + dy * dy)
capture_initial_dist = maxf(capture_dist, 1.0)
capture_angle = atan2(dy, dx)
capture_timer = 0.0
func update(delta: float) -> void:
# Animation
spin_angle += spin_speed * delta
cloud_drift += cloud_speed * delta
if captured:
capture_timer += delta
var t: float = clampf(capture_timer / CAPTURE_DURATION, 0.0, 1.0)
capture_dist = capture_initial_dist * (1.0 - t * t)
var angular_speed: float = 4.0 + 10.0 * t * t
capture_angle += angular_speed * delta
x = capture_bh_x + cos(capture_angle) * capture_dist
y = capture_bh_y + sin(capture_angle) * capture_dist
radius = maxf(1.0, initial_radius * (1.0 - t * 0.8))
if int(capture_timer * 12.5) > int((capture_timer - delta) * 12.5):
var da: float = randf() * TAU
debris_trail.append({"x": x + cos(da) * radius,
"y": y + sin(da) * radius, "life": 0.5})
if debris_trail.size() > DEBRIS_MAX: debris_trail.pop_front()
for d: Dictionary in debris_trail:
d["life"] = float(d["life"]) - delta
debris_trail = debris_trail.filter(func(d): return float(d["life"]) > 0.0)
if t > 0.6:
alpha = maxf(0.0, 1.0 - (t - 0.6) / 0.4)
if capture_timer >= CAPTURE_DURATION:
dead = true
return
orbit_angle += orbit_speed
x = orbit_cx + cos(orbit_angle) * orbit_radius
y = orbit_cy + sin(orbit_angle) * orbit_radius * 0.4
for m: Dictionary in moons:
m["angle"] = float(m["angle"]) + float(m["speed"])
# ─── Drawing ──────────────────────────────────────────────────────────────────
func draw(canvas: CanvasItem) -> void:
# Back half of rings (behind planet)
if ring:
_draw_ring_half(canvas, true)
# Lava outer halo (drawn before body, under-glow)
if ptype == PType.LAVA:
var halo_r := int(ceil(radius + 3.0))
var pulse: float = 0.55 + sin(spin_angle * 3.0) * 0.15
var hc := Color(palette[1].r, palette[1].g, palette[1].b, alpha * 0.18 * pulse)
canvas.draw_rect(Rect2(x - halo_r, y - halo_r, halo_r * 2 + 1, halo_r * 2 + 1), hc)
# Planet body
_draw_body(canvas)
# Front half of rings (in front of planet)
if ring:
_draw_ring_half(canvas, false)
# Moons
for m: Dictionary in moons:
var mx: float = x + cos(float(m["angle"])) * float(m["dist"])
var my: float = y + sin(float(m["angle"])) * float(m["dist"]) * 0.5
_draw_moon(canvas, mx, my, m)
# Debris trail (tidal disruption)
for d: Dictionary in debris_trail:
var da: float = float(d["life"]) / 0.5
canvas.draw_rect(Rect2(float(d["x"]) - 1, float(d["y"]) - 1, 2, 2),
Color(palette[0].r, palette[0].g, palette[0].b, alpha * da * 0.7))
func _draw_body(canvas: CanvasItem) -> void:
var ir := int(ceil(radius))
var r2 := radius * radius
for py in range(-ir, ir + 1):
for px in range(-ir, ir + 1):
if px * px + py * py > r2:
continue
var idx := _sample_pattern(px, py)
var c: Color = palette[idx]
# Spherical shading — light from upper-left
var nx := float(px) / radius
var ny := float(py) / radius
var light: float = clampf(0.65 - nx * 0.35 + ny * 0.35, 0.35, 1.0)
var out := Color(c.r * light, c.g * light, c.b * light, alpha)
canvas.draw_rect(Rect2(x + px, y + py, 1, 1), out)
# Cloud layer (terrestrial only)
if has_clouds and ptype == PType.TERRESTRIAL:
var cv: float = cloud_noise.get_noise_2d(
float(px) + cloud_drift + spin_angle * radius * 0.6,
float(py) * 1.2)
if cv > 0.35:
var cloud_a: float = alpha * 0.55 * clampf((cv - 0.35) / 0.25, 0.0, 1.0) * light
canvas.draw_rect(Rect2(x + px, y + py, 1, 1),
Color(1.0, 1.0, 1.0, cloud_a))
# Lava glow — small emissive bloom near glow pixels
if ptype == PType.LAVA and idx == 2:
var gc := Color(palette[2].r, palette[2].g, palette[2].b, alpha * 0.35)
canvas.draw_rect(Rect2(x + px - 1, y + py, 1, 1), gc)
canvas.draw_rect(Rect2(x + px + 1, y + py, 1, 1), gc)
canvas.draw_rect(Rect2(x + px, y + py - 1, 1, 1), gc)
canvas.draw_rect(Rect2(x + px, y + py + 1, 1, 1), gc)
func _sample_pattern(px: int, py: int) -> int:
var fpx: float = float(px)
var fpy: float = float(py)
var shifted_x: float = fpx + spin_angle * radius * 0.6
match ptype:
PType.TERRESTRIAL:
# Polar caps
if absf(fpy) / radius > 0.78:
return 2
var v: float = surface_noise.get_noise_2d(shifted_x, fpy * 1.1)
return 1 if v > 0.05 else 0
PType.DESERT:
if fpy / radius < -0.82:
return 2
var v2: float = surface_noise.get_noise_2d(shifted_x, fpy * 1.1)
return 1 if v2 > 0.15 else 0
PType.GAS_GIANT:
# Distorted banding; spot overrides band
if has_spot:
# Spot position rotates with spin
var spot_x: float = cos(spot_longitude + spin_angle) * radius * 0.6
# Only visible on front half (cos > 0)
var front: float = cos(spot_longitude + spin_angle)
if front > 0.0:
var dx: float = fpx - spot_x
var dy: float = fpy - spot_latitude
if dx * dx + dy * dy < spot_radius * spot_radius:
return 2
var warp: float = surface_noise.get_noise_2d(shifted_x * 0.5, fpy * 4.0) * 2.0
var band: int = int(floor((fpy + warp + radius) / 2.5))
var m: int = band % 3
if m < 0: m += 3
return m
PType.ICE:
var v3: float = surface_noise.get_noise_2d(shifted_x, fpy * 1.1)
if v3 > 0.55:
return 2
if absf(v3) < 0.08:
return 1
return 0
PType.LAVA:
var shift: float = sin(spin_angle * 3.0) * 0.03
var v4: float = surface_noise.get_noise_2d(shifted_x, fpy * 1.1)
if v4 > 0.45 + shift: return 2
if v4 > 0.15 + shift: return 1
return 0
PType.TOXIC:
var v5: float = surface_noise.get_noise_2d(shifted_x, fpy * 1.1)
var v6: float = surface_noise.get_noise_2d(shifted_x + v5 * 3.0, fpy * 1.1 + v5 * 3.0)
if v6 > 0.3: return 2
if v6 > 0.0: return 1
return 0
return 0
func _draw_ring_half(canvas: CanvasItem, back: bool) -> void:
# Build band radii (each integer radius = one pixel band). Split by ring_count groups.
var band_width: float = (ring_outer - ring_inner) / float(maxi(ring_count, 1) * 2 + 1)
var a_start: float = PI
var a_end: float = TAU
if not back:
a_start = 0.0
a_end = PI
# For each band
for i in ring_count:
var inner: float = ring_inner + float(i * 2) * band_width
var outer: float = inner + band_width
var col: Color = ring_colors[i]
var rc := Color(col.r, col.g, col.b, alpha * 0.55)
# Sample arc points
var steps: int = int(clampf(outer * 1.6, 20.0, 64.0))
var prev: Vector2 = Vector2.ZERO
var have_prev: bool = false
for s in range(steps + 1):
var a: float = a_start + (a_end - a_start) * float(s) / float(steps)
# For each radius in [inner, outer]
var rad: float = (inner + outer) * 0.5
if _ring_gap_hit(rad):
have_prev = false
continue
var px: float = x + cos(a) * rad
var py: float = y + sin(a) * rad * ring_tilt
if have_prev:
canvas.draw_line(prev, Vector2(px, py), rc, maxf(1.0, band_width))
prev = Vector2(px, py)
have_prev = true
func _ring_gap_hit(rad: float) -> bool:
for g in ring_gaps:
if absf(rad - float(g)) < 0.6:
return true
return false
func _draw_moon(canvas: CanvasItem, mx: float, my: float, m: Dictionary) -> void:
var mc: Color = m["color"]
var ms: float = float(m["size"])
var a_out := Color(mc.r, mc.g, mc.b, alpha)
# Solid body
canvas.draw_rect(Rect2(mx - ms * 0.5, my - ms * 0.5, ms, ms), a_out)
# Type-specific detail
var mtype: int = int(m["mtype"])
match mtype:
0:
# Rocky — darker crater pixel
var dark := Color(mc.r * 0.55, mc.g * 0.55, mc.b * 0.55, alpha)
canvas.draw_rect(Rect2(mx - ms * 0.5 + 1, my - ms * 0.5, 1, 1), dark)
if ms >= 4.0:
canvas.draw_rect(Rect2(mx + ms * 0.5 - 2, my + ms * 0.5 - 2, 1, 1), dark)
1:
# Icy — bright highlight
var hi := Color(1.0, 1.0, 1.0, alpha)
canvas.draw_rect(Rect2(mx - ms * 0.5, my - ms * 0.5, 1, 1), hi)
2:
# Volcanic — red/black speckle
var hot := Color(1.0, 0.6, 0.1, alpha)
var black := Color(0.1, 0.05, 0.05, alpha)
canvas.draw_rect(Rect2(mx - ms * 0.5 + 1, my - ms * 0.5 + 1, 1, 1), hot)
canvas.draw_rect(Rect2(mx + ms * 0.5 - 1, my - ms * 0.5, 1, 1), black)
+1
View File
@@ -0,0 +1 @@
uid://b78slt2taoxyc
+97
View File
@@ -0,0 +1,97 @@
extends Node
var master_volume: float = 1.0
var sfx_muted: bool = false
var music_volume: float = 0.8
var fullscreen: bool = false
var nebula_enabled: bool = true
var star_density: int = 1 # 0=low 1=mid 2=high
var language: String = "de" # "de" or "en"
var touch_mode: int = 0 # 0=auto, 1=always on, 2=always off
# Runtime-only — not saved. Set by input detection in main.gd / touch_controls.gd.
var last_input_device: String = "keyboard" # "keyboard" | "pad" | "touch"
# Rebindable gameplay actions (P1 only).
const REBIND_ACTIONS: Array[String] = ["p1_thrust", "p1_left", "p1_right", "p1_shoot", "p1_wipe"]
# Stores physical keycodes (int) for rebound keys. Empty = use project defaults.
var key_bindings: Dictionary = {}
const PATH := "user://settings.cfg"
func _ready() -> void:
load_settings()
apply_all()
func apply_all() -> void:
apply_volume()
apply_key_bindings()
# Mobile always runs fullscreen — ignore the saved setting.
if OS.has_feature("android") or OS.has_feature("ios"):
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
elif fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
func apply_volume() -> void:
var db := linear_to_db(master_volume) if not sfx_muted else -80.0
AudioServer.set_bus_volume_db(0, db)
func apply_music_volume() -> void:
var bus_idx := AudioServer.get_bus_index("Music")
if bus_idx != -1:
AudioServer.set_bus_volume_db(bus_idx,
linear_to_db(music_volume) if music_volume > 0.0 else -80.0)
func apply_key_bindings() -> void:
for action: String in key_bindings:
var keycode: int = key_bindings[action]
if keycode > 0 and InputMap.has_action(action):
# Remove existing keyboard events for this action
var events := InputMap.action_get_events(action)
for ev in events:
if ev is InputEventKey:
InputMap.action_erase_event(action, ev)
# Add the custom key
var new_ev := InputEventKey.new()
new_ev.physical_keycode = keycode as Key
InputMap.action_add_event(action, new_ev)
func reset_key_bindings() -> void:
InputMap.load_from_project_settings()
key_bindings.clear()
save_settings()
func save_settings() -> void:
var cfg := ConfigFile.new()
cfg.set_value("sound", "volume", master_volume)
cfg.set_value("sound", "muted", sfx_muted)
cfg.set_value("sound", "music", music_volume)
cfg.set_value("graphics", "fullscreen", fullscreen)
cfg.set_value("graphics", "nebula", nebula_enabled)
cfg.set_value("graphics", "stars", star_density)
cfg.set_value("game", "language", language)
cfg.set_value("game", "touch_mode", touch_mode)
for action: String in REBIND_ACTIONS:
if key_bindings.has(action):
cfg.set_value("bindings", action, key_bindings[action])
cfg.save(PATH)
func load_settings() -> void:
var cfg := ConfigFile.new()
if cfg.load(PATH) != OK:
return
master_volume = cfg.get_value("sound", "volume", 1.0)
sfx_muted = cfg.get_value("sound", "muted", false)
music_volume = cfg.get_value("sound", "music", 0.8)
fullscreen = cfg.get_value("graphics", "fullscreen", false)
nebula_enabled = cfg.get_value("graphics", "nebula", true)
star_density = cfg.get_value("graphics", "stars", 1)
language = cfg.get_value("game", "language", "de")
touch_mode = cfg.get_value("game", "touch_mode", 0)
key_bindings.clear()
for action: String in REBIND_ACTIONS:
if cfg.has_section_key("bindings", action):
key_bindings[action] = cfg.get_value("bindings", action)
+1
View File
@@ -0,0 +1 @@
uid://c1yb21fhib2c1
+167
View File
@@ -0,0 +1,167 @@
extends Node2D
var W: float = 960.0
var H: float = 600.0
# Cockpit palette
const COL_BG := Color(0.0, 0.04, 0.02, 0.90)
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 1.0) # phosphor green
const COL_ACCENT := Color(0.27, 1.0, 0.8, 1.0)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.6) # lines / brackets only
const COL_TEXT := Color(0.3, 0.68, 0.46, 0.88) # readable secondary text
const COL_P2 := Color(0.27, 1.0, 0.67, 1.0)
const COL_OVERLAY := Color(0.0, 0.0, 0.04, 0.78)
var is_p2_mode: bool = false
var selected: int = 0
var ships: Array = []
var blink_phase: float = 0.0
var enter_blink: bool = true
var enter_timer: float = 0.0
func start_select(p2_mode: bool, start_sel: int, ship_data: Array) -> void:
is_p2_mode = p2_mode
selected = start_sel
ships = ship_data
visible = true
blink_phase = 0.0
func set_selection(idx: int) -> void:
selected = idx
queue_redraw()
func _process(delta: float) -> void:
blink_phase += delta
enter_timer += delta
if enter_timer >= 0.5:
enter_timer = 0.0
enter_blink = not enter_blink
queue_redraw()
func _draw() -> void:
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
# Background overlay so live simulation shows through
draw_rect(get_viewport_rect(), COL_OVERLAY)
# ── Header bar ───────────────────────────────────────────────────────────
draw_rect(Rect2(0, 0, W, 52.0), Color(0.0, 0.04, 0.02, 0.92))
draw_line(Vector2(0, 52), Vector2(W, 52), COL_DIM, 1.0)
var header_col := COL_PRIMARY if not is_p2_mode else COL_P2
_draw_text_centered(Tr.t("select_header"), W * 0.5, 14.0, 10, COL_TEXT)
var sub := Tr.t("select_pilot1") if not is_p2_mode else Tr.t("select_pilot2")
_draw_text_centered(sub, W * 0.5, 30.0, 13, header_col)
# Outer corner brackets
_draw_corner_brackets(8.0, 8.0, W - 8.0, H - 8.0, COL_DIM, 16.0)
# ── Ship boxes ───────────────────────────────────────────────────────────
# 2×2 Grid Layout (supports 4 ships, falls back for 3)
var n := ships.size()
var cols: int = 2 if n >= 4 else min(n, 3)
var box_w := 220.0; var box_h := 180.0
if cols == 3:
box_w = 210.0; box_h = 220.0
var row_h: float = box_h + 16.0
var col_space: float = (W - box_w * cols) / float(cols + 1)
var grid_start_y: float = 72.0
for i in n:
var col_i: int = i % cols
var row_i: int = int(i / cols)
var bx: float = col_space + col_i * (box_w + col_space)
var by: float = grid_start_y + row_i * row_h
var is_sel := i == selected
var pulse := 0.5 + 0.5 * sin(blink_phase * 3.0)
# Box background
draw_rect(Rect2(bx, by, box_w, box_h), Color(0.0, 0.04, 0.02, 0.72))
# Corner L-brackets instead of full border
var bracket_col: Color
if is_sel:
bracket_col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6 + 0.4 * pulse)
else:
bracket_col = COL_DIM
_draw_corner_brackets(bx, by, bx + box_w, by + box_h, bracket_col, 16.0)
# Ship preview
var cx := bx + box_w * 0.5
var cy := by + box_h * 0.42
var preview_heading := blink_phase * 0.6 if is_sel else -PI * 0.5
_draw_ship_preview(cx, cy, preview_heading, ships[i])
# Ship name
var name_col := COL_ACCENT if is_sel else COL_TEXT
_draw_text_centered(ships[i]["name"], cx, by + box_h - 42.0, 12, name_col)
# Ship identifier or boost hint
var sub_str: String
match ships[i]["id"]:
"classic": sub_str = "1 SHIELD · BALANCED"
"inferno": sub_str = "SPEED+ · RAM DAMAGE"
"aurora": sub_str = "2 SHIELDS · BH RESIST"
"titan": sub_str = "[SHIFT] BOOST"
_: sub_str = ships[i]["id"].to_upper()
_draw_text_centered(sub_str, cx, by + box_h - 24.0, 8,
Color(COL_TEXT.r, COL_TEXT.g, COL_TEXT.b, 0.85))
# ── Footer ───────────────────────────────────────────────────────────────
draw_rect(Rect2(0, H - 48.0, W, 48.0), Color(0.0, 0.04, 0.02, 0.92))
draw_line(Vector2(0, H - 48.0), Vector2(W, H - 48.0), COL_DIM, 1.0)
_draw_text_centered(Tr.hint("select_choose"), W * 0.5, H - 44.0, 9, COL_TEXT)
if not is_p2_mode:
if enter_blink:
_draw_text_centered(Tr.hint("select_confirm"), W * 0.5, H - 26.0, 11, COL_PRIMARY)
else:
_draw_text_centered(Tr.hint("select_join"), W * 0.3, H - 26.0, 10,
COL_P2 if enter_blink else COL_DIM)
_draw_text_centered(Tr.hint("select_solo"), W * 0.7, H - 26.0, 10,
COL_PRIMARY if enter_blink else COL_DIM)
# ── Drawing helpers ───────────────────────────────────────────────────────────
func _draw_corner_brackets(x1: float, y1: float, x2: float, y2: float,
col: Color, arm: float) -> void:
# Top-left
draw_line(Vector2(x1, y1), Vector2(x1 + arm, y1), col, 1.5)
draw_line(Vector2(x1, y1), Vector2(x1, y1 + arm), col, 1.5)
# Top-right
draw_line(Vector2(x2, y1), Vector2(x2 - arm, y1), col, 1.5)
draw_line(Vector2(x2, y1), Vector2(x2, y1 + arm), col, 1.5)
# Bottom-left
draw_line(Vector2(x1, y2), Vector2(x1 + arm, y2), col, 1.5)
draw_line(Vector2(x1, y2), Vector2(x1, y2 - arm), col, 1.5)
# Bottom-right
draw_line(Vector2(x2, y2), Vector2(x2 - arm, y2), col, 1.5)
draw_line(Vector2(x2, y2), Vector2(x2, y2 - arm), col, 1.5)
func _draw_ship_preview(cx: float, cy: float, heading: float, ship: Dictionary) -> void:
var cos_h := cos(heading); var sin_h := sin(heading)
var preview_scale := 5.5
var hull_pixels := [
[3, 0, "nose"], [2, -1, "mid"], [2, 1, "mid"],
[1, 0, "dim"], [0, -2, "accent"],[0, 2, "accent"],
[0, 0, "bright"],[-1,-1, "dim"], [-1, 1, "dim"],
[-2,-2, "shadow"],[-2, 2, "shadow"],[-2, 0, "edge"],
]
for px_data in hull_pixels:
var lx: float = px_data[0] * preview_scale
var ly: float = px_data[1] * preview_scale
var col: Color = ship.get(px_data[2], Color.WHITE)
var rx := cx + lx * cos_h - ly * sin_h
var ry := cy + lx * sin_h + ly * cos_h
draw_rect(Rect2(rx - 2, ry - 2, 5, 5), col)
func _draw_text_centered(text: String, x: float, y: float, font_size: int, col: Color) -> void:
var font := ThemeDB.fallback_font
var tw := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x
draw_string(font, Vector2(x - tw * 0.5, y + font_size), text,
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, col)
func _draw_text(text: String, x: float, y: float, font_size: int, col: Color) -> void:
var font := ThemeDB.fallback_font
draw_string(font, Vector2(x, y + font_size), text,
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, col)
+1
View File
@@ -0,0 +1 @@
uid://bc1kys1vqyll6
+50
View File
@@ -0,0 +1,50 @@
extends RefCounted
class_name ShipStats
# All multiplicative / additive upgrades for one player's run.
# Stats stack: each item applies via apply_item_def(def) oder apply_item(effects).
var ship_id: String = "" # set at game start; used for per-ship runtime mechanics
var speed_mult: float = 1.0 # scales THRUST and MAX_SPEED
var turn_mult: float = 1.0 # scales TURN_SPEED
var fire_rate_mult: float = 1.0 # divides BULLET_COOLDOWN_MAX (higher = faster fire)
var damage_mult: float = 1.0 # scales bullet hit radius; >= 2.0 → pierce
var bullet_speed_mult: float = 1.0 # scales bullet travel speed
var bullet_count: int = 1 # shots per trigger press
var shield_charges: int = 0 # absorbs N hits before death
var invuln_mult: float = 1.0 # scales INVULN_FRAMES after hit
var bh_resist: float = 0.0 # 0.00.9: fraction of BH pull negated
var wipe_mult: float = 1.0 # scales BigWipe hold-time requirement
var credit_bonus: float = 1.0 # multiplier on all credits earned
# ─── Werkstatt-spezifische Felder ─────────────────────────────────────────────
var hull_scale: float = 3.0 # px per hull pixel (Basis 3.0, Max 4.5)
var owned_item_ids: Array = [] # IDs für visuelle Attachments am Schiff
var has_boost: bool = false # ist Boost-Taste aktiviert? (TITAN)
var boost_cooldown_max: float = 5.0 # Sekunden zwischen Boosts
# Wendet einen rohen effects-Dict an (Legacy-Shop-Path).
func apply_item(effects: Dictionary) -> void:
for key: String in effects:
var val = effects[key]
match key:
"speed_mult": speed_mult *= float(val)
"turn_mult": turn_mult *= float(val)
"fire_rate_mult": fire_rate_mult *= float(val)
"damage_mult": damage_mult *= float(val)
"bullet_speed_mult": bullet_speed_mult *= float(val)
"bullet_count": bullet_count += int(val)
"shield_charges": shield_charges = max(0, shield_charges + int(val))
"invuln_mult": invuln_mult *= float(val)
"bh_resist": bh_resist = min(0.9, bh_resist + float(val))
"wipe_mult": wipe_mult *= float(val)
"credit_bonus": credit_bonus *= float(val)
# Clamp bullet_count auf minimum 1
bullet_count = max(1, bullet_count)
# Wendet einen ItemDef an (neuer Werkstatt-Path).
func apply_item_def(def: ItemDef) -> void:
if def == null: return
apply_item(def.effects)
hull_scale = min(4.5, hull_scale + def.hull_size_bonus)
owned_item_ids.append(def.id)
+1
View File
@@ -0,0 +1 @@
uid://c1b87vp4lr17r
+605
View File
@@ -0,0 +1,605 @@
extends Node2D
# ─── Werkstatt UI ─────────────────────────────────────────────────────────────
# Zwei Phasen:
# PHASE_ATTR — 3 kostenlose Attribut-Upgrades (Pflicht, eins auswählen)
# PHASE_SHOP — Werkstatt: beliebig viele Items kaufen, SPACE beendet
#
# Items kommen aus ItemDB.roll_werkstatt() (Plugin-System unter res://items/).
# Gekaufte Items werden via stats.apply_item_def(def) angewendet und erscheinen
# visuell am Schiff-Preview im Zentrum.
# Emits: shop_closed(remaining_credits, updated_stats, owned_item_ids)
signal shop_closed(remaining_credits: int, new_stats: ShipStats, items: Array, reroll_count: int)
# ─── Konstanten ───────────────────────────────────────────────────────────────
const REROLL_BASE_COST := 60
const REROLL_STEP_COST := 42
const SHOP_CARD_COUNT := 4
const ATTR_CARD_COUNT := 3
# Cockpit palette
const COL_BG := Color(0.0, 0.0, 0.04, 0.92)
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 1.0)
const COL_ACCENT := Color(0.27, 1.0, 0.8, 1.0)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.55)
const COL_WHITE := Color(1.0, 1.0, 1.0, 0.90)
const COL_WARN := Color(1.0, 0.67, 0.0, 0.90)
const COL_POS := Color(0.3, 1.0, 0.4, 1.0)
const COL_NEG := Color(1.0, 0.35, 0.35, 1.0)
# ─── Attribut-Pool (Phase 1 — gratis, reine Buffs) ────────────────────────────
# Jedes Attribut ist ein Dictionary wie ein ItemDef-Effect.
const ATTR_POOL: Array = [
{ "id": "a_speed", "name": "Schubverstärker", "name_en": "Thruster", "desc": "+8% Geschwindigkeit", "desc_en": "+8% Speed", "effects": { "speed_mult": 1.08 } },
{ "id": "a_turn", "name": "Agilität", "name_en": "Agility", "desc": "+12% Wendigkeit", "desc_en": "+12% Handling", "effects": { "turn_mult": 1.12 } },
{ "id": "a_fire", "name": "Schnellfeuer", "name_en": "Rapid Fire", "desc": "+12% Feuerrate", "desc_en": "+12% Fire Rate", "effects": { "fire_rate_mult": 1.12 } },
{ "id": "a_damage", "name": "Schwere Ladung", "name_en": "Heavy Load", "desc": "+12% Schaden", "desc_en": "+12% Damage", "effects": { "damage_mult": 1.12 } },
{ "id": "a_proj", "name": "Lineare Ballistik","name_en": "Linear Ballistics", "desc": "+12% Projektilgeschw.", "desc_en": "+12% Projectile Speed", "effects": { "bullet_speed_mult": 1.12 } },
{ "id": "a_shield", "name": "Panzerplatte", "name_en": "Armor Plate", "desc": "+1 Schutzladung", "desc_en": "+1 Shield Charge", "effects": { "shield_charges": 1 } },
{ "id": "a_invuln", "name": "Schildgenerator", "name_en": "Shield Generator", "desc": "+18% Unverwundbarkeit", "desc_en": "+18% Invulnerability", "effects": { "invuln_mult": 1.18 } },
{ "id": "a_credits", "name": "Profitoptimierer", "name_en": "Profit Optimizer", "desc": "+8% Kreditgewinn", "desc_en": "+8% Credit Gain", "effects": { "credit_bonus": 1.08 } },
]
# ─── Phasen-State ─────────────────────────────────────────────────────────────
enum Phase { ATTR, SHOP }
var _phase: int = Phase.ATTR
# ─── Session-State ────────────────────────────────────────────────────────────
var _lives: int = 3
var _credits: int = 0
var _stats: ShipStats = null
var _owned_ids: Array = []
var _player_label: String = ""
var _ship_palette: Dictionary = {}
var _wave: int = 1
var _player_idx: int = 1 # 1 = P1 (Pfeiltasten/Enter/Space), 2 = P2 (ADWEF/Q)
# Phase 1
var _attr_choices: Array = [] # 3 zufällige Attribute
var _attr_cursor: int = 0
# Phase 2
var _shop_cards: Array = [] # aktuelle 4 ItemDef-Objekte
var _shop_cursor: int = 0
var _reroll_count: int = 0
# UI
var _blink: float = 0.0
var W: float = 960.0
var H: float = 600.0
# Touch state (direct InputEventScreenTouch handling)
var _tc_id: int = -1
var _tc_start: Vector2 = Vector2.ZERO
# ──────────────────────────────────────────────────────────────────────────────
func open(lives: int, credits: int, stats: ShipStats, owned: Array,
player_label: String = "", ship_palette: Dictionary = {},
wave: int = 1, reroll_count: int = 0, player_idx: int = 1) -> void:
_lives = lives
_credits = credits
_stats = stats if stats != null else ShipStats.new()
_owned_ids = owned.duplicate()
_player_label = player_label
_ship_palette = ship_palette
_wave = wave
# Attribut-Phase nur in ungeraden Wellen (1, 3, 5, ...). Gerade Wellen
# starten direkt im Shop — langsamere Power-Kurve.
_phase = Phase.ATTR if (wave % 2) == 1 else Phase.SHOP
_attr_cursor = 0
_shop_cursor = 0
# Reroll-Counter persistiert über Wellen hinweg (von main.gd durchgereicht).
_reroll_count = reroll_count
_player_idx = player_idx
_blink = 0.0
if _phase == Phase.ATTR:
_roll_attr()
_roll_shop()
visible = true
func _roll_attr() -> void:
var pool := ATTR_POOL.duplicate()
pool.shuffle()
_attr_choices = pool.slice(0, ATTR_CARD_COUNT)
func _roll_shop() -> void:
# Exclude already-owned item IDs so you don't see the same thing twice in one roll
_shop_cards = ItemDB.roll_werkstatt(SHOP_CARD_COUNT, _owned_ids)
func _process(delta: float) -> void:
if not visible: return
_blink += delta
queue_redraw()
# ─── Input ────────────────────────────────────────────────────────────────────
func _input(event: InputEvent) -> void:
if not visible: return
if not event is InputEventScreenTouch: return
if event.pressed:
if _tc_id == -1:
_tc_id = event.index
_tc_start = event.position
elif event.index == _tc_id:
_tc_id = -1
var d: Vector2 = event.position - _tc_start
if d.length() < 32.0:
_touch_tap(event.position)
elif abs(d.x) >= abs(d.y):
_touch_nav("ui_left" if d.x < 0 else "ui_right")
else:
_touch_nav("ui_up" if d.y < 0 else "ui_down")
get_viewport().set_input_as_handled()
func _touch_tap(p: Vector2) -> void:
if _phase == Phase.ATTR:
var card_w := 230.0; var card_h := 140.0
var n := _attr_choices.size()
var total_w: float = card_w * n + 20.0 * (n - 1)
var start_x: float = (W - total_w) * 0.5
var cy: float = H * 0.5 - 30.0 - card_h * 0.5 + 40.0
for i in n:
var cx: float = start_x + i * (card_w + 20.0)
if Rect2(cx, cy, card_w, card_h).has_point(p):
_attr_cursor = i
_confirm_attr()
return
else:
var card_w := 250.0; var card_h := 140.0
var card_pos: Array = [
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - card_h - 50.0),
Vector2(W * 0.5 + 120.0, H * 0.5 - card_h - 50.0),
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - 10.0),
Vector2(W * 0.5 + 120.0, H * 0.5 - 10.0),
]
for i in min(_shop_cards.size(), card_pos.size()):
if Rect2(card_pos[i].x, card_pos[i].y, card_w, card_h).has_point(p):
_shop_cursor = i
_try_buy(i)
return
if p.y >= H - 52.0:
if p.x < W * 0.5:
_try_reroll()
else:
_close()
func _touch_nav(action: String) -> void:
if _phase == Phase.ATTR:
match action:
"ui_left": _attr_cursor = (_attr_cursor - 1 + _attr_choices.size()) % _attr_choices.size()
"ui_right": _attr_cursor = (_attr_cursor + 1) % _attr_choices.size()
else:
match action:
"ui_left": _move_shop_cursor(-1)
"ui_right": _move_shop_cursor(1)
"ui_up": _move_shop_cursor(-2)
"ui_down": _move_shop_cursor(2)
func _unhandled_input(event: InputEvent) -> void:
if not visible: return
if _phase == Phase.ATTR:
_handle_attr_input(event)
else:
_handle_shop_input(event)
func _handle_attr_input(event: InputEvent) -> void:
if _player_idx == 2:
if event.is_action_pressed("p2_left"):
_attr_cursor = (_attr_cursor - 1 + _attr_choices.size()) % _attr_choices.size()
elif event.is_action_pressed("p2_right"):
_attr_cursor = (_attr_cursor + 1) % _attr_choices.size()
elif event.is_action_pressed("p2_shoot"):
_confirm_attr()
else:
if event.is_action_pressed("ui_left"):
_attr_cursor = (_attr_cursor - 1 + _attr_choices.size()) % _attr_choices.size()
elif event.is_action_pressed("ui_right"):
_attr_cursor = (_attr_cursor + 1) % _attr_choices.size()
elif event is InputEventKey and event.pressed and event.keycode == KEY_SPACE:
_confirm_attr()
elif event.is_action_pressed("ui_accept"):
_confirm_attr()
func _handle_shop_input(event: InputEvent) -> void:
if _player_idx == 2:
if event.is_action_pressed("p2_wipe") or event.is_action_pressed("ui_cancel"):
_close()
elif event.is_action_pressed("p2_left"):
_move_shop_cursor(-1)
elif event.is_action_pressed("p2_right"):
_move_shop_cursor(1)
elif event.is_action_pressed("p2_thrust"):
_move_shop_cursor(-2)
elif event.is_action_pressed("p2_boost"):
_try_reroll()
elif event.is_action_pressed("p2_shoot"):
_try_buy(_shop_cursor)
else:
if event is InputEventKey and event.pressed:
if event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE:
_close()
return
elif event.keycode == KEY_ENTER:
_try_buy(_shop_cursor)
return
elif event.keycode == KEY_R:
_try_reroll()
return
if event.is_action_pressed("ui_cancel"):
_close()
elif event.is_action_pressed("ui_left"):
_move_shop_cursor(-1)
elif event.is_action_pressed("ui_right"):
_move_shop_cursor(1)
elif event.is_action_pressed("ui_up"):
_move_shop_cursor(-2)
elif event.is_action_pressed("ui_down"):
_move_shop_cursor(2)
elif event.is_action_pressed("ui_accept"):
_try_buy(_shop_cursor)
func _move_shop_cursor(delta: int) -> void:
if _shop_cards.is_empty(): return
_shop_cursor = (_shop_cursor + delta + _shop_cards.size()) % _shop_cards.size()
func _confirm_attr() -> void:
if _attr_cursor >= _attr_choices.size(): return
var choice: Dictionary = _attr_choices[_attr_cursor]
_stats.apply_item(choice["effects"])
_phase = Phase.SHOP
func _try_buy(idx: int) -> void:
if idx < 0 or idx >= _shop_cards.size(): return
var def: ItemDef = _shop_cards[idx]
if _credits < def.cost: return
_credits -= def.cost
_stats.apply_item_def(def)
_owned_ids.append(def.id)
_shop_cards.remove_at(idx)
if _shop_cards.is_empty():
_shop_cursor = 0
else:
_shop_cursor = min(_shop_cursor, _shop_cards.size() - 1)
func _try_reroll() -> void:
var cost: int = REROLL_BASE_COST + _reroll_count * REROLL_STEP_COST
if _credits < cost: return
_credits -= cost
_reroll_count += 1
_roll_shop()
_shop_cursor = 0
func _close() -> void:
visible = false
shop_closed.emit(_credits, _stats, _owned_ids, _reroll_count)
# ═══════════════════════════════════════════════════════════════════════════════
# Drawing
# ═══════════════════════════════════════════════════════════════════════════════
func _draw() -> void:
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
draw_rect(get_viewport_rect(), COL_BG)
_draw_header()
_draw_brackets(8, 8, W - 8, H - 8, COL_DIM, 16)
if _phase == Phase.ATTR:
_draw_attr_phase()
else:
_draw_shop_phase()
_draw_footer()
# ── Header ────────────────────────────────────────────────────────────────────
func _draw_header() -> void:
draw_rect(Rect2(0, 0, W, 50.0), Color(0.0, 0.04, 0.02, 0.95))
draw_line(Vector2(0, 50), Vector2(W, 50), COL_DIM, 1.0)
var phase_key: String = "werk_phase_attr" if _phase == Phase.ATTR else "werk_phase_shop"
var wave_str := Tr.t("werk_wave") % _wave
if _player_label != "":
_draw_text_c(_player_label, W * 0.5, 10.0, 9, COL_DIM)
_draw_text_c(Tr.t(phase_key) + wave_str, W * 0.5, 26.0, 13, COL_PRIMARY)
# Lives + credits oben rechts
var lives_str := ""
for li in 3:
lives_str += ("" if li < _lives else "")
_draw_text(lives_str + " CR: %d" % _credits, W - 190.0, 14.0, 11, COL_PRIMARY)
# ── Attribut-Phase ────────────────────────────────────────────────────────────
func _draw_attr_phase() -> void:
var y_mid := H * 0.5 - 30.0
_draw_text_c(Tr.t("werk_attr_prompt"), W * 0.5, 76.0, 11, COL_ACCENT)
var card_w := 230.0; var card_h := 140.0
var n: int = _attr_choices.size()
var total_w: float = card_w * n + 20.0 * (n - 1)
var start_x: float = (W - total_w) * 0.5
for i in n:
var choice: Dictionary = _attr_choices[i]
var cx: float = start_x + i * (card_w + 20.0)
var cy: float = y_mid - card_h * 0.5 + 40.0
var is_sel: bool = i == _attr_cursor
var pulse: float = 0.5 + 0.5 * sin(_blink * 3.0)
draw_rect(Rect2(cx, cy, card_w, card_h),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.10))
var bc: Color = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.55 + 0.45 * pulse) if is_sel else COL_DIM
_draw_brackets(cx, cy, cx + card_w, cy + card_h, bc, 14)
_draw_text_c(Tr.t("werk_attr_tag"), cx + card_w * 0.5, cy + 10.0, 8,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6))
var _en := Settings.language == "en"
_draw_text_c(choice["name_en"] if _en else choice["name"], cx + card_w * 0.5, cy + 32.0, 13, COL_WHITE)
_draw_text_c(choice["desc_en"] if _en else choice["desc"], cx + card_w * 0.5, cy + 62.0, 10, COL_POS)
if is_sel:
var hint_col := Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6 + 0.4 * pulse)
var pick_key: String = "werk_pick_p2" if _player_idx == 2 else "werk_pick"
_draw_text_c(Tr.hint(pick_key), cx + card_w * 0.5, cy + card_h - 24.0, 10, hint_col)
# ── Werkstatt-Phase ───────────────────────────────────────────────────────────
func _draw_shop_phase() -> void:
# Schiff-Preview in der Mitte (leicht nach oben verschoben)
_draw_ship_preview(W * 0.5, H * 0.5 - 15.0)
# 2×2 Karten um das Schiff herum — nach oben geschoben, damit Vorschau-Panel Platz hat
var card_w := 250.0; var card_h := 140.0
var positions: Array = [
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - card_h - 50.0), # Top-left y=110
Vector2(W * 0.5 + 120.0, H * 0.5 - card_h - 50.0), # Top-right y=110
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - 10.0), # Bottom-left y=290
Vector2(W * 0.5 + 120.0, H * 0.5 - 10.0), # Bottom-right y=290
]
for i in _shop_cards.size():
var def: ItemDef = _shop_cards[i]
var p: Vector2 = positions[i] if i < positions.size() else Vector2(20, 80 + i * 40)
_draw_shop_card(def, p.x, p.y, card_w, card_h, i == _shop_cursor)
# Vorschau-Panel — unterhalb der unteren Kartenreihe (y=430), mit 8px Abstand
if not _shop_cards.is_empty() and _shop_cursor < _shop_cards.size():
_draw_preview_panel(_shop_cards[_shop_cursor], W * 0.5, H - 119.0)
# Owned items Banner (oben unter dem Header)
if _owned_ids.size() > 0:
_draw_owned_banner(W * 0.5, 60.0)
func _draw_shop_card(def: ItemDef, x: float, y: float, w: float, h: float, is_sel: bool) -> void:
var pulse: float = 0.5 + 0.5 * sin(_blink * 3.0)
var can_afford: bool = _credits >= def.cost
var rarity_col: Color = ItemDB.RARITY_COLORS.get(def.rarity, COL_PRIMARY)
var bg_a: float = 0.18 if can_afford else 0.08
draw_rect(Rect2(x, y, w, h), Color(rarity_col.r, rarity_col.g, rarity_col.b, bg_a))
var border_col: Color
if is_sel:
border_col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.55 + 0.45 * pulse)
else:
border_col = Color(rarity_col.r, rarity_col.g, rarity_col.b, 0.35 if can_afford else 0.15)
_draw_brackets(x, y, x + w, y + h, border_col, 12)
# Icon + Kategorie
var _item_en := Settings.language == "en"
var disp_cat := def.category_en if _item_en and def.category_en != "" else def.category
var disp_name := def.name_en if _item_en and def.name_en != "" else def.name
_draw_text(def.icon + " " + disp_cat, x + 12.0, y + 8.0, 8,
Color(rarity_col.r, rarity_col.g, rarity_col.b, 0.7 if can_afford else 0.3))
# Name
var name_a: float = 0.9 if can_afford else 0.35
_draw_text(disp_name, x + 12.0, y + 26.0, 13, Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b, name_a))
# Effekte (bis zu 4 Zeilen, + grün / rot)
var line_y: float = y + 58.0
var count := 0
for key: String in def.effects:
if count >= 4: break
var val = def.effects[key]
var is_pos: bool = ItemDB.is_positive_effect(key, val)
var sign_str: String = "+ " if is_pos else " "
var col: Color = COL_POS if is_pos else COL_NEG
if not can_afford:
col = Color(col.r, col.g, col.b, 0.35)
var line: String = "%s%s %s" % [sign_str, ItemDB.stat_display_name(key), _fmt_val(key, val)]
_draw_text(line, x + 16.0, line_y, 9, col)
line_y += 13.0
count += 1
# Cost unten rechts
var cost_col: Color = COL_WARN if not can_afford else Color(1.0, 1.0, 0.6, 0.95)
_draw_text("%d CR" % def.cost, x + w - 60.0, y + h - 20.0, 12, cost_col)
# Hint auf aktiver Karte
if is_sel:
var hint_col: Color = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6 + 0.4 * pulse)
var hint: String = Tr.hint("werk_buy_hint_p2" if _player_idx == 2 else "werk_buy_hint_p1") if can_afford else Tr.t("shop_no_credits")
_draw_text(hint, x + 12.0, y + h - 20.0, 9, hint_col)
# ── Preview-Panel: Vorher/Nachher-Diff ────────────────────────────────────────
func _draw_preview_panel(def: ItemDef, cx: float, cy: float) -> void:
var pw := 420.0; var ph := 86.0
var px := cx - pw * 0.5; var py := cy - ph * 0.5
draw_rect(Rect2(px, py, pw, ph), Color(0.0, 0.04, 0.02, 0.9))
_draw_brackets(px, py, px + pw, py + ph, COL_DIM, 10)
_draw_text_c(Tr.t("werk_preview") + " · " + def.name,
cx, py + 6.0, 9, COL_ACCENT)
# Liste: key: before → after
var col_x: float = px + 14.0
var line_y: float = py + 26.0
var count := 0
for key: String in def.effects:
if count >= 3: break
var val = def.effects[key]
var before: float = _stat_value(key)
var after: float = _project_stat(key, val)
var pos: bool = ItemDB.is_positive_effect(key, val)
var col: Color = COL_POS if pos else COL_NEG
var line := "%s: %.2f%.2f" % [ItemDB.stat_display_name(key), before, after]
_draw_text(line, col_x, line_y, 10, col)
line_y += 14.0
count += 1
# Hull-Size Warnung
if def.hull_size_bonus > 0.0:
_draw_text(Tr.t("werk_grows"), px + pw - 150.0, py + ph - 22.0, 9, COL_WARN)
func _stat_value(key: String) -> float:
match key:
"speed_mult": return _stats.speed_mult
"turn_mult": return _stats.turn_mult
"fire_rate_mult": return _stats.fire_rate_mult
"damage_mult": return _stats.damage_mult
"bullet_speed_mult": return _stats.bullet_speed_mult
"bullet_count": return float(_stats.bullet_count)
"shield_charges": return float(_stats.shield_charges)
"invuln_mult": return _stats.invuln_mult
"bh_resist": return _stats.bh_resist
"wipe_mult": return _stats.wipe_mult
"credit_bonus": return _stats.credit_bonus
return 0.0
func _project_stat(key: String, val) -> float:
match key:
"speed_mult","turn_mult","fire_rate_mult","damage_mult", \
"bullet_speed_mult","invuln_mult","wipe_mult","credit_bonus":
return _stat_value(key) * float(val)
"bullet_count","shield_charges":
return _stat_value(key) + float(val)
"bh_resist":
return min(0.9, _stat_value(key) + float(val))
return _stat_value(key)
func _fmt_val(key: String, val) -> String:
match key:
"bullet_count": return "+%d" % int(val)
"shield_charges": return ("+%d" % int(val)) if int(val) >= 0 else "%d" % int(val)
"bh_resist": return "+%.0f%%" % (float(val) * 100.0)
_:
var f: float = float(val)
var pct: float = (f - 1.0) * 100.0
return "%+.0f%%" % pct
# ── Ship preview im Zentrum ───────────────────────────────────────────────────
func _draw_ship_preview(cx: float, cy: float) -> void:
var bw := 180.0; var bh := 180.0
draw_rect(Rect2(cx - bw * 0.5, cy - bh * 0.5, bw, bh),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.05))
_draw_brackets(cx - bw * 0.5, cy - bh * 0.5, cx + bw * 0.5, cy + bh * 0.5,
COL_DIM, 14)
_draw_text_c(Tr.t("werk_ship_preview"), cx, cy - bh * 0.5 + 6.0, 8,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.55))
# Schiff zeichnen — gleich wie Spaceship.HULL_PIXELS, skaliert
var hs: float = _stats.hull_scale if _stats != null else 3.0
var preview_scale: float = 3.5 # Extra-Zoom für Preview
var off_mul: float = hs / 3.0 * preview_scale
var rect_sz: float = hs * preview_scale * 0.9
var rect_half: float = rect_sz * 0.5
var heading: float = -PI * 0.5 # Schiff zeigt nach oben
var cos_h: float = cos(heading); var sin_h: float = sin(heading)
for px_data in Spaceship.HULL_PIXELS:
var lx: float = px_data[0] * off_mul
var ly: float = px_data[1] * off_mul
var col: Color = _ship_palette.get(px_data[2], COL_WHITE)
var rx: float = cx + lx * cos_h - ly * sin_h
var ry: float = cy + lx * sin_h + ly * cos_h
draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)
# Attachments aus owned_items
var seen: Dictionary = {}
for id: String in _owned_ids:
var def: ItemDef = ItemDB.get_def_by_id(id)
if def == null or def.visual_pixels.is_empty(): continue
var stack: int = int(seen.get(id, 0))
seen[id] = stack + 1
var stack_off: float = float(stack) * 0.6
for vpx in def.visual_pixels:
var lx: float = (float(vpx[0]) + stack_off) * off_mul
var ly: float = float(vpx[1]) * off_mul
var col: Color = _ship_palette.get(vpx[2], COL_WHITE)
var rx: float = cx + lx * cos_h - ly * sin_h
var ry: float = cy + lx * sin_h + ly * cos_h
draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)
func _draw_owned_banner(cx: float, cy: float) -> void:
var names: Array = []
for id: String in _owned_ids:
var def: ItemDef = ItemDB.get_def_by_id(id)
if def != null:
names.append(def.name)
if names.is_empty(): return
var line := Tr.t("shop_owned") + " " + " · ".join(names)
if line.length() > 100:
line = line.substr(0, 97) + ""
_draw_text_c(line, cx, cy, 9, Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.5))
# ── Footer ────────────────────────────────────────────────────────────────────
func _draw_footer() -> void:
draw_rect(Rect2(0, H - 52.0, W, 52.0), Color(0.0, 0.04, 0.02, 0.95))
draw_line(Vector2(0, H - 52.0), Vector2(W, H - 52.0), COL_DIM, 1.0)
var p2: bool = _player_idx == 2
var is_touch: bool = Settings.last_input_device == "touch"
var pulse: float = 0.5 + 0.4 * sin(_blink * 2.5)
if _phase == Phase.ATTR:
if is_touch:
_draw_text_c("● TAP", W * 0.5, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
else:
_draw_text_c(Tr.hint("werk_attr_footer_p2" if p2 else "werk_attr_footer"), W * 0.5, H - 48.0, 9, COL_DIM)
_draw_text_c(Tr.hint("werk_confirm_p2" if p2 else "werk_confirm"), W * 0.5, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
else:
var reroll_cost: int = REROLL_BASE_COST + _reroll_count * REROLL_STEP_COST
var reroll_col: Color = COL_DIM if _credits < reroll_cost else COL_PRIMARY
if is_touch:
# Touch: left half = REROLL tap zone, right half = SKIP tap zone
draw_rect(Rect2(0, H - 52.0, W * 0.5 - 1, 52.0),
Color(reroll_col.r, reroll_col.g, reroll_col.b, 0.06))
draw_rect(Rect2(W * 0.5 + 1, H - 52.0, W * 0.5 - 1, 52.0),
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.06))
var reroll_str: String = ("REROLL %d CR" if Settings.language == "en" else "REROLL %d CR") % reroll_cost
_draw_text_c(reroll_str, W * 0.25, H - 26.0, 10, reroll_col)
var skip_str: String = "SKIP ►" if Settings.language == "en" else "WEITER ►"
_draw_text_c(skip_str, W * 0.75, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
else:
_draw_text_c(Tr.hint("werk_shop_footer_p2" if p2 else "werk_shop_footer"), W * 0.5, H - 48.0, 9, COL_DIM)
var reroll_key: String = "Q" if p2 else "R"
_draw_text_c(Tr.t("werk_reroll") % [reroll_key, reroll_cost], W * 0.30, H - 26.0, 10, reroll_col)
_draw_text_c(Tr.hint("werk_continue_p2" if p2 else "shop_continue"), W * 0.72, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
# ═══════════════════════════════════════════════════════════════════════════════
# Text + Shape Helpers
# ═══════════════════════════════════════════════════════════════════════════════
func _draw_text(text: String, x: float, y: float, sz: int, col: Color) -> void:
var font := ThemeDB.fallback_font
draw_string(font, Vector2(x, y + sz), text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_text_c(text: String, x: float, y: float, sz: int, col: Color) -> void:
var font := ThemeDB.fallback_font
var tw: float = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
draw_string(font, Vector2(x - tw * 0.5, y + sz), text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_brackets(x1: float, y1: float, x2: float, y2: float, col: Color, arm: float) -> void:
var bw: float = 1.5
draw_line(Vector2(x1, y1), Vector2(x1 + arm, y1), col, bw)
draw_line(Vector2(x1, y1), Vector2(x1, y1 + arm), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2 - arm, y1), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2, y1 + arm), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1 + arm, y2), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1, y2 - arm), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2 - arm, y2), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2, y2 - arm), col, bw)
+1
View File
@@ -0,0 +1 @@
uid://18s6j8gme2ne
+75
View File
@@ -0,0 +1,75 @@
extends Node
# SoundManager - Autoload singleton for all SFX
var _bus_sfx := 0
func _ready() -> void:
_bus_sfx = AudioServer.get_bus_index("Master")
func play_sfx(sfx_name: String) -> void:
match sfx_name:
"player_shoot": _play_tone(880.0, 0.07, "sine", -18.0, 440.0)
"enemy_shoot": _play_tone(220.0, 0.09, "saw", -20.0, 110.0)
"enemy_die": _play_noise(0.2, -16.0); _play_tone(180.0, 0.15, "square", -18.0, 60.0)
"player_die": _play_noise(0.8, -14.0); _play_tone(120.0, 0.8, "sine", -16.0, 30.0)
"antimatter_hit": _play_tone(1200.0, 0.3, "saw", -16.0, 300.0); _play_noise(0.2, -18.0)
"bh_swallow": _play_tone(200.0, 0.22, "sine", -20.0, 40.0)
"wipe_start": _play_tone(35.0, 2.3, "sine", -22.0, 35.0)
"wipe_flash": _play_noise(0.4, -16.0); _play_tone(80.0, 0.6, "sine", -18.0, 20.0)
"wipe_survived": _play_chord_fanfare()
"smbh_spawn": _play_tone(60.0, 1.0, "sine", -18.0, 20.0); _play_noise(0.5, -16.0)
"quasar_boost": _play_tone(280.0, 0.45, "sine", -15.0, 1500.0); _play_noise(0.15, -22.0)
"antimatter_swarm":_play_noise(0.28, -18.0)
func _play_tone(freq: float, duration: float, wave: String, vol_db: float, end_freq: float) -> void:
var gen := AudioStreamGenerator.new()
gen.mix_rate = 44100.0
gen.buffer_length = duration
var player := AudioStreamPlayer.new()
player.stream = gen
player.volume_db = vol_db
player.autoplay = false
add_child(player)
player.play()
var playback: AudioStreamGeneratorPlayback = player.get_stream_playback()
var frames := int(44100.0 * duration)
var phase := 0.0
for i in frames:
var t := float(i) / float(frames)
var cur_freq := freq + (end_freq - freq) * t
phase += cur_freq / 44100.0
var s := 0.0
match wave:
"sine": s = sin(phase * TAU)
"saw": s = fmod(phase, 1.0) * 2.0 - 1.0
"square": s = 1.0 if sin(phase * TAU) > 0 else -1.0
var env := 1.0 - t
playback.push_frame(Vector2(s * env, s * env))
var timer := get_tree().create_timer(duration + 0.1)
timer.timeout.connect(player.queue_free)
func _play_noise(duration: float, vol_db: float) -> void:
var gen := AudioStreamGenerator.new()
gen.mix_rate = 44100.0
gen.buffer_length = duration
var player := AudioStreamPlayer.new()
player.stream = gen
player.volume_db = vol_db
add_child(player)
player.play()
var playback: AudioStreamGeneratorPlayback = player.get_stream_playback()
var frames := int(44100.0 * duration)
for i in frames:
var t := float(i) / float(frames)
var s := randf_range(-1.0, 1.0) * (1.0 - t)
playback.push_frame(Vector2(s, s))
var timer := get_tree().create_timer(duration + 0.1)
timer.timeout.connect(player.queue_free)
func _play_chord_fanfare() -> void:
var freqs: Array[float] = [261.6, 329.6, 392.0]
for i in freqs.size():
var delay_timer := get_tree().create_timer(i * 0.06)
var f: float = freqs[i]
delay_timer.timeout.connect(func(): _play_tone(f, 0.4, "sine", -16.0, f))
+1
View File
@@ -0,0 +1 @@
uid://bivpkig7j7mor
+275
View File
@@ -0,0 +1,275 @@
extends RefCounted
class_name Spaceship
const THRUST := 0.28
const TURN_SPEED := 0.08
const MAX_SPEED := 7.5
const DRAG := 0.985
const TRAIL_LEN := 22
const INVULN_FRAMES := 90
# ─── Boost-Mechanik (nur für Schiffe mit stats.has_boost) ────────────────────
const BOOST_DURATION := 0.55 # seconds during which the extra impulse applies
const BOOST_IMPULSE := 14.0 # instantaneous velocity boost (px/frame)
const BOOST_MAX_SPEED_MULT := 2.8
var x: float
var y: float
var vx: float = 0.0
var vy: float = 0.0
var heading: float = -PI / 2.0
var palette: Dictionary
var trail: Array = []
var invuln_timer: int = INVULN_FRAMES
var dead: bool = false
var is_thrusting: bool = false
var player_index: int = 0
var survival_time: float = 0.0
var kills: int = 0
var wipe_bonus: int = 0
var bullet_cooldown: int = 0
const BULLET_COOLDOWN_MAX := 20
# Auflademechanik (nur wenn wk_charge im Inventar)
var charge_timer: float = 0.0
const CHARGE_MAX: float = 1.5 # Sekunden für volle Ladung
# Roguelite stats — set via apply_stats() after init
var stats: ShipStats = null
var current_shields: int = 0
# Boost state
var boost_cd: float = 0.0 # remaining cooldown (seconds)
var boost_active_t: float = 0.0 # remaining boost-phase time (seconds)
# Hull offsets — each unit = 1 px at Basis-scale 3.0, scaled by stats.hull_scale.
const HULL_PIXELS := [
[6, 0, "nose"],
[4, -2, "mid"], [4, 2, "mid"],
[2, 0, "dim"],
[0, -4, "accent"],[0, 4, "accent"],
[0, 0, "bright"],
[-2,-2, "dim"], [-2, 2, "dim"],
[-4,-4, "shadow"],[-4, 4, "shadow"],
[-4, 0, "edge"],
]
func init(px: float, py: float, pal: Dictionary, pidx: int) -> void:
x = px; y = py
palette = pal
player_index = pidx
invuln_timer = INVULN_FRAMES
func apply_stats(s: ShipStats) -> void:
stats = s
current_shields = s.shield_charges
# Called by game_world when the boost input is just_pressed.
func try_boost() -> bool:
if dead or stats == null or not stats.has_boost: return false
if boost_cd > 0.0: return false
boost_cd = stats.boost_cooldown_max
boost_active_t = BOOST_DURATION
vx += cos(heading) * BOOST_IMPULSE
vy += sin(heading) * BOOST_IMPULSE
return true
func update(thrust: bool, turn: int, world_w: float, world_h: float, delta: float) -> void:
if dead: return
is_thrusting = thrust
var eff_turn: float = TURN_SPEED * (stats.turn_mult if stats else 1.0)
var eff_thrust: float = THRUST * (stats.speed_mult if stats else 1.0)
var eff_max_speed: float = MAX_SPEED * (stats.speed_mult if stats else 1.0)
# While a boost phase is active, lift the cap so the impulse isn't clamped.
if boost_active_t > 0.0:
boost_active_t = max(0.0, boost_active_t - delta)
eff_max_speed *= BOOST_MAX_SPEED_MULT
heading += turn * eff_turn
if thrust:
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
vx *= DRAG; vy *= DRAG
var spd := sqrt(vx*vx + vy*vy)
if spd > eff_max_speed:
vx = vx / spd * eff_max_speed
vy = vy / spd * eff_max_speed
trail.push_front(Vector2(x, y))
if trail.size() > TRAIL_LEN: trail.pop_back()
x += vx; y += vy
if x < 0: x += world_w
elif x > world_w: x -= world_w
if y < 0: y += world_h
elif y > world_h: y -= world_h
if invuln_timer > 0: invuln_timer -= 1
if bullet_cooldown > 0: bullet_cooldown -= 1
if boost_cd > 0.0: boost_cd = max(0.0, boost_cd - delta)
survival_time += delta
func boost_ratio() -> float:
# 0.0 = ready, 1.0 = full cooldown
if stats == null or not stats.has_boost or stats.boost_cooldown_max <= 0.0:
return 0.0
return clamp(boost_cd / stats.boost_cooldown_max, 0.0, 1.0)
func has_charge_weapon() -> bool:
return stats != null and stats.owned_item_ids.has("wk_charge")
# Welcher Bullet-Style passt zum ersten gefundenen Waffenitem im Inventar.
func _get_bullet_style() -> String:
if stats == null: return "default"
const WEAPON_STYLES: Dictionary = {
"wk_charge": "charge",
"wk_plasma": "plasma",
"wk_rail": "rail",
"wk_sniper": "sniper",
"wk_laser": "laser",
"wk_ion": "ion",
"wk_shotgun": "scatter",
"wk_scatter": "scatter",
"wk_burst": "burst",
}
for id: String in stats.owned_item_ids:
if id in WEAPON_STYLES:
return WEAPON_STYLES[id]
return "default"
func can_shoot() -> bool:
return bullet_cooldown <= 0 and not dead
func shoot() -> Bullet:
if not can_shoot(): return null
var cooldown_max := int(float(BULLET_COOLDOWN_MAX) / (stats.fire_rate_mult if stats else 1.0))
bullet_cooldown = max(3, cooldown_max)
var b := Bullet.new()
var otype := "p1" if player_index == 0 else "p2"
b.init(x + cos(heading) * 8.0, y + sin(heading) * 8.0, heading, otype)
b.style = _get_bullet_style()
if stats:
b.vx *= stats.bullet_speed_mult
b.vy *= stats.bullet_speed_mult
b.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult
b.pierce = stats.damage_mult >= 2.0
return b
# Aufgeladener Schuss — ratio 0..1 (0.1 Minimum damit Antippen nichts tut)
func shoot_charged(charge_ratio: float) -> Bullet:
charge_timer = 0.0
if charge_ratio < 0.12 or not can_shoot(): return null
var cooldown_max := int(float(BULLET_COOLDOWN_MAX) / (stats.fire_rate_mult if stats else 1.0))
bullet_cooldown = max(8, cooldown_max)
var b := Bullet.new()
var otype := "p1" if player_index == 0 else "p2"
b.init(x + cos(heading) * 8.0, y + sin(heading) * 8.0, heading, otype)
b.style = "charge"
var dmg: float = stats.damage_mult if stats else 1.0
b.effective_hit_radius = Bullet.HIT_RADIUS * dmg * (1.0 + charge_ratio * 2.5)
b.pierce = true # Aufgeladene Schüsse durchdringen immer
if stats:
b.vx *= stats.bullet_speed_mult * 0.75 # etwas langsamer, aber mächtiger
b.vy *= stats.bullet_speed_mult * 0.75
return b
# Returns all bullets for this shot (handles multi-shot via stats.bullet_count)
func shoot_burst() -> Array:
var result: Array = []
var base: Bullet = shoot()
if base == null: return result
result.append(base)
var extra: int = (stats.bullet_count - 1) if stats else 0
const SPREAD := 0.18 # radians between extra shots
for i in extra:
var offset := SPREAD * ((float(i) / 2.0 + 1.0) * (1.0 if i % 2 == 0 else -1.0))
var angle := heading + offset
var b2 := Bullet.new()
var otype := "p1" if player_index == 0 else "p2"
b2.init(x + cos(angle) * 8.0, y + sin(angle) * 8.0, angle, otype)
if stats:
b2.vx *= stats.bullet_speed_mult
b2.vy *= stats.bullet_speed_mult
b2.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult
b2.pierce = stats.damage_mult >= 2.0
result.append(b2)
return result
func is_invulnerable() -> bool:
return invuln_timer > 0
func get_draw_alpha(frame: int) -> float:
if not is_invulnerable(): return 1.0
return 0.4 if (frame % 16) < 8 else 1.0
func draw(canvas: CanvasItem, frame: int) -> void:
if dead: return
var alpha := get_draw_alpha(frame)
var cos_h := cos(heading); var sin_h := sin(heading)
# Hull scale parameters (Basis 3.0)
var hs: float = stats.hull_scale if stats else 3.0
var off_mul: float = hs / 3.0 # 1.0 bei Basis, >1 macht Offsets größer
var rect_sz: float = hs # 3 px bei Basis
var rect_half: float = rect_sz * 0.5
# Trail
for i in trail.size():
var t_alpha := (1.0 - float(i) / float(TRAIL_LEN)) * 0.55 * alpha
var tc := Color(palette["trail"].r, palette["trail"].g, palette["trail"].b, t_alpha)
canvas.draw_rect(Rect2(trail[i].x - 1, trail[i].y - 1, 2, 2), tc)
# Thrust flare — intensified during boost
var flare_count: int = 3
if boost_active_t > 0.0:
flare_count = 6
if is_thrusting or boost_active_t > 0.0:
var hot: Color = palette.get("thrustHot", Color.YELLOW)
var cool: Color = palette.get("thrustCool", Color.ORANGE)
for fi in flare_count:
var fx: float = x + (-6.0 - fi * 2.5) * cos_h
var fy: float = y + (-6.0 - fi * 2.5) * sin_h
var fc: Color = hot if fi == 0 else cool
fc.a = alpha * (1.0 - fi * (0.16 if boost_active_t > 0.0 else 0.25))
canvas.draw_rect(Rect2(fx - 1, fy - 1, 3, 3), fc)
# Hull pixels
for px_data in HULL_PIXELS:
var lx: float = px_data[0] * off_mul
var ly: float = px_data[1] * off_mul
var col_key: String = px_data[2]
var col: Color = palette.get(col_key, Color.WHITE)
col.a = alpha
var rx := x + lx * cos_h - ly * sin_h
var ry := y + lx * sin_h + ly * cos_h
canvas.draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)
# Attachments from bought items (drawn on top of hull)
if stats != null and stats.owned_item_ids.size() > 0:
_draw_attachments(canvas, cos_h, sin_h, off_mul, rect_sz, rect_half, alpha)
# Boost indicator ring under the ship when ready (only for boost-ships)
if stats != null and stats.has_boost and boost_cd <= 0.0 and not is_invulnerable():
var ring_col: Color = palette.get("accent", Color(1,1,0,1))
ring_col.a = 0.35 * alpha
var r: float = 10.0 * off_mul
canvas.draw_arc(Vector2(x, y), r, 0.0, TAU, 16, ring_col, 1.0, false)
func _draw_attachments(canvas: CanvasItem, cos_h: float, sin_h: float,
off_mul: float, rect_sz: float, rect_half: float, alpha: float) -> void:
# Count how many of each item — use for stacking offset
var seen_count: Dictionary = {}
for id in stats.owned_item_ids:
var def: ItemDef = ItemDB.get_def_by_id(id)
if def == null or def.visual_pixels.is_empty(): continue
var stack: int = int(seen_count.get(id, 0))
seen_count[id] = stack + 1
var stack_off: float = float(stack) * 0.6 # slight per-stack visual offset
for px in def.visual_pixels:
var lx: float = (float(px[0]) + stack_off) * off_mul
var ly: float = float(px[1]) * off_mul
var key: String = px[2]
var col: Color = palette.get(key, Color.WHITE)
col.a = alpha
var rx := x + lx * cos_h - ly * sin_h
var ry := y + lx * sin_h + ly * cos_h
canvas.draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)
+1
View File
@@ -0,0 +1 @@
uid://57llmkpuptgb
+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)
+1
View File
@@ -0,0 +1 @@
uid://cn7sutxtu7qym
+456
View File
@@ -0,0 +1,456 @@
extends Node
# ─── Tr — Translation singleton ───────────────────────────────────────────────
# Usage: Tr.t("key") → returns string in current language (Settings.language)
# Supported languages: "de" (default), "en"
const _DE: Dictionary = {
# ── Main menu ──────────────────────────────────────────────────────────────
"menu_single": "SINGLEPLAYER",
"menu_vs": "VS",
"menu_options": "OPTIONEN",
"menu_atlas": "ATLAS",
"menu_quit": "BEENDEN",
# ── Pause menu ─────────────────────────────────────────────────────────────
"pause_resume": "WEITER",
"pause_main_menu": "HAUPTMENÜ",
"pause_title": "── PAUSE ──",
"pause_footer": "↑↓ AUSWÄHLEN ENTER BESTÄTIGEN",
"pause_footer_pad": "D-PAD AUSWÄHLEN [A] BESTÄTIGEN",
"pause_footer_touch":"↑↓ wischen ● tippen",
# ── Shared options ─────────────────────────────────────────────────────────
"opt_sfx": "SFX LAUTST.",
"opt_music": "MUSIK LAUTST.",
"opt_mute": "TON AUS",
"opt_fullscreen": "VOLLBILD",
"opt_nebula": "NEBULA",
"opt_stars": "STERNE",
"opt_language": "SPRACHE",
"opt_touch": "TOUCH",
"touch_auto": "AUTO",
"touch_on": "EIN",
"touch_off": "AUS",
"opt_back": "ZURÜCK",
"opt_title": "── OPTIONEN ──",
"opt_footer": "◄ ► ÄNDERN ESC ZURÜCK",
"opt_footer_pad": "D-PAD ◄► ÄNDERN [B] ZURÜCK",
"opt_footer_touch": "◄► wischen ● ändern ◄ zurück",
"opt_controls": "STEUERUNG",
# ── Option values ──────────────────────────────────────────────────────────
"yes": "JA",
"no": "NEIN",
"star_low": "NIEDRIG",
"star_mid": "MITTEL",
"star_high": "HOCH",
# ── Main menu decorations ──────────────────────────────────────────────────
"subtitle": "NAVIGATIONSSYSTEM v1.0",
"footer_nav": "↑↓ NAVIGIEREN ENTER BESTÄTIGEN",
"footer_nav_pad": "D-PAD NAVIGIEREN [A] BESTÄTIGEN",
"footer_nav_touch": "↑↓ wischen ● tippen",
# ── Ship select ────────────────────────────────────────────────────────────
"select_header": "NAVIGATIONS-KONSOLE / SCHIFF-INITIALISIERUNG",
"select_pilot1": "PILOT 1 — WÄHLE DEIN SCHIFF",
"select_pilot2": "PILOT 2 — WÄHLE DEIN SCHIFF",
"select_choose": "← → AUSWAHL",
"select_choose_pad": "D-PAD ←→ AUSWAHL",
"select_choose_touch": "← → wischen",
"select_confirm": "[ ENTER BESTÄTIGEN ]",
"select_confirm_pad": "[ A BESTÄTIGEN ]",
"select_confirm_touch": "[ TIPPEN ]",
"select_join": "ENTER = BEITRETEN",
"select_join_pad": "[A] BEITRETEN",
"select_join_touch": "● BEITRETEN",
"select_solo": "ESC = SOLO",
"select_solo_pad": "[B] SOLO",
"select_solo_touch": "◄ SOLO",
# ── HUD ────────────────────────────────────────────────────────────────────
"hud_launching": "STARTET IN",
"hud_wave_clear": "WELLE %d ABGESCHLOSSEN",
"hud_to_shop": "→ SHOP",
"hud_mission_over":"MISSION BEENDET",
"hud_transfer": "ÜBERTRAGE DATEN…",
"hud_press_key": "[ TASTE DRÜCKEN ]",
"hud_press_key_pad": "[ A ] WEITER",
"hud_press_key_touch": "[ TIPPEN ]",
"hud_score": "SCORE %d",
# ── Shop ───────────────────────────────────────────────────────────────────
"shop_console": "AUSRÜSTUNGS-KONSOLE",
"shop_upgrade": "SCHIFF UPGRADEN",
"shop_footer": "← → AUSWÄHLEN ENTER KAUFEN",
"shop_continue": "[ SPACE ] WEITER →",
"shop_continue_pad": "[ B ] WEITER →",
"shop_continue_touch": "[ ◄ ] WEITER →",
"shop_no_credits": "— KEINE CREDITS —",
"shop_owned": "AUSRÜSTUNG:",
# ── Werkstatt (neu) ────────────────────────────────────────────────────────
"werk_phase_attr": "ATTRIBUT-UPGRADE",
"werk_phase_shop": "WERKSTATT",
"werk_attr_prompt": "Wähle ein kostenloses Attribut-Upgrade",
"werk_attr_tag": "[ GRATIS ]",
"werk_attr_footer": "← → AUSWÄHLEN SPACE BESTÄTIGEN",
"werk_attr_footer_pad": "← → AUSWÄHLEN [A] BESTÄTIGEN",
"werk_attr_footer_touch": "← → WISCHEN ● BESTÄTIGEN",
"werk_attr_footer_p2": "A D AUSWÄHLEN F BESTÄTIGEN",
"werk_shop_footer": "← → ↑ ↓ AUSWÄHLEN ENTER KAUFEN",
"werk_shop_footer_pad": "← → ↑ ↓ AUSWÄHLEN [A] KAUFEN",
"werk_shop_footer_touch": "← → WISCHEN ● KAUFEN",
"werk_shop_footer_p2": "A D W AUSWÄHLEN F KAUFEN",
"werk_pick": "[ SPACE WÄHLEN ]",
"werk_pick_pad": "[ A WÄHLEN ]",
"werk_pick_touch": "[ TIPPEN ]",
"werk_pick_p2": "[ F WÄHLEN ]",
"werk_confirm": "[ SPACE BESTÄTIGEN ]",
"werk_confirm_pad": "[ A BESTÄTIGEN ]",
"werk_confirm_touch": "[ TIPPEN ]",
"werk_confirm_p2": "[ F BESTÄTIGEN ]",
"werk_wave": " · WELLE %d",
"werk_reroll": "[%s] REROLL — %d CR",
"werk_continue_p2": "[ E ] WEITER →",
"werk_buy_hint_p1": "[ENTER]",
"werk_buy_hint_p1_pad": "[A]",
"werk_buy_hint_p1_touch": "[●]",
"werk_buy_hint_p2": "[F]",
"werk_preview": "VORSCHAU",
"werk_ship_preview":"DEIN SCHIFF",
"werk_grows": "SCHIFF WÄCHST",
# ── Atlas ──────────────────────────────────────────────────────────────────
"atlas_title": "── ATLAS DER OBJEKTE ──",
"atlas_footer": "↑↓ AUSWÄHLEN ESC ZURÜCK",
"atlas_footer_pad": "D-PAD AUSWÄHLEN [B] ZURÜCK",
"atlas_footer_touch": "↑↓ wischen ◄ zurück",
# ── Controls ───────────────────────────────────────────────────────────────
"ctrl_title": "── STEUERUNG ──",
"ctrl_thrust": "SCHUB",
"ctrl_left": "LINKS",
"ctrl_right": "RECHTS",
"ctrl_shoot": "SCHUSS",
"ctrl_wipe": "WIPE",
"ctrl_waiting": "TASTE DRÜCKEN…",
"ctrl_reset": "STANDARD",
"ctrl_footer": "ENTER BINDEN ESC ZURÜCK",
"ctrl_footer_pad": "[A] BINDEN [B] ZURÜCK",
"ctrl_footer_touch": "● BINDEN ◄ ZURÜCK",
"hud_wipe_key": "⚠ BIG WIPE — [N] / [E]",
"hud_wipe_key_pad": "⚠ BIG WIPE — [Y] / [LT]",
"hud_wipe_key_touch":"⚠ BIG WIPE — SURVIVE",
"atlas_props": "EIGENSCHAFTEN",
"atlas_desc": "BESCHREIBUNG",
"atlas_cat_cosmic": "KOSMISCH",
"atlas_cat_exotic": "EXOTISCH",
"atlas_cat_anti": "ANTIMATERIE",
"atlas_cat_ships": "SCHIFFE",
"atlas_cat_events": "EREIGNISSE",
"atlas_prop_size": "GRÖSSE",
"atlas_prop_speed": "GESCHW.",
"atlas_prop_hazard": "GEFAHR",
"atlas_prop_hp": "TREFFER",
"atlas_prop_damage": "SCHADEN",
"atlas_prop_life": "LEBEN",
"atlas_prop_spawn": "SPAWN",
"atlas_prop_orbit": "ORBIT",
"atlas_prop_effect": "EFFEKT",
"atlas_prop_reward": "BELOHNUNG",
"atlas_hazard_none": "",
"atlas_hazard_low": "NIEDRIG",
"atlas_hazard_mid": "MITTEL",
"atlas_hazard_high": "HOCH",
"atlas_hazard_deadly": "TÖDLICH",
# Entry names
"atlas_n_star": "STERN",
"atlas_n_planet_terr": "PLANET — ERDÄHNLICH",
"atlas_n_planet_desert": "PLANET — WÜSTE",
"atlas_n_planet_gas": "PLANET — GASRIESE",
"atlas_n_planet_ice": "PLANET — EIS",
"atlas_n_planet_lava": "PLANET — LAVA",
"atlas_n_planet_toxic": "PLANET — TOXIC",
"atlas_n_nebula": "NEBEL",
"atlas_n_comet": "KOMET",
"atlas_n_galaxy": "GALAXIE",
"atlas_n_blackhole": "SCHWARZES LOCH",
"atlas_n_whitehole": "WEISSES LOCH",
"atlas_n_neutron": "NEUTRONENSTERN",
"atlas_n_quasar": "QUASAR",
"atlas_n_antimatter": "ANTIMATERIE",
"atlas_n_antistar": "ANTIMATERIESTERN",
"atlas_n_player": "SPIELERSCHIFF",
"atlas_n_enemy": "GEGNERSCHIFF",
"atlas_n_wraith": "WRAITH — MINIBOSS",
"atlas_n_leviathan": "LEVIATHAN — BOSS",
"atlas_n_bullet": "PROJEKTIL",
"atlas_n_bigwipe": "BIG WIPE",
# Descriptions
"atlas_d_star": "Ferne Sonnen. Driften langsam nach oben. Helle Supergiganten zeigen Lichtkreuze, manche Sterne pulsieren rosa: Antimaterie-Sterne lösen sich in Partikel auf. Sterne werden von Schwarzen Löchern spiralförmig verschluckt.",
"atlas_d_planet_terr": "Ozeane, Kontinente und eisige Polkappen. Oft mit driftenden Wolken. Kreist um einen unsichtbaren Ankerpunkt. Kann von einem Schwarzen Loch eingefangen und zerrissen werden (Tidal Stripping).",
"atlas_d_planet_desert": "Heiße Wüstenwelt mit dünner Polkappe. Dreht sich langsam. Wie Mars. Reiner Deko-Himmelskörper — keine direkte Gefahr.",
"atlas_d_planet_gas": "Gasplanet mit mehreren farbigen Bändern und oft einem wandernden Sturm-Fleck. Größer als Gesteinsplaneten, trägt häufig Ringe und mehrere Monde.",
"atlas_d_planet_ice": "Kalte, glitzernde Welt mit feinen Rissen in der gefrorenen Oberfläche. Selten, dreht sich ruhig in seiner Umlaufbahn.",
"atlas_d_planet_lava": "Glühender Vulkanplanet. Strahlt einen pulsierenden Halo ab, Lava-Adern leuchten. Nur kosmetisch — trotz des Feuerscheins harmlos.",
"atlas_d_planet_toxic": "Giftige Wirbel aus Schwefel und Säuren. Die Muster sind verdreht und chaotisch. Deko-Objekt ohne direkten Gameplay-Einfluss.",
"atlas_d_nebula": "Wolken aus Gas und Staub. Bewegen sich sehr langsam und verfärben den Hintergrund. Rein dekorativ. In den Optionen abschaltbar.",
"atlas_d_comet": "Fliegt mit heller Spur von einer Bildschirmseite zur anderen. Spawnt häufiger bei höherer Schwierigkeit. Kollidiert nicht — zieht nur die Blicke.",
"atlas_d_galaxy": "Spiralgalaxie, dreht sich um ihr Zentrum. Wird von einem Schwarzen Loch konsumiert, verstärkt es zu einem Supermassiven Schwarzen Loch (SMBH).",
"atlas_d_blackhole": "Zieht alles im Umkreis an: Spieler, Gegner, Sterne, Planeten. Verschluckt nah kommende Objekte. Bei 30 Verschluckungen: Supernova — stirbt und spawnt Quasar, neue Löcher und Sterne. Frisst es 12 Galaxien, entsteht ein SMBH, der nach 45 s kollabiert.",
"atlas_d_whitehole": "Das Gegenstück zum Schwarzen Loch: stößt Spieler, Gegner, Kometen und Sterne ab und ejiziert regelmäßig neue Sterne und Planeten nach außen. Lebensdauer: ~60 Sekunden.",
"atlas_d_neutron": "Rotierender Pulsar mit einem schmalen Lichtstrahl. Objekte im Strahlkegel werden weggestoßen. Nützlich als natürlicher Schutzwall gegen Gegner-Schwärme.",
"atlas_d_quasar": "Entsteht aus einer Supernova. Sieht aus wie ein leuchtendes SMBH mit zwei polaren Jets. Lebt ~30 Sekunden. Stößt Schiffe, Gegner und Kometen radial weg. Mehrere Quasare in der Nähe verschmelzen zu einem größeren. Ein Schwarzes Loch kann ihn fressen — aber nur wenn es größer ist. Im Strahlkegel: Schub bei gedrücktem Schubknopf.",
"atlas_d_antimatter": "Magenta Partikel. Tötet Spieler und Gegner bei Berührung sofort. Antimaterie-Partikel ziehen sich gegenseitig an — bei 5+ Clustern: Antimateriestern.",
"atlas_d_antistar": "Entsteht aus einem Cluster von Antimaterie. Repulsiert Spieler wie ein Weißes Loch, ist aber hochgefährlich bei Kontakt. Lebensdauer: ~50 Sekunden.",
"atlas_d_player": "Das Schiff des Spielers. 4 Varianten im Hangar. NOVA-1: ausgewogen, 1 Schutzschild. INFERNO: schnell, hohe Feuerrate, Ramm-Schaden bei voller Fahrt. AURORA: sehr wendig, 2 Schilde, BH-Resistenz. TITAN: langsam mit aktivem Boost (SHIFT). Upgrades aus dem Shop stapeln sich auf die Basis-Stats.",
"atlas_d_enemy": "KI-Gegner in rot oder cyan. Verfolgt den Spieler in 600 px Reichweite und schießt. Weicht Schwarzen Löchern aus. Respawnt 4-8 s nach dem Tod. Belohnung: 15 Credits.",
"atlas_d_wraith": "Miniboss in Welle 5. 20 HP, magenta. Orbitiert elliptisch um die Mitte und feuert 3-Way-Schüsse. Belohnung: 150 Credits.",
"atlas_d_leviathan": "Endboss in Welle 8. 50 HP, orange. Phase 2 ab 50 % HP: spawnt ein Schwarzes Loch, feuert 8-Way-Schüsse, Musik verdichtet sich. Belohnung: 300 Credits.",
"atlas_d_bullet": "Energie-Projektil. 9.6 px/Frame, lebt 240 Frames. Farbe je nach Besitzer. Bei Damage ≥ 2.0 wird es durchschlagend (pierce, bis zu 2 Ziele).",
"atlas_d_bigwipe": "Notfall-Reset wenn >500 Objekte. Bildschirm verdunkelt sich 2.33 s — Spieler müssen die Wipe-Taste halten, sonst Tod. Anschließend Weißer Flash, alles außer Planeten/Sternen wird gelöscht. Belohnung: 25 Credits.",
# ── Leaderboard ────────────────────────────────────────────────────────────
"menu_leaderboard": "LEADERBOARD",
"lb_title": "── LEADERBOARD ──",
"lb_local": "LOKAL",
"lb_online": "ONLINE",
"lb_empty": "KEINE EINTRÄGE",
"lb_loading": "LÄDT…",
"lb_error": "VERBINDUNGSFEHLER",
"lb_footer": "O ONLINE ESC ZURÜCK",
"lb_footer_pad": "[Y] ONLINE [B] ZURÜCK",
"lb_footer_touch": "● ONLINE ◄ ZURÜCK",
"lb_enter_name": "DEIN NAME:",
"lb_name_hint": "ENTER BESTÄTIGEN ESC ÜBERSPRINGEN",
"lb_name_hint_pad": "[A] BESTÄTIGEN [B] ÜBERSPRINGEN",
"lb_name_hint_touch": "● BESTÄTIGEN ◄ ÜBERSPRINGEN",
"lb_saved": "GESPEICHERT !",
"lb_wave": "W",
}
const _EN: Dictionary = {
# ── Main menu ──────────────────────────────────────────────────────────────
"menu_single": "SINGLEPLAYER",
"menu_vs": "VS",
"menu_options": "OPTIONS",
"menu_atlas": "ATLAS",
"menu_quit": "QUIT",
# ── Pause menu ─────────────────────────────────────────────────────────────
"pause_resume": "RESUME",
"pause_main_menu": "MAIN MENU",
"pause_title": "── PAUSE ──",
"pause_footer": "↑↓ SELECT ENTER CONFIRM",
"pause_footer_pad": "D-PAD SELECT [A] CONFIRM",
"pause_footer_touch":"↑↓ swipe ● tap",
# ── Shared options ─────────────────────────────────────────────────────────
"opt_sfx": "SFX VOL.",
"opt_music": "MUSIC VOL.",
"opt_mute": "MUTE",
"opt_fullscreen": "FULLSCREEN",
"opt_nebula": "NEBULA",
"opt_stars": "STARS",
"opt_language": "LANGUAGE",
"opt_touch": "TOUCH",
"touch_auto": "AUTO",
"touch_on": "ON",
"touch_off": "OFF",
"opt_back": "BACK",
"opt_title": "── OPTIONS ──",
"opt_footer": "◄ ► CHANGE ESC BACK",
"opt_footer_pad": "D-PAD ◄► CHANGE [B] BACK",
"opt_footer_touch": "◄► swipe ● change ◄ back",
"opt_controls": "CONTROLS",
# ── Option values ──────────────────────────────────────────────────────────
"yes": "YES",
"no": "NO",
"star_low": "LOW",
"star_mid": "MED",
"star_high": "HIGH",
# ── Main menu decorations ──────────────────────────────────────────────────
"subtitle": "NAVIGATION SYSTEM v1.0",
"footer_nav": "↑↓ NAVIGATE ENTER CONFIRM",
"footer_nav_pad": "D-PAD NAVIGATE [A] CONFIRM",
"footer_nav_touch": "↑↓ swipe ● tap",
# ── Ship select ────────────────────────────────────────────────────────────
"select_header": "NAVIGATION CONSOLE / SHIP INITIALIZATION",
"select_pilot1": "PILOT 1 — SELECT YOUR SHIP",
"select_pilot2": "PILOT 2 — SELECT YOUR SHIP",
"select_choose": "← → SELECT",
"select_choose_pad": "D-PAD ←→ SELECT",
"select_choose_touch": "← → swipe",
"select_confirm": "[ ENTER CONFIRM ]",
"select_confirm_pad": "[ A CONFIRM ]",
"select_confirm_touch": "[ TAP ]",
"select_join": "ENTER = JOIN",
"select_join_pad": "[A] JOIN",
"select_join_touch": "● JOIN",
"select_solo": "ESC = SOLO",
"select_solo_pad": "[B] SOLO",
"select_solo_touch": "◄ SOLO",
# ── HUD ────────────────────────────────────────────────────────────────────
"hud_launching": "LAUNCHING IN",
"hud_wave_clear": "WAVE %d COMPLETE",
"hud_to_shop": "→ SHOP",
"hud_mission_over":"MISSION OVER",
"hud_transfer": "UPLOADING DATA…",
"hud_press_key": "[ PRESS ANY KEY ]",
"hud_press_key_pad": "[ A ] CONTINUE",
"hud_press_key_touch": "[ TAP ]",
"hud_score": "SCORE %d",
# ── Shop ───────────────────────────────────────────────────────────────────
"shop_console": "EQUIPMENT CONSOLE",
"shop_upgrade": "UPGRADE SHIP",
"shop_footer": "← → SELECT ENTER BUY",
"shop_continue": "[ SPACE ] CONTINUE →",
"shop_continue_pad": "[ B ] CONTINUE →",
"shop_continue_touch": "[ ◄ ] CONTINUE →",
"shop_no_credits": "— NO CREDITS —",
"shop_owned": "EQUIPMENT:",
# ── Werkstatt (new) ────────────────────────────────────────────────────────
"werk_phase_attr": "ATTRIBUTE UPGRADE",
"werk_phase_shop": "WORKSHOP",
"werk_attr_prompt": "Pick one free attribute upgrade",
"werk_attr_tag": "[ FREE ]",
"werk_attr_footer": "← → SELECT SPACE CONFIRM",
"werk_attr_footer_pad": "← → SELECT [A] CONFIRM",
"werk_attr_footer_touch": "← → SWIPE ● CONFIRM",
"werk_attr_footer_p2": "A D SELECT F CONFIRM",
"werk_shop_footer": "← → ↑ ↓ SELECT ENTER BUY",
"werk_shop_footer_pad": "← → ↑ ↓ SELECT [A] BUY",
"werk_shop_footer_touch": "← → SWIPE ● BUY",
"werk_shop_footer_p2": "A D W SELECT F BUY",
"werk_pick": "[ SPACE PICK ]",
"werk_pick_pad": "[ A PICK ]",
"werk_pick_touch": "[ TAP ]",
"werk_pick_p2": "[ F PICK ]",
"werk_confirm": "[ SPACE CONFIRM ]",
"werk_confirm_pad": "[ A CONFIRM ]",
"werk_confirm_touch": "[ TAP ]",
"werk_confirm_p2": "[ F CONFIRM ]",
"werk_wave": " · WAVE %d",
"werk_reroll": "[%s] REROLL — %d CR",
"werk_continue_p2": "[ E ] CONTINUE →",
"werk_buy_hint_p1": "[ENTER]",
"werk_buy_hint_p1_pad": "[A]",
"werk_buy_hint_p1_touch": "[●]",
"werk_buy_hint_p2": "[F]",
"werk_preview": "PREVIEW",
"werk_ship_preview":"YOUR SHIP",
"werk_grows": "SHIP GROWS",
# ── Atlas ──────────────────────────────────────────────────────────────────
"atlas_title": "── OBJECT ATLAS ──",
"atlas_footer": "↑↓ SELECT ESC BACK",
"atlas_footer_pad": "D-PAD SELECT [B] BACK",
"atlas_footer_touch": "↑↓ swipe ◄ back",
# ── Controls ───────────────────────────────────────────────────────────────
"ctrl_title": "── CONTROLS ──",
"ctrl_thrust": "THRUST",
"ctrl_left": "LEFT",
"ctrl_right": "RIGHT",
"ctrl_shoot": "SHOOT",
"ctrl_wipe": "WIPE",
"ctrl_waiting": "PRESS KEY…",
"ctrl_reset": "DEFAULT",
"ctrl_footer": "ENTER BIND ESC BACK",
"ctrl_footer_pad": "[A] BIND [B] BACK",
"ctrl_footer_touch": "● BIND ◄ BACK",
"hud_wipe_key": "⚠ BIG WIPE — [N] / [E]",
"hud_wipe_key_pad": "⚠ BIG WIPE — [Y] / [LT]",
"hud_wipe_key_touch":"⚠ BIG WIPE — SURVIVE",
"atlas_props": "PROPERTIES",
"atlas_desc": "DESCRIPTION",
"atlas_cat_cosmic": "COSMIC",
"atlas_cat_exotic": "EXOTIC",
"atlas_cat_anti": "ANTIMATTER",
"atlas_cat_ships": "SHIPS",
"atlas_cat_events": "EVENTS",
"atlas_prop_size": "SIZE",
"atlas_prop_speed": "SPEED",
"atlas_prop_hazard": "HAZARD",
"atlas_prop_hp": "HP",
"atlas_prop_damage": "DAMAGE",
"atlas_prop_life": "LIFETIME",
"atlas_prop_spawn": "SPAWN",
"atlas_prop_orbit": "ORBIT",
"atlas_prop_effect": "EFFECT",
"atlas_prop_reward": "REWARD",
"atlas_hazard_none": "",
"atlas_hazard_low": "LOW",
"atlas_hazard_mid": "MED",
"atlas_hazard_high": "HIGH",
"atlas_hazard_deadly": "DEADLY",
# Entry names
"atlas_n_star": "STAR",
"atlas_n_planet_terr": "PLANET — TERRESTRIAL",
"atlas_n_planet_desert": "PLANET — DESERT",
"atlas_n_planet_gas": "PLANET — GAS GIANT",
"atlas_n_planet_ice": "PLANET — ICE",
"atlas_n_planet_lava": "PLANET — LAVA",
"atlas_n_planet_toxic": "PLANET — TOXIC",
"atlas_n_nebula": "NEBULA",
"atlas_n_comet": "COMET",
"atlas_n_galaxy": "GALAXY",
"atlas_n_blackhole": "BLACK HOLE",
"atlas_n_whitehole": "WHITE HOLE",
"atlas_n_neutron": "NEUTRON STAR",
"atlas_n_quasar": "QUASAR",
"atlas_n_antimatter": "ANTIMATTER",
"atlas_n_antistar": "ANTIMATTER STAR",
"atlas_n_player": "PLAYER SHIP",
"atlas_n_enemy": "ENEMY SHIP",
"atlas_n_wraith": "WRAITH — MINIBOSS",
"atlas_n_leviathan": "LEVIATHAN — BOSS",
"atlas_n_bullet": "BULLET",
"atlas_n_bigwipe": "BIG WIPE",
# Descriptions
"atlas_d_star": "Distant suns. Drift slowly upward. Bright supergiants form cross-flares; pink-tinted antimatter stars dissolve into particles over time. Stars spiral into black holes when caught by gravity.",
"atlas_d_planet_terr": "Oceans, continents and icy polar caps. Often with drifting clouds. Orbits an invisible anchor point. Can be captured and torn apart by a black hole (tidal stripping).",
"atlas_d_planet_desert": "Hot desert world with a thin polar cap. Rotates slowly. Mars-like. A pure cosmetic body — no direct gameplay hazard.",
"atlas_d_planet_gas": "Gas planet with multiple colored bands and often a migrating storm spot. Larger than rocky planets; frequently carries rings and several moons.",
"atlas_d_planet_ice": "A cold, glittering world with fine cracks in its frozen surface. Rare, rotates calmly in its orbit.",
"atlas_d_planet_lava": "Molten volcanic planet. Radiates a pulsing halo; lava veins glow. Cosmetic only — harmless despite the fiery look.",
"atlas_d_planet_toxic": "Toxic swirls of sulfur and acids. Patterns are warped and chaotic. Decorative body with no direct gameplay impact.",
"atlas_d_nebula": "Clouds of gas and dust. Drift very slowly, tinting the background. Purely decorative. Can be disabled in options.",
"atlas_d_comet": "Streaks across the screen with a bright tail. Spawns more often at higher difficulty. No collision — just visual flavour.",
"atlas_d_galaxy": "Spiral galaxy, rotates around its core. When a black hole consumes it, the hole grows into a Supermassive Black Hole (SMBH).",
"atlas_d_blackhole": "Pulls everything nearby: the player, enemies, stars, planets. Swallows objects that get too close. At 30 swallows: supernova — it dies and spawns a quasar, new holes and stars. Devouring 12 galaxies creates an SMBH that collapses after 45 s.",
"atlas_d_whitehole": "Opposite of a black hole: pushes players, enemies, comets and stars away, and periodically ejects new stars and planets outward. Lifespan: ~60 seconds.",
"atlas_d_neutron": "Rotating pulsar with a narrow beam of light. Objects inside the beam are repelled. Useful as a natural shield against enemy swarms.",
"atlas_d_quasar": "Born from a supernova. Looks like a luminous SMBH with two polar jets. Lives ~30 seconds. Repels ships, enemies and comets radially. Multiple nearby quasars merge into one. A black hole can consume it — but only if larger. Inside the jet beam: thrust boost while holding the thrust button.",
"atlas_d_antimatter": "Magenta particle. Kills players and enemies on contact. Antimatter particles attract each other — 5+ clustered form an antimatter star.",
"atlas_d_antistar": "Forms from a cluster of antimatter. Repels the player like a white hole, but is lethal on contact. Lifespan: ~50 seconds.",
"atlas_d_player": "The player's ship. 4 variants in the hangar. NOVA-1: balanced, 1 shield. INFERNO: fast, high fire rate, ram damage at full speed. AURORA: highly agile, 2 shields, BH resistance. TITAN: slow with an active boost (SHIFT). Shop upgrades stack on top of base stats.",
"atlas_d_enemy": "AI enemy in red or cyan. Chases the player within 600 px range and fires. Evades black holes. Respawns 4-8 s after death. Reward: 15 credits.",
"atlas_d_wraith": "Miniboss in wave 5. 20 HP, magenta. Orbits the center elliptically and fires 3-way shots. Reward: 150 credits.",
"atlas_d_leviathan": "Final boss in wave 8. 50 HP, orange. Phase 2 below 50 % HP: spawns a black hole, fires 8-way shots, music thickens. Reward: 300 credits.",
"atlas_d_bullet": "Energy projectile. 9.6 px/frame, 240 frame lifetime. Colour depends on owner. Damage ≥ 2.0 makes it piercing (hits up to 2 targets).",
"atlas_d_bigwipe": "Emergency reset when >500 objects. Screen dims for 2.33 s — players must hold the wipe key or die. A white flash follows; everything except planets/stars is cleared. Reward: 25 credits.",
# ── Leaderboard ────────────────────────────────────────────────────────────
"menu_leaderboard": "LEADERBOARD",
"lb_title": "── LEADERBOARD ──",
"lb_local": "LOCAL",
"lb_online": "ONLINE",
"lb_empty": "NO ENTRIES",
"lb_loading": "LOADING…",
"lb_error": "CONNECTION ERROR",
"lb_footer": "O ONLINE ESC BACK",
"lb_footer_pad": "[Y] ONLINE [B] BACK",
"lb_footer_touch": "● ONLINE ◄ BACK",
"lb_enter_name": "YOUR NAME:",
"lb_name_hint": "ENTER CONFIRM ESC SKIP",
"lb_name_hint_pad": "[A] CONFIRM [B] SKIP",
"lb_name_hint_touch": "● CONFIRM ◄ SKIP",
"lb_saved": "SAVED !",
"lb_wave": "W",
}
func t(key: String) -> String:
var dict: Dictionary = _EN if Settings.language == "en" else _DE
if dict.has(key):
return dict[key]
if _DE.has(key):
return _DE[key]
return key
# Like t(), but returns a device-specific variant when available.
# Appends "_pad" or "_touch" to the key and falls back to the generic key.
# Example: hint("footer_nav") → "footer_nav_pad" on gamepad, "footer_nav" on keyboard.
func hint(key: String) -> String:
var device: String = Settings.last_input_device if Settings else "keyboard"
if device != "keyboard":
var variant := key + "_" + device
var dict: Dictionary = _EN if Settings.language == "en" else _DE
if dict.has(variant):
return dict[variant]
return t(key)
+1
View File
@@ -0,0 +1 @@
uid://ddebuue2ry42v