Initial commit — Godot space roguelite source
- Touch controls: direct InputEventScreenTouch in shop_ui (bypass relay) - ItemDB: static preload list instead of DirAccess scan (export fix) - All 18 items with EN localisation (name_en, desc_en, category_en) - Ship playstyles: NOVA-1 shield, INFERNO ram, AURORA agile/tank - Quasar: SMBH visual, jet boost, merge, push, BH-eating - Atlas & UI text updated EN+DE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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", "1–7 px"],
|
||||
["atlas_prop_speed", "0.05–0.3 px/f"],
|
||||
["atlas_prop_spawn", "~150–300"],
|
||||
["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", "5–10 px"],
|
||||
["atlas_prop_orbit", "19–72 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", "5–10 px"],
|
||||
["atlas_prop_orbit", "19–72 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", "12–19 px"],
|
||||
["atlas_prop_orbit", "19–72 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", "5–10 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", "5–10 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", "5–10 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", "120–220 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", "1–4 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", "14–40 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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbj3vbayteqo0
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://qe52wshn58wj
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmqsqq8etgp7e
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://r3q2arux2t1n
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://6a8ufydt7pd8
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://j7pdrterps08
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://fycowvc7jmfy
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dgjwy8o1rgfd7
|
||||
+101
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://kn3e6fiywxr7
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhqnupvfv7tnt
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://sfb1ybt3r8ne
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxa4d1ome3iap
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cs0jfxgljs4rl
|
||||
+367
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctbcvqj0aoo3v
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cp36y6rjo8aqs
|
||||
@@ -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 1–8 (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 9–16 (Index 15–47) ═════════════════════════════════
|
||||
# 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxun4apx8cxf1
|
||||
@@ -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 = ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs401f5368qos
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://lmuic5md3b5p
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b78slt2taoxyc
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1yb21fhib2c1
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bc1kys1vqyll6
|
||||
@@ -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.0–0.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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1b87vp4lr17r
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://18s6j8gme2ne
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bivpkig7j7mor
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://57llmkpuptgb
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn7sutxtu7qym
|
||||
+456
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddebuue2ry42v
|
||||
Reference in New Issue
Block a user