commit edc40f9008ead91655c5e4a17c24b03f5ef21bb1 Author: alpacamannn Date: Tue Apr 21 14:38:09 2026 +0200 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 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d9774a4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,22 @@ +{ + "permissions": { + "allow": [ + "mcp__godot-mcp-pro__validate_script", + "mcp__godot-mcp-pro__get_editor_errors", + "mcp__godot-mcp-pro__reload_project", + "mcp__godot-mcp-pro__play_scene", + "mcp__godot-mcp-pro__create_script", + "mcp__godot-mcp-pro__get_project_info", + "mcp__godot-mcp-pro__get_scene_tree", + "mcp__godot-mcp-pro__read_script", + "mcp__godot-mcp-pro__get_game_screenshot", + "mcp__godot-mcp-pro__simulate_action", + "mcp__godot-mcp-pro__execute_game_script", + "mcp__godot-mcp-pro__stop_scene", + "mcp__godot-mcp-pro__get_filesystem_tree", + "mcp__godot-mcp-pro__get_scene_file_content", + "mcp__godot-mcp-pro__get_input_actions", + "mcp__godot-mcp-pro__execute_editor_script" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44fd684 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Godot 4+ specific ignores +.godot/ +/android/ +addons/ +build/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5823c5d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "godot-mcp-pro": { + "command": "node", + "args": [ + "C:/_gamedev/godot_mcp_server/build/index.js" + ] + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9614e01 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "godotTools.editorPath.godot4": "c:\\Program Files\\Godot\\Godot_v4.6-stable_win64.exe" +} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..93d70be --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,431 @@ +# spacel — Architektur-Dokumentation + +## Überblick + +**spacel** ist ein 2D-Space-Shooter mit Roguelite-Elementen, gebaut in **Godot 4.6** (Forward Plus, D3D12). Das Spiel unterstützt 1–2 Spieler (Split-Keyboard). Es gibt keine externen Assets — alles wird prozedural gezeichnet und synthetisiert (keine Sprites, keine Audio-Dateien). + +--- + +## Projektstruktur + +``` +spacel/ +├── project.godot # Projekt-Konfiguration, Autoloads, Input-Map +├── scenes/ +│ ├── main.tscn # Root-Scene (enthält alles) +│ ├── game_world.tscn # Spielwelt / Hintergrund-Canvas +│ ├── hud.tscn # HUD (CanvasLayer) +│ └── ship_select.tscn # Schiff-Auswahlbildschirm +├── scripts/ +│ ├── main.gd # State Machine — Spielfluss-Controller +│ ├── game_world.gd # Spielwelt: Simulation, Rendering, Kollisionen +│ ├── spaceship.gd # Spieler-Schiff (Datenklasse) +│ ├── enemy_ship.gd # Gegner-Schiff mit KI +│ ├── boss_ship.gd # Bosse: WRAITH (Welle 5) & LEVIATHAN (Welle 8) +│ ├── bullet.gd # Projektil +│ ├── black_hole.gd # Schwarzes Loch mit Gravitation + Supernova +│ ├── big_wipe.gd # Big-Wipe-Event (Bildschirm-Reset) +│ ├── cosmic_objects.gd # Alle Weltraum-Objekte (Star, Planet, ...) +│ ├── ship_stats.gd # Roguelite-Stat-System +│ ├── item_db.gd # Item-Datenbank (20 Items, 4 Seltenheiten) +│ ├── hud.gd # HUD-Logik +│ ├── hud_draw.gd # HUD-Rendering +│ ├── settings.gd # Einstellungen (Autoload) +│ ├── sound_manager.gd # Procedurales Audio (Autoload) +│ ├── music_player.gd # Musik +│ ├── shop_ui.gd # Shop zwischen den Wellen +│ ├── ship_select.gd # Schiff-Auswahl UI +│ ├── main_menu.gd # Hauptmenü +│ ├── pause_menu.gd # Pause-Menü +│ └── tr.gd # Lokalisierung (Autoload, de/en) +└── addons/godot_mcp/ # MCP-Plugin für KI-gestützte Entwicklung +``` + +--- + +## Autoloads (Singletons) + +| Name | Script | Funktion | +|------|--------|----------| +| `Settings` | `settings.gd` | Lautstärke, Grafik, Sprache — persisted in `user://settings.cfg` | +| `SoundManager` | `sound_manager.gd` | Alle SFX prozedural generiert (Sinus/Säge/Square-Wellen, kein Audio-File) | +| `Tr` | `tr.gd` | Übersetzungen DE/EN | +| MCP-Dienste | `addons/godot_mcp/` | Nur für Entwicklung (Editor-Plugin) | + +--- + +## State Machine (`main.gd`) + +`main.gd` ist der zentrale Controller. Er verwaltet alle UI-Panels und schaltet zwischen folgenden Zuständen um: + +``` +MAIN_MENU → SELECT → (SELECT_P2) → LAUNCHING → PLAYING ⟷ PAUSED + ↓ + RETURNING → SHOP → LAUNCHING (nächste Welle) + ↓ + GAMEOVER → MAIN_MENU + WAVE_CLEAR → SHOP +``` + +**Wichtige Variablen in `main.gd`:** +- `lives_p1 / credits_p1 / stats_p1 / owned_items_p1` — Run-Zustand Spieler 1 +- `wave_number` — aktuelle Wellennummer (steigt nach jedem Shop) +- `SHIPS[]` — 4 Schiffe (NOVA-1, INFERNO, AURORA, TITAN) mit Farbpaletten und Basis-Stats + +**Schiff-Spielstile** (`_apply_ship_base_stats()` in `main.gd`): +| ID | Name | Besonderheit | +|----|------|--------------| +| `classic` | NOVA-1 | 1 Schutzschild — ausgewogener Einstieg | +| `inferno` | INFERNO | Speed +28 %, Feuerrate +55 %, Kurve −28 % · Passiv: Ramm-Schaden ab ≥ 4.5 px/s | +| `aurora` | AURORA | Kurve +55 %, Speed −20 %, 2 Schilde, BH-Resist 55 %, 2× Unverwundbarkeitszeit | +| `titan` | TITAN | Speed −28 %, Kurve −15 % · Aktiv: Boost-Impuls (SHIFT, 5 s Cooldown) | + +`ship_id: String` in `ShipStats` wird beim Spielstart gesetzt und ermöglicht runtime-Verzweigungen (z. B. INFERNO-Rammen in `game_world.gd`). + +**Hauptlogik:** +- `_set_state(new_state)` — zeigt/versteckt UI, initialisiert `game_world` +- `_process()` — Countdown vor Spielstart, Return-/Wave-Timer +- `add_credits(player, amount)` — Credits dem Spieler gutschreiben +- `on_game_over() / on_wave_complete()` — von `game_world` aufgerufen + +--- + +## Spielwelt (`game_world.gd`) + +Das Herzstück des Spiels. `game_world.gd` ist ein `Node2D` und macht **alles selbst** per `_draw()` — keine Child-Nodes für Spielobjekte. + +### Physik-Loop + +Fixed-Timestep bei **60 Hz** (`PHYS_DT = 1/60`): + +```gdscript +_process(delta): + _phys_accum += delta # akkumuliert reale Zeit + while _phys_accum >= PHYS_DT: + _phys_accum -= PHYS_DT + frame += 1 + _tick(PHYS_DT) + queue_redraw() +``` + +### `_tick(dt)` — Reihenfolge pro Frame + +1. `_handle_input(dt)` — Spieler-Input lesen, Schüsse erzeugen +2. `_update_objects(dt)` — Alle kosmischen Objekte updaten, Gegner KI +3. `_update_bullets()` — Projektile bewegen, tote entfernen +4. Boss-Update (falls vorhanden) → erzeugt Bullets +5. `_update_particles(dt)` — Partikeleffekte +6. `_check_collisions()` — Bullet/Enemy, Bullet/Player, Antimatter/Player, Bullets/Boss +7. `_check_big_wipe()` — Threshold prüfen (> 500 Objekte) +8. BigWipe-Update +9. Schwierigkeit + Wave-Timer + Credit-Trickle +10. All-Dead-Check → `main_node.on_game_over()` +11. Kometen- und Antimatter-Spawn-Timer + +### Arrays der Spielobjekte + +```gdscript +var stars, planets, nebulae, comets, galaxies: Array +var black_holes, quasars, white_holes, neutron_stars: Array +var antimatter, antimatter_stars: Array +var bullets, particles: Array +var players, enemies: Array +``` + +### Rendering (`_draw()`) + +Alles wird mit Godot's Canvas-API gezeichnet — **keine Sprites**: +- `draw_rect()` für Pixel/Rumpfe +- `draw_circle()` für Schwarze Löcher, White Holes +- `draw_arc()` für Akkretionsscheiben, Ringe +- `draw_line()` für Trails + +**Zeichenreihenfolge:** Nebulae → Sterne → Galaxien → Planeten → Kometen → Quasare → White Holes → Neutronensterne → Schwarze Löcher → Antimatter → Bullets → Enemies → Boss → Spieler → Partikel → BigWipe-Overlay + +--- + +## Datenklassen (alle `extends RefCounted`) + +Alle Spielobjekte sind **keine Nodes** — sie sind reine Datenklassen, die von `game_world` in Arrays verwaltet werden. Das verhindert den Node-Overhead und ermöglicht den eigenen Physik-Loop. + +### `Spaceship` (`spaceship.gd`) + +Spieler-Schiff. + +| Eigenschaft | Wert | Beschreibung | +|-------------|------|--------------| +| `THRUST` | 0.28 | Beschleunigung pro Frame | +| `TURN_SPEED` | 0.08 | Drehgeschwindigkeit (rad/frame) | +| `MAX_SPEED` | 7.5 | Maximale Geschwindigkeit | +| `DRAG` | 0.985 | Trägheit (Reibung) | +| `INVULN_FRAMES` | 90 | ~1.5s Unverwundbarkeit nach Treffer | +| `stats.ship_id` | String | Schiff-ID für runtime-spezifische Mechaniken | + +**Methoden:** +- `update(thrust, turn, W, H, delta)` — Bewegung, Screen-Wrap, Trail +- `shoot_burst() → Array[Bullet]` — erzeugt 1–N Bullets je nach `stats.bullet_count` +- `draw(canvas, frame)` — malt Rumpf aus `HULL_PIXELS`-Tabelle mit Heading-Rotation + +**Screen-Wrap:** Wenn Schiff den Bildschirmrand verlässt, erscheint es auf der anderen Seite (Toroidal). + +### `EnemyShip` (`enemy_ship.gd`) + +KI-Gegner. 2 Farbvarianten (idx 0 = Rot, idx 1 = Cyan). Jeder Gegner hat eine **Role** (AGGRO / CIRCLE / FLANK), die per `role_id % 3` beim Spawn bestimmt wird und über Respawns hinweg erhalten bleibt. `role_id` wird in `game_world.gd` als laufender Index vergeben. + +**Rollen:** +- **AGGRO**: stürmt direkt auf den nächsten Spieler zu +- **CIRCLE**: hält Abstand (~180 px) und kreist kontinuierlich um den Spieler (orbit_offset dreht sich) +- **FLANK**: nähert sich aus ±90°-Flankenwinkel statt frontal + +**Gemeinsamkeiten:** +- Schuss immer direkt auf Spieler gezielt (unabhängig von der Bewegungsrichtung) → CIRCLE/FLANK-Gegner bleiben gefährlich +- **Separation Force**: Gegner stoßen sich gegenseitig ab (Radius 110 px) → kein Clumping +- **Patrol**: außerhalb Reichweite navigieren Gegner zu zufälligen Zielpunkten auf dem Bildschirm (nicht mehr reines Heading-Drehen) → natürliche Verteilung +- **Schwarzloch-Ausweichen**: spürt BH-Radius und weicht ab +- Feuer-Timer ist pro `role_id` gestaffelt (FIRE_INTERVAL + rid×17) → Salven verteilt +- Nach Tod: respawnt nach 4–8s vom zufälligen Bildschirmrand, behält Role bei + +**Wellenskalierung:** +- Alte Formel: `2 + (wave-1)/2` (cap 6→8 nach letztem Balancing-Pass) +- **Neue Formel:** `min(2 + (wave - 1), MAX_ENEMEYS)` → Welle 1: 2, Welle 5: 6, Welle 10: 11, Welle 20: 21 + +### `BossShip` (`boss_ship.gd`) + +Zwei Bosse — gleiche Klasse, unterschiedliche Parameter: + +| | WRAITH (Welle 5) | LEVIATHAN (Welle 8) | +|--|--|--| +| HP | 20 | 50 | +| Farbe | Magenta | Orange/Feuerrot | +| Orbit-Geschwindigkeit | 0.55 rad/s | 0.44 / 0.72 (Phase 2) | +| Feuer-Intervall | 72 Frames | 60 / 42 (Phase 2) | +| Schüsse | 3-Way | 5-Way / 8-Way | +| Pixel-Größe | 5 | 7 | + +**Mechaniken:** +- Orbitiert elliptisch um Bildschirmmitte +- Heading zeigt immer zur Mitte (schießt nach innen) +- LEVIATHAN: Phase-2-Übergang bei ≤ 50% HP → Black Hole spawnt, Musik verdichtet + +### `Bullet` (`bullet.gd`) + +- Geschwindigkeit: 9.6 px/frame +- Lebensdauer: 240 Frames, Fade ab Frame 210 +- Besitzer-Typen: `"p1"`, `"p2"`, `"enemy"`, `"boss"` (bestimmt Kollisions-Check und Farbe) +- `pierce`: true wenn `damage_mult >= 2.0` → trifft bis zu 2 Ziele + +### `BlackHole` (`black_hole.gd`) + +Komplexestes Objekt im Spiel. + +**Parameter:** +- `PULL_RADIUS`: 160px Gravitationsfeld +- `SWALLOW_RADIUS`: 14px Verschluck-Radius +- `FORCE_MULT`: 45.0 (Gravitationsstärke) +- `SUPERNOVA_AT`: 30 verschluckte Objekte → Supernova + +**Mechaniken:** +- Wandert langsam umher (Micro-Drift) +- 18% Chance: jagt den nächsten Spieler (Hunting-Mode) +- Wächst mit jedem verschluckten Objekt (radius, pull_radius, gravity) +- Bei 30 Verschluckungen: **Supernova** → stirbt, spawnt Quasar + neue BHs + White/Neutron Star + Sterne/Planeten +- **SMBH**: nach 12 verschluckten Galaxien → Super-Massive BH, nach 45s kollabiert er +- BH-BH-Verschmelzung: größerer frisst kleineren + +### `BigWipe` (`big_wipe.gd`) + +Notfall-Reset wenn > 500 kosmische Objekte vorhanden sind. + +**Phasen:** +1. `COLLAPSE` (2.33s): Bildschirm verdunkelt sich, Spieler müssen Wipe-Taste halten +2. `FLASH` (0.4s): Weißer Flash +3. Alle Objekte außer Planeten/Sternen werden gelöscht + +**Spieler-Reaktion:** Signal `p1_wipe` / `p2_wipe` drücken während COLLAPSE → überleben. Wer nicht drückt, stirbt. Belohnung: 25 Credits (+ Credit-Bonus). + +### `CosmicObjects` (`cosmic_objects.gd`) + +Statische äußere Klasse mit inneren Klassen: + +| Klasse | Beschreibung | +|--------|-------------| +| `Star` | Wandert nach oben, kann gravitationell angezogen werden, spiralt ins BH | +| `Planet` | Kreist um Orbit-Punkt, kann von BH gefangen und zerrissen werden | +| `Nebula` | Rein dekorativ, bewegt sich langsam | +| `Comet` | Fliegt von Bildschirmrand zu Bildschirmrand | +| `Galaxy` | Spiral-Galaxie, kann von BH konsumiert werden → SMBH | +| `Quasar` | Entsteht aus Supernova, lebt 30s, rein dekorativ | +| `WhiteHole` | Gegenteil des BH: stößt Spieler ab, ejiziert Sterne/Planeten, lebt 60s | +| `NeutronStar` | Rotierender Pulsar-Strahl, stößt Objekte im Beam-Bereich ab | +| `Antimatter` | Partikel, tötet Spieler/Gegner bei Berührung, ziehen sich an | +| `AntimatterStar` | Entsteht wenn 5+ Antimatter-Partikel clustern, repulsiert Spieler, lebt 50s | + +--- + +## Roguelite-System + +### `ShipStats` (`ship_stats.gd`) + +Alle Stats sind **multiplikativ** (ausgenommen additive: `bullet_count`, `shield_charges`, `bh_resist`): + +| Stat | Standard | Effekt | +|------|----------|--------| +| `speed_mult` | 1.0 | Schub und Max-Speed | +| `turn_mult` | 1.0 | Drehgeschwindigkeit | +| `fire_rate_mult` | 1.0 | Teilt Cooldown (höher = schneller) | +| `damage_mult` | 1.0 | Trefferzone; ≥ 2.0 → Pierce | +| `bullet_speed_mult` | 1.0 | Projektil-Geschwindigkeit | +| `bullet_count` | 1 | Projektile pro Schuss | +| `shield_charges` | 0 | Absorbiert N Treffer | +| `invuln_mult` | 1.0 | Unverwundbarkeitszeit nach Treffer | +| `bh_resist` | 0.0 | 0–0.9: Anteil des BH-Zugs der negiert wird | +| `wipe_mult` | 1.0 | BigWipe-Haltezeit-Faktor | +| `credit_bonus` | 1.0 | Multiplikator auf alle Credits | + +### `ItemDB` (`item_db.gd`) + +Zwei Pools: +- **Legacy `ITEMS`** (6 Einträge) — nur noch für Enemy-Boosts ab Welle 10 (`game_world.gd` rollt 1× daraus und wendet es auf jeden Gegner an). +- **Werkstatt-Plugin-Pool** (auto-discover unter `res://items/`, derzeit 17 ItemDefs) — der eigentliche Shop-Pool. + +Seltenheiten: + +| Seltenheit | Gewicht | Farbe | +|------------|---------|-------| +| STANDARD (Common) | 60 | Grau | +| SELTEN (Uncommon) | 25 | Grün | +| EPISCH (Rare) | 12 | Blau | +| LEGENDÄR (Epic) | 3 | Lila | + +Shop zeigt nach jeder Welle 4 zufällige Items (gekaufte werden aus Folge-Rolls ausgeschlossen). Credits kommen aus: +- Kill: 15 Credits × `credit_bonus` +- Passiv: 5 Credits alle 10 Sekunden +- BigWipe überlebt: 25 Credits × `credit_bonus` +- Boss getötet: 150 (Miniboss) / 300 (Boss) Credits + +### Werkstatt-Flow (`shop_ui.gd`) + +Zwei Phasen pro Shop-Öffnung: + +1. **Attribut-Phase** — nur in **ungeraden Wellen** (1, 3, 5, …). 3 zufällige Gratis-Buffs, einer wird gewählt. Werte `+8 % bis +18 %`. Gerade Wellen (2, 4, …) starten direkt in der Shop-Phase → dämpft frühes Snowballing. +2. **Shop-Phase** — 4 Karten aus dem Plugin-Pool. Kauf per ENTER, Reroll per R, SPACE/ESC zum Verlassen. + +**Reroll-Kosten:** `60 + 42 × reroll_count` CR → `60 / 102 / 144 / 186 / 228 …`. Der `reroll_count` wird in `main.gd` (`reroll_count_p1` / `_p2`) gehalten und **wellenübergreifend** persistiert — verteuerter Reroll schützt den Rest des Runs vor billigem Ketten-Reroll. Reset nur bei neuem Run / Game Over. + +--- + +## Input-Mapping + +| Aktion | Spieler 1 | Spieler 2 | +|--------|-----------|-----------| +| Schub | Pfeil Oben | W | +| Links | Pfeil Links | A | +| Rechts | Pfeil Rechts | D | +| Schießen | Leertaste | F | +| BigWipe | N | E | + +--- + +## Wellen-Progression + +| Welle | Dauer | Gegner | Boss | +|-------|-------|--------|------| +| 1 | 20s | 2 | — | +| 3 | 36s | 3 | — | +| 5 | 52s | 4 | WRAITH (Miniboss) | +| 7 | 68s | 5 | — | +| 8 | 76s | 5 | LEVIATHAN (Full Boss) | +| 10+ | ≤120s | 6 (mit Stats!) | — | +| 13+ | 120s | 8 (Cap) | — | + +Formel: `enemy_count = min(2 + (wave-1)/2, 8)`. Enemy-Stats bleiben Welle 1–9 auf Basis; ab Welle 10 bekommt jeder Gegner zusätzlich 1 zufälliges Legacy-Item. + +**Schwierigkeits-Ramp:** `difficulty = clamp(game_time / 300.0, 0.0, 1.0)` — steuert Kometen-Spawn-Rate, BH-Cap und mehr. + +--- + +## Balancing-Notizen + +Die Power-Kurve ist bewusst **langsam**. Quellen wurden 2026-04-20 global gesenkt, um zu verhindern, dass der Spieler Welle 3 bereits dominiert. + +### Plugin-Items (Kern-Multiplikatoren) + +| Kategorie | Item | Kern-Effekt | Kosten | +|-----------|------|-------------|--------| +| Waffe | `wk_burst` (EPIC) | fire_rate ×1.80, damage ×0.45 | 160 | +| Waffe | `wk_laser` | fire_rate ×1.35, damage ×0.70 | 115 | +| Waffe | `wk_plasma` | damage ×1.55, proj_speed ×0.60 | 130 | +| Waffe | `wk_ion` | damage ×1.25, +1 Projektil, speed ×0.70 | 140 | +| Waffe | `wk_rail` | proj_speed ×1.50, fire_rate ×0.50 | 125 | +| Waffe | `wk_sniper` | proj_speed ×1.30, damage ×1.18, fire_rate ×0.55 | 115 | +| Waffe | `wk_shotgun` | +2 Projektile, proj_speed ×0.40, damage ×0.80 | 105 | +| Waffe | `wk_scatter` | +1 Projektil, damage ×0.75 | 115 | +| Antrieb | `drive_overdrive` | speed ×1.35, −1 Schild | 95 | +| Antrieb | `drive_quantum` | speed ×1.22, turn ×1.18, proj_speed ×0.80 | 135 | +| Antrieb | `drive_steer` | turn ×1.35, speed ×0.90 | 85 | +| Hülle | `hull_giant` (EPIC) | +2 Schild, invuln ×1.20, speed ×0.70, turn ×0.80 | 220 | +| Hülle | `hull_plating` | +1 Schild, speed ×0.85 | 120 | +| Hülle | `hull_reaktor` | invuln ×1.45, fire_rate ×0.85 | 115 | +| Hülle | `hull_nullfeld` | bh_resist +0.45, speed ×0.80 | 125 | +| Spezial | `special_credit_mag` | credit_bonus ×1.18 | 170 | +| Spezial | `special_wipe_core` | wipe_mult ×0.65 | 150 | + +### Legacy-ITEMS (nur Enemy-Boosts Welle 10+) + +| id | Effekt | Kosten | +|----|--------|--------| +| thrust_1 | speed ×1.12 | 50 | +| firerate_1 | fire_rate ×1.15 | 50 | +| damage_1 | damage ×1.15 | 50 | +| shield_1 | +1 Schild | 55 | +| firerate_2 | fire_rate ×1.22 | 90 | +| damage_2 | damage ×1.22, proj_speed ×1.10 | 100 | + +### Attribute (Gratis, ungerade Wellen) + +`ATTR_POOL` in `shop_ui.gd` — Werte: speed/turn/fire/damage/proj je **1.08–1.12**, invuln 1.18, credit_bonus 1.08, Schild +1. + +--- + +## Audio-System (`sound_manager.gd`) + +Kein einziger Audio-File im Projekt. Alle Sounds werden mit `AudioStreamGenerator` pro Frame synthetisiert: + +- `_play_tone(freq, duration, wave, vol_db, end_freq)` — Sinus/Säge/Square mit Frequenz-Sweep und Hüllkurve +- `_play_noise(duration, vol_db)` — Weißes Rauschen mit Decay +- `_play_chord_fanfare()` — Drei Töne mit kurzem Delay (BigWipe überlebt) + +SFX-Typen: `player_shoot`, `enemy_shoot`, `enemy_die`, `player_die`, `antimatter_hit`, `bh_swallow`, `wipe_start`, `wipe_flash`, `wipe_survived`, `smbh_spawn`, `antimatter_swarm` + +--- + +## Grafik-System + +**Viewport:** 960×600px, Stretch-Modus `canvas_items / expand` +**Hintergrundfarbe:** `#0a0a14` (Tiefschwarz-Blau) +**FPS-Cap:** 60 +**Pixel-Filter:** Nearest-Neighbor (`default_texture_filter=0`) + +Alle Schiffe benutzen `HULL_PIXELS`-Arrays — lokale Koordinaten-Offsets, die per `cos(heading)/sin(heading)` in Weltkoordinaten gedreht werden. Jeder "Pixel" ist ein 3×3-`draw_rect`. + +--- + +## Signalfluss + +``` +game_world → main_node.on_game_over() # alle Spieler tot +game_world → main_node.on_wave_complete() # Wave-Timer abgelaufen +game_world → main_node.add_credits(player, n) # Credits vergeben +game_world → main_node.start_boss_music(final) # Boss spawnt +game_world → main_node.boss_phase_changed(2) # Leviathan Phase 2 +big_wipe.wipe_complete → game_world._on_wipe_complete() +shop_ui.shop_closed → main._on_shop_closed() +pause_menu.resume_requested → main._on_pause_resume() +main_menu.mode_selected → main._on_mode_selected(multi) +``` + +--- + +## MCP-Plugin (`addons/godot_mcp/`) + +Das Godot-MCP-Pro-Plugin verbindet den Godot-Editor per WebSocket mit externen KI-Tools (Claude Code). Ermöglicht das Lesen/Schreiben von Szenen, Scripts und Eigenschaften direkt aus der KI-Konversation heraus. Nur für die Entwicklung relevant — kein Einfluss auf das Spiel. Referenz dazu in der CLaude.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..ed2c889 --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,344 @@ +[preset.0] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/build9/spacel.exe" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +shader_baker/enabled=false +binary_format/architecture="x86_64" +codesign/enable=false +codesign/timestamp=true +codesign/timestamp_server_url="" +codesign/digest_algorithm=1 +codesign/description="" +codesign/custom_options=PackedStringArray() +application/modify_resources=true +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="" +application/product_version="" +application/company_name="" +application/product_name="" +application/file_description="" +application/copyright="Eric Reischel (alpcaman)" +application/trademarks="" +application/export_angle=0 +application/export_d3d12=0 +application/d3d12_agility_sdk_multiarch=true +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}' +$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}' +$trigger = New-ScheduledTaskTrigger -Once -At 00:00 +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings +Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true +Start-ScheduledTask -TaskName godot_remote_debug +while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 } +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue" +ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue +Remove-Item -Recurse -Force '{temp_dir}'" + +[preset.1] + +name="Linux" +platform="Linux" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/build9/spacelbuild8.x86_64" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +shader_baker/enabled=false +binary_format/architecture="x86_64" +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="#!/usr/bin/env bash +export DISPLAY=:0 +unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\" +\"{temp_dir}/{exe_name}\" {cmd_args}" +ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash +pkill -x -f \"{temp_dir}/{exe_name} {cmd_args}\" +rm -rf \"{temp_dir}\"" + +[preset.2] + +name="Android" +platform="Android" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/build9/spacel.apk" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.2.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=false +gradle_build/gradle_build_directory="" +gradle_build/android_source_template="" +gradle_build/compress_native_libraries=false +gradle_build/export_format=0 +gradle_build/min_sdk="" +gradle_build/target_sdk="" +gradle_build/custom_theme_attributes={} +architectures/armeabi-v7a=false +architectures/arm64-v8a=true +architectures/x86=false +architectures/x86_64=false +version/code=1 +version/name="" +package/unique_name="com.alpacaman.$genname" +package/name="spacel" +package/signed=true +package/app_category=2 +package/retain_data_on_uninstall=false +package/exclude_from_recents=false +package/show_in_android_tv=false +package/show_in_app_library=true +package/show_as_launcher_app=false +launcher_icons/main_192x192="" +launcher_icons/adaptive_foreground_432x432="" +launcher_icons/adaptive_background_432x432="" +launcher_icons/adaptive_monochrome_432x432="" +graphics/opengl_debug=false +shader_baker/enabled=false +xr_features/xr_mode=0 +gesture/swipe_to_dismiss=false +screen/immersive_mode=true +screen/edge_to_edge=false +screen/support_small=true +screen/support_normal=true +screen/support_large=true +screen/support_xlarge=true +screen/background_color=Color(0, 0, 0, 1) +user_data_backup/allow=false +command_line/extra_args="" +apk_expansion/enable=false +apk_expansion/SALT="" +apk_expansion/public_key="" +permissions/custom_permissions=PackedStringArray() +permissions/access_checkin_properties=false +permissions/access_coarse_location=false +permissions/access_fine_location=false +permissions/access_location_extra_commands=false +permissions/access_media_location=false +permissions/access_mock_location=false +permissions/access_network_state=false +permissions/access_surface_flinger=false +permissions/access_wifi_state=false +permissions/account_manager=false +permissions/add_voicemail=false +permissions/authenticate_accounts=false +permissions/battery_stats=false +permissions/bind_accessibility_service=false +permissions/bind_appwidget=false +permissions/bind_device_admin=false +permissions/bind_input_method=false +permissions/bind_nfc_service=false +permissions/bind_notification_listener_service=false +permissions/bind_print_service=false +permissions/bind_remoteviews=false +permissions/bind_text_service=false +permissions/bind_vpn_service=false +permissions/bind_wallpaper=false +permissions/bluetooth=false +permissions/bluetooth_admin=false +permissions/bluetooth_privileged=false +permissions/brick=false +permissions/broadcast_package_removed=false +permissions/broadcast_sms=false +permissions/broadcast_sticky=false +permissions/broadcast_wap_push=false +permissions/call_phone=false +permissions/call_privileged=false +permissions/camera=false +permissions/capture_audio_output=false +permissions/capture_secure_video_output=false +permissions/capture_video_output=false +permissions/change_component_enabled_state=false +permissions/change_configuration=false +permissions/change_network_state=false +permissions/change_wifi_multicast_state=false +permissions/change_wifi_state=false +permissions/clear_app_cache=false +permissions/clear_app_user_data=false +permissions/control_location_updates=true +permissions/delete_cache_files=false +permissions/delete_packages=false +permissions/device_power=false +permissions/diagnostic=false +permissions/disable_keyguard=false +permissions/dump=false +permissions/expand_status_bar=false +permissions/factory_test=false +permissions/flashlight=false +permissions/force_back=false +permissions/get_accounts=false +permissions/get_package_size=false +permissions/get_tasks=false +permissions/get_top_activity_info=false +permissions/global_search=false +permissions/hardware_test=false +permissions/inject_events=false +permissions/install_location_provider=false +permissions/install_packages=false +permissions/install_shortcut=false +permissions/internal_system_window=false +permissions/internet=false +permissions/kill_background_processes=false +permissions/location_hardware=false +permissions/manage_accounts=false +permissions/manage_app_tokens=false +permissions/manage_documents=false +permissions/manage_external_storage=false +permissions/manage_media=false +permissions/master_clear=false +permissions/media_content_control=false +permissions/modify_audio_settings=false +permissions/modify_phone_state=false +permissions/mount_format_filesystems=false +permissions/mount_unmount_filesystems=false +permissions/nfc=false +permissions/persistent_activity=false +permissions/post_notifications=false +permissions/process_outgoing_calls=false +permissions/read_calendar=false +permissions/read_call_log=false +permissions/read_contacts=false +permissions/read_external_storage=false +permissions/read_frame_buffer=false +permissions/read_history_bookmarks=false +permissions/read_input_state=false +permissions/read_logs=false +permissions/read_media_audio=false +permissions/read_media_images=false +permissions/read_media_video=false +permissions/read_media_visual_user_selected=false +permissions/read_phone_state=false +permissions/read_profile=false +permissions/read_sms=false +permissions/read_social_stream=false +permissions/read_sync_settings=false +permissions/read_sync_stats=false +permissions/read_user_dictionary=false +permissions/reboot=false +permissions/receive_boot_completed=false +permissions/receive_mms=false +permissions/receive_sms=false +permissions/receive_wap_push=false +permissions/record_audio=false +permissions/reorder_tasks=false +permissions/restart_packages=false +permissions/send_respond_via_message=false +permissions/send_sms=false +permissions/set_activity_watcher=false +permissions/set_alarm=false +permissions/set_always_finish=false +permissions/set_animation_scale=false +permissions/set_debug_app=false +permissions/set_orientation=false +permissions/set_pointer_speed=false +permissions/set_preferred_applications=false +permissions/set_process_limit=false +permissions/set_time=false +permissions/set_time_zone=false +permissions/set_wallpaper=false +permissions/set_wallpaper_hints=false +permissions/signal_persistent_processes=false +permissions/status_bar=false +permissions/subscribed_feeds_read=false +permissions/subscribed_feeds_write=false +permissions/system_alert_window=false +permissions/transmit_ir=false +permissions/uninstall_shortcut=false +permissions/update_device_stats=false +permissions/use_credentials=false +permissions/use_sip=false +permissions/vibrate=false +permissions/wake_lock=false +permissions/write_apn_settings=false +permissions/write_calendar=false +permissions/write_call_log=false +permissions/write_contacts=false +permissions/write_external_storage=false +permissions/write_gservices=false +permissions/write_history_bookmarks=false +permissions/write_profile=false +permissions/write_secure_settings=false +permissions/write_settings=false +permissions/write_sms=false +permissions/write_social_stream=false +permissions/write_sync_settings=false +permissions/write_user_dictionary=false diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..5737890 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cttlglpbaw6jc" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/items/drives/drive_overdrive.gd b/items/drives/drive_overdrive.gd new file mode 100644 index 0000000..b81f3c7 --- /dev/null +++ b/items/drives/drive_overdrive.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "drive_overdrive" + name = "Überantrieb" + name_en = "Overdrive" + desc = "Massiver Speed-Schub — fragilere Hülle" + desc_en = "Massive speed boost — fragile hull" + category = "ANTRIEBSMOD" + category_en = "DRIVE MOD" + icon = "▲" + cost = 95 + rarity = 1 + effects = { "speed_mult": 1.35, "shield_charges": -1 } + visual_pixels = [ + [-5, 0, "accent"], + [-6, 0, "bright"], + [-5, -1, "mid"], + [-5, 1, "mid"], + ] + hull_size_bonus = 0.0 diff --git a/items/drives/drive_overdrive.gd.uid b/items/drives/drive_overdrive.gd.uid new file mode 100644 index 0000000..b9af208 --- /dev/null +++ b/items/drives/drive_overdrive.gd.uid @@ -0,0 +1 @@ +uid://dft6ejbi8x82t diff --git a/items/drives/drive_quantum.gd b/items/drives/drive_quantum.gd new file mode 100644 index 0000000..dad8849 --- /dev/null +++ b/items/drives/drive_quantum.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "drive_quantum" + name = "Quantenantrieb" + name_en = "Quantum Drive" + desc = "Speed + Wendekraft — fragile Projektile" + desc_en = "Speed + agility — fragile projectiles" + category = "ANTRIEBSMOD" + category_en = "DRIVE MOD" + icon = "◈" + cost = 135 + rarity = 2 + effects = { "speed_mult": 1.22, "turn_mult": 1.18, "bullet_speed_mult": 0.80 } + visual_pixels = [ + [-5, -2, "accent"], + [-5, 2, "accent"], + [-6, -3, "mid"], + [-6, 3, "mid"], + ] + hull_size_bonus = 0.0 diff --git a/items/drives/drive_quantum.gd.uid b/items/drives/drive_quantum.gd.uid new file mode 100644 index 0000000..ab60158 --- /dev/null +++ b/items/drives/drive_quantum.gd.uid @@ -0,0 +1 @@ +uid://cey4o4kxhhdmn diff --git a/items/drives/drive_steer.gd b/items/drives/drive_steer.gd new file mode 100644 index 0000000..30b4c0e --- /dev/null +++ b/items/drives/drive_steer.gd @@ -0,0 +1,19 @@ +extends ItemDef + +func _init() -> void: + id = "drive_steer" + name = "Steuerdüsen" + name_en = "Steering Jets" + desc = "Massiv wendiger — niedrigerer Topspeed" + desc_en = "Massively more agile — lower top speed" + category = "ANTRIEBSMOD" + category_en = "DRIVE MOD" + icon = "↺" + cost = 85 + rarity = 0 + effects = { "turn_mult": 1.35, "speed_mult": 0.90 } + visual_pixels = [ + [-3, -3, "bright"], + [-3, 3, "bright"], + ] + hull_size_bonus = 0.0 diff --git a/items/drives/drive_steer.gd.uid b/items/drives/drive_steer.gd.uid new file mode 100644 index 0000000..27b8976 --- /dev/null +++ b/items/drives/drive_steer.gd.uid @@ -0,0 +1 @@ +uid://b1nqolbilm25g diff --git a/items/hull/hull_giant.gd b/items/hull/hull_giant.gd new file mode 100644 index 0000000..b09e12f --- /dev/null +++ b/items/hull/hull_giant.gd @@ -0,0 +1,23 @@ +extends ItemDef + +func _init() -> void: + id = "hull_giant" + name = "Kolossal-Hülle" + name_en = "Colossal Hull" + desc = "Massiv verstärktes Schiff — stark vergrößert, sehr träge" + desc_en = "Massively reinforced ship — much larger, very sluggish" + category = "HÜLLENMOD" + category_en = "HULL MOD" + icon = "◼" + cost = 220 + rarity = 3 + effects = { "shield_charges": 2, "invuln_mult": 1.20, "speed_mult": 0.70, "turn_mult": 0.80 } + visual_pixels = [ + [-5, -6, "shadow"], + [-5, 6, "shadow"], + [ 2, -6, "dim"], + [ 2, 6, "dim"], + [-5, -5, "mid"], + [-5, 5, "mid"], + ] + hull_size_bonus = 0.8 diff --git a/items/hull/hull_giant.gd.uid b/items/hull/hull_giant.gd.uid new file mode 100644 index 0000000..a190443 --- /dev/null +++ b/items/hull/hull_giant.gd.uid @@ -0,0 +1 @@ +uid://cne4dtcoi12x4 diff --git a/items/hull/hull_nullfeld.gd b/items/hull/hull_nullfeld.gd new file mode 100644 index 0000000..56fa1ec --- /dev/null +++ b/items/hull/hull_nullfeld.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "hull_nullfeld" + name = "Nullfeld-Hülle" + name_en = "Null-Field Hull" + desc = "Schwerkraft-Immunität — langsamer" + desc_en = "Gravity immunity — slower" + category = "HÜLLENMOD" + category_en = "HULL MOD" + icon = "⬡" + cost = 125 + rarity = 2 + effects = { "bh_resist": 0.45, "speed_mult": 0.80 } + visual_pixels = [ + [-1, -4, "accent"], + [-1, 4, "accent"], + [-3, -3, "dim"], + [-3, 3, "dim"], + ] + hull_size_bonus = 0.0 diff --git a/items/hull/hull_nullfeld.gd.uid b/items/hull/hull_nullfeld.gd.uid new file mode 100644 index 0000000..5b6644e --- /dev/null +++ b/items/hull/hull_nullfeld.gd.uid @@ -0,0 +1 @@ +uid://xnlr0664qydt diff --git a/items/hull/hull_plating.gd b/items/hull/hull_plating.gd new file mode 100644 index 0000000..dc6a6d3 --- /dev/null +++ b/items/hull/hull_plating.gd @@ -0,0 +1,23 @@ +extends ItemDef + +func _init() -> void: + id = "hull_plating" + name = "Panzerplatten" + name_en = "Armor Plating" + desc = "Schweres Titan-Gehäuse — breiter aber träger" + desc_en = "Heavy titanium shell — wider but slower" + category = "HÜLLENMOD" + category_en = "HULL MOD" + icon = "◎" + cost = 120 + rarity = 1 + effects = { "shield_charges": 1, "speed_mult": 0.85 } + visual_pixels = [ + [-3, -5, "dim"], + [-3, 5, "dim"], + [-4, -5, "shadow"], + [-4, 5, "shadow"], + [-2, -5, "mid"], + [-2, 5, "mid"], + ] + hull_size_bonus = 0.4 diff --git a/items/hull/hull_plating.gd.uid b/items/hull/hull_plating.gd.uid new file mode 100644 index 0000000..9cb2dc9 --- /dev/null +++ b/items/hull/hull_plating.gd.uid @@ -0,0 +1 @@ +uid://gwidt7dkenor diff --git a/items/hull/hull_reaktor.gd b/items/hull/hull_reaktor.gd new file mode 100644 index 0000000..d67d22c --- /dev/null +++ b/items/hull/hull_reaktor.gd @@ -0,0 +1,20 @@ +extends ItemDef + +func _init() -> void: + id = "hull_reaktor" + name = "Reaktorschild" + name_en = "Reactor Shield" + desc = "Verlängerte Unverwundbarkeit — geringere Feuerrate" + desc_en = "Extended invulnerability — reduced fire rate" + category = "HÜLLENMOD" + category_en = "HULL MOD" + icon = "⬢" + cost = 115 + rarity = 1 + effects = { "invuln_mult": 1.45, "fire_rate_mult": 0.85 } + visual_pixels = [ + [-2, -3, "accent"], + [-2, 3, "accent"], + [-1, 0, "bright"], + ] + hull_size_bonus = 0.0 diff --git a/items/hull/hull_reaktor.gd.uid b/items/hull/hull_reaktor.gd.uid new file mode 100644 index 0000000..1e996be --- /dev/null +++ b/items/hull/hull_reaktor.gd.uid @@ -0,0 +1 @@ +uid://bvaoatus42p8j diff --git a/items/special/special_credit_mag.gd b/items/special/special_credit_mag.gd new file mode 100644 index 0000000..181d0ff --- /dev/null +++ b/items/special/special_credit_mag.gd @@ -0,0 +1,19 @@ +extends ItemDef + +func _init() -> void: + id = "special_credit_mag" + name = "Kreditmagnet" + name_en = "Credit Magnet" + desc = "+18% Kreditgewinn — keine Nachteile" + desc_en = "+18% credit gain — no downsides" + category = "SPEZIAL" + category_en = "SPECIAL" + icon = "¢" + cost = 170 + rarity = 2 + effects = { "credit_bonus": 1.18 } + visual_pixels = [ + [0, -6, "accent"], + [0, 6, "accent"], + ] + hull_size_bonus = 0.0 diff --git a/items/special/special_credit_mag.gd.uid b/items/special/special_credit_mag.gd.uid new file mode 100644 index 0000000..8a1654c --- /dev/null +++ b/items/special/special_credit_mag.gd.uid @@ -0,0 +1 @@ +uid://c0g7a21c4snoj diff --git a/items/special/special_wipe_core.gd b/items/special/special_wipe_core.gd new file mode 100644 index 0000000..f6c3e3b --- /dev/null +++ b/items/special/special_wipe_core.gd @@ -0,0 +1,20 @@ +extends ItemDef + +func _init() -> void: + id = "special_wipe_core" + name = "Warp-Kern" + name_en = "Warp Core" + desc = "Big Wipe lädt 35% schneller" + desc_en = "Big Wipe charges 35% faster" + category = "SPEZIAL" + category_en = "SPECIAL" + icon = "✺" + cost = 150 + rarity = 2 + effects = { "wipe_mult": 0.65 } + visual_pixels = [ + [-2, 0, "accent"], + [-3, -1, "bright"], + [-3, 1, "bright"], + ] + hull_size_bonus = 0.0 diff --git a/items/special/special_wipe_core.gd.uid b/items/special/special_wipe_core.gd.uid new file mode 100644 index 0000000..186092f --- /dev/null +++ b/items/special/special_wipe_core.gd.uid @@ -0,0 +1 @@ +uid://dgfs6a1mixcxi diff --git a/items/weapons/wk_burst.gd b/items/weapons/wk_burst.gd new file mode 100644 index 0000000..7916333 --- /dev/null +++ b/items/weapons/wk_burst.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "wk_burst" + name = "Burst-Kern" + name_en = "Burst Core" + desc = "Feuerrate explodiert — schwächere Treffer" + desc_en = "Fire rate explodes — weaker hits" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "✦" + cost = 160 + rarity = 3 + effects = { "fire_rate_mult": 1.80, "damage_mult": 0.45 } + visual_pixels = [ + [3, 0, "accent"], + [2, -1, "bright"], + [2, 1, "bright"], + [1, 0, "nose"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_burst.gd.uid b/items/weapons/wk_burst.gd.uid new file mode 100644 index 0000000..f026993 --- /dev/null +++ b/items/weapons/wk_burst.gd.uid @@ -0,0 +1 @@ +uid://j6tdxxbnnxfs diff --git a/items/weapons/wk_charge.gd b/items/weapons/wk_charge.gd new file mode 100644 index 0000000..21738e4 --- /dev/null +++ b/items/weapons/wk_charge.gd @@ -0,0 +1,24 @@ +extends ItemDef + +func _init() -> void: + id = "wk_charge" + name = "Laserkanone" + name_en = "Charge Cannon" + desc = "Gedrückt halten = Aufladen — loslassen = mächtiger Schuss" + desc_en = "Hold to charge — release for a powerful shot" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "◎" + cost = 150 + rarity = 3 + effects = { "damage_mult": 1.60, "fire_rate_mult": 0.40 } + visual_pixels = [ + [8, 0, "nose"], + [9, 0, "bright"], + [10, 0, "accent"], + [7, -1, "edge"], + [7, 1, "edge"], + [6, -2, "dim"], + [6, 2, "dim"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_charge.gd.uid b/items/weapons/wk_charge.gd.uid new file mode 100644 index 0000000..ddd807d --- /dev/null +++ b/items/weapons/wk_charge.gd.uid @@ -0,0 +1 @@ +uid://bw14fge7v00xq diff --git a/items/weapons/wk_ion.gd b/items/weapons/wk_ion.gd new file mode 100644 index 0000000..46a24fe --- /dev/null +++ b/items/weapons/wk_ion.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "wk_ion" + name = "Ionenstrahl" + name_en = "Ion Beam" + desc = "Schaden + zusätzliches Projektil — langsamer" + desc_en = "Damage + extra projectile — slower" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "⚡" + cost = 140 + rarity = 2 + effects = { "damage_mult": 1.25, "bullet_count": 1, "speed_mult": 0.70 } + visual_pixels = [ + [5, 0, "accent"], + [7, 0, "bright"], + [4, -2, "accent"], + [4, 2, "accent"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_ion.gd.uid b/items/weapons/wk_ion.gd.uid new file mode 100644 index 0000000..f2ae441 --- /dev/null +++ b/items/weapons/wk_ion.gd.uid @@ -0,0 +1 @@ +uid://c24q5i65gcqhl diff --git a/items/weapons/wk_laser.gd b/items/weapons/wk_laser.gd new file mode 100644 index 0000000..4781303 --- /dev/null +++ b/items/weapons/wk_laser.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "wk_laser" + name = "Laser-Kanone" + name_en = "Laser Gun" + desc = "Schnelles Dauerfeuer — schwächere Treffer" + desc_en = "Fast sustained fire — weaker hits" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "◈" + cost = 115 + rarity = 1 + effects = { "fire_rate_mult": 1.35, "damage_mult": 0.70 } + visual_pixels = [ + [9, -2, "accent"], + [9, 2, "accent"], + [8, -2, "bright"], + [8, 2, "bright"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_laser.gd.uid b/items/weapons/wk_laser.gd.uid new file mode 100644 index 0000000..dab03e7 --- /dev/null +++ b/items/weapons/wk_laser.gd.uid @@ -0,0 +1 @@ +uid://7psg6avxedy2 diff --git a/items/weapons/wk_plasma.gd b/items/weapons/wk_plasma.gd new file mode 100644 index 0000000..0fea912 --- /dev/null +++ b/items/weapons/wk_plasma.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "wk_plasma" + name = "Plasmawerfer" + name_en = "Plasma Launcher" + desc = "Explosiver Schaden — träge Projektile" + desc_en = "Explosive damage — sluggish projectiles" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "◉" + cost = 130 + rarity = 2 + effects = { "damage_mult": 1.55, "bullet_speed_mult": 0.60 } + visual_pixels = [ + [7, 0, "accent"], + [8, 0, "bright"], + [6, -3, "dim"], + [6, 3, "dim"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_plasma.gd.uid b/items/weapons/wk_plasma.gd.uid new file mode 100644 index 0000000..15310c8 --- /dev/null +++ b/items/weapons/wk_plasma.gd.uid @@ -0,0 +1 @@ +uid://by4hroe3nyrb8 diff --git a/items/weapons/wk_rail.gd b/items/weapons/wk_rail.gd new file mode 100644 index 0000000..cb72ca7 --- /dev/null +++ b/items/weapons/wk_rail.gd @@ -0,0 +1,22 @@ +extends ItemDef + +func _init() -> void: + id = "wk_rail" + name = "Railgun" + name_en = "Railgun" + desc = "Hypersonisches Projektil — lange Ladezeit" + desc_en = "Hypersonic projectile — long reload" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "→" + cost = 125 + rarity = 2 + effects = { "bullet_speed_mult": 1.50, "fire_rate_mult": 0.50 } + visual_pixels = [ + [7, 0, "bright"], + [8, 0, "nose"], + [9, 0, "accent"], + [6, -1, "edge"], + [6, 1, "edge"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_rail.gd.uid b/items/weapons/wk_rail.gd.uid new file mode 100644 index 0000000..513913a --- /dev/null +++ b/items/weapons/wk_rail.gd.uid @@ -0,0 +1 @@ +uid://ca0ny064gti12 diff --git a/items/weapons/wk_scatter.gd b/items/weapons/wk_scatter.gd new file mode 100644 index 0000000..a3ef457 --- /dev/null +++ b/items/weapons/wk_scatter.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "wk_scatter" + name = "Streuschuß" + name_en = "Scatter Shot" + desc = "Mehr Projektile — schwächer pro Treffer" + desc_en = "More projectiles — weaker per hit" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "⁂" + cost = 115 + rarity = 1 + effects = { "bullet_count": 1, "damage_mult": 0.75 } + visual_pixels = [ + [5, -4, "mid"], + [5, 4, "mid"], + [6, -3, "accent"], + [6, 3, "accent"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_scatter.gd.uid b/items/weapons/wk_scatter.gd.uid new file mode 100644 index 0000000..dda83ff --- /dev/null +++ b/items/weapons/wk_scatter.gd.uid @@ -0,0 +1 @@ +uid://chbitntcvo2sp diff --git a/items/weapons/wk_shotgun.gd b/items/weapons/wk_shotgun.gd new file mode 100644 index 0000000..d5020ba --- /dev/null +++ b/items/weapons/wk_shotgun.gd @@ -0,0 +1,23 @@ +extends ItemDef + +func _init() -> void: + id = "wk_shotgun" + name = "Schrotflinte" + name_en = "Shotgun" + desc = "Breite Salve — kurze Reichweite" + desc_en = "Wide burst — short range" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "▦" + cost = 105 + rarity = 1 + effects = { "bullet_count": 2, "bullet_speed_mult": 0.40, "damage_mult": 0.80 } + visual_pixels = [ + [5, -3, "mid"], + [5, 0, "mid"], + [5, 3, "mid"], + [6, -3, "accent"], + [6, 0, "accent"], + [6, 3, "accent"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_shotgun.gd.uid b/items/weapons/wk_shotgun.gd.uid new file mode 100644 index 0000000..f317935 --- /dev/null +++ b/items/weapons/wk_shotgun.gd.uid @@ -0,0 +1 @@ +uid://m4ec31l51x41 diff --git a/items/weapons/wk_sniper.gd b/items/weapons/wk_sniper.gd new file mode 100644 index 0000000..0ff40b6 --- /dev/null +++ b/items/weapons/wk_sniper.gd @@ -0,0 +1,21 @@ +extends ItemDef + +func _init() -> void: + id = "wk_sniper" + name = "Präzisionswerfer" + name_en = "Precision Rifle" + desc = "Präzision + Reichweite — halbierte Kadenz" + desc_en = "Precision + range — halved fire rate" + category = "WAFFENMODUL" + category_en = "WEAPON MOD" + icon = "◎" + cost = 115 + rarity = 1 + effects = { "bullet_speed_mult": 1.30, "damage_mult": 1.18, "fire_rate_mult": 0.55 } + visual_pixels = [ + [7, 0, "edge"], + [8, 0, "dim"], + [9, 0, "bright"], + [10, 0, "nose"], + ] + hull_size_bonus = 0.0 diff --git a/items/weapons/wk_sniper.gd.uid b/items/weapons/wk_sniper.gd.uid new file mode 100644 index 0000000..e5ed2ec --- /dev/null +++ b/items/weapons/wk_sniper.gd.uid @@ -0,0 +1 @@ +uid://dloo2irxpr0r4 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..d17a79c --- /dev/null +++ b/project.godot @@ -0,0 +1,175 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="spacel" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.6", "Forward Plus") +run/max_fps=60.0 +config/icon="res://icon.svg" + +[autoload] + +Settings="*res://scripts/settings.gd" +SoundManager="*res://scripts/sound_manager.gd" +Leaderboard="*res://scripts/leaderboard.gd" +MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd" +MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd" +MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd" +Tr="*res://scripts/tr.gd" + +[display] + +window/size/viewport_width=960.0 +window/size/viewport_height=600.0 +window/size/mode=3.0 +window/size/window_width_override=960.0 +window/size/window_height_override=600.0 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" +window/handheld/orientation="landscape" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg") + +[input] + +ui_accept={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194310,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +] +} +ui_cancel={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":1,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":6,"pressure":0.0,"pressed":false,"script":null) +]) +} +ui_left={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":-1.0,"script":null) +]) +} +ui_right={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":1.0,"script":null) +]) +} +ui_up={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":-1.0,"script":null) +]) +} +ui_down={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":1.0,"script":null) +]) +} +p1_thrust={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":-1.0,"script":null) +] +} +p1_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":-1.0,"script":null) +] +} +p1_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":1.0,"script":null) +] +} +p1_shoot={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":2,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":5,"axis_value":1.0,"script":null) +] +} +p1_wipe={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":78,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":3,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":9,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":4,"axis_value":1.0,"script":null) +] +} +p2_thrust={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":87,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +p2_left={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":65,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +p2_right={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":68,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +p2_shoot={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":70,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +p2_wipe={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +p1_boost={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194325,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +p2_boost={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":81,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} + +[input_devices] + +pointing/emulate_touch_from_mouse=true + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +textures/canvas_textures/default_texture_filter=0.0 +rendering_device/driver.windows="d3d12" +textures/vram_compression/import_etc2_astc=true diff --git a/scenes/game_world.tscn b/scenes/game_world.tscn new file mode 100644 index 0000000..96e9468 --- /dev/null +++ b/scenes/game_world.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://cduyy76lfld7f"] + +[ext_resource type="Script" uid="uid://dgjwy8o1rgfd7" path="res://scripts/game_world.gd" id="1_0biqy"] + +[node name="GameWorld" type="Node2D" unique_id=536762585] +script = ExtResource("1_0biqy") diff --git a/scenes/hud.tscn b/scenes/hud.tscn new file mode 100644 index 0000000..ce69448 --- /dev/null +++ b/scenes/hud.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://c5o85b0e7tydc"] + +[ext_resource type="Script" uid="uid://kn3e6fiywxr7" path="res://scripts/hud.gd" id="1_ahhtf"] + +[node name="HUD" type="CanvasLayer" unique_id=941486947] +script = ExtResource("1_ahhtf") diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..5ac4740 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,42 @@ +[gd_scene format=3 uid="uid://bq46s1hievrq4"] + +[ext_resource type="Script" uid="uid://ctbcvqj0aoo3v" path="res://scripts/main.gd" id="1_o6xl0"] +[ext_resource type="PackedScene" uid="uid://cduyy76lfld7f" path="res://scenes/game_world.tscn" id="2_tipki"] +[ext_resource type="PackedScene" uid="uid://dpjrso6jkwunn" path="res://scenes/ship_select.tscn" id="4_choun"] +[ext_resource type="Script" uid="uid://18s6j8gme2ne" path="res://scripts/shop_ui.gd" id="5_tbgi4"] +[ext_resource type="PackedScene" uid="uid://c5o85b0e7tydc" path="res://scenes/hud.tscn" id="6_eb6dy"] +[ext_resource type="Script" uid="uid://lmuic5md3b5p" path="res://scripts/pause_menu.gd" id="7_pause"] +[ext_resource type="Script" uid="uid://cp36y6rjo8aqs" path="res://scripts/main_menu.gd" id="8_mainmenu"] +[ext_resource type="Script" uid="uid://dxun4apx8cxf1" path="res://scripts/music_player.gd" id="9_music"] +[ext_resource type="Script" uid="uid://dbj3vbayteqo0" path="res://scripts/atlas_ui.gd" id="10_atlas"] +[ext_resource type="Script" uid="uid://cn7sutxtu7qym" path="res://scripts/touch_controls.gd" id="11_touch"] + +[node name="Main" type="Node2D" unique_id=262978007] +script = ExtResource("1_o6xl0") + +[node name="GameWorld" parent="." unique_id=536762585 instance=ExtResource("2_tipki")] + +[node name="ShipSelectUI" parent="." unique_id=541251044 instance=ExtResource("4_choun")] + +[node name="HUD" parent="." unique_id=941486947 instance=ExtResource("6_eb6dy")] + +[node name="TouchControls" type="Node2D" parent="HUD" unique_id=1039181909] +script = ExtResource("11_touch") + +[node name="ShopUI" type="Node2D" parent="." unique_id=1039181903] +visible = false +script = ExtResource("5_tbgi4") + +[node name="PauseMenu" type="Node2D" parent="." unique_id=1039181904] +visible = false +script = ExtResource("7_pause") + +[node name="MainMenu" type="Node2D" parent="." unique_id=1039181905] +script = ExtResource("8_mainmenu") + +[node name="AtlasUI" type="Node2D" parent="." unique_id=1039181907] +visible = false +script = ExtResource("10_atlas") + +[node name="MusicPlayer" type="Node" parent="." unique_id=1039181906] +script = ExtResource("9_music") diff --git a/scenes/ship_select.tscn b/scenes/ship_select.tscn new file mode 100644 index 0000000..7cb8e9f --- /dev/null +++ b/scenes/ship_select.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://dpjrso6jkwunn"] + +[ext_resource type="Script" uid="uid://bc1kys1vqyll6" path="res://scripts/ship_select.gd" id="1_27sl3"] + +[node name="ShipSelectUI" type="Node2D" unique_id=541251044] +script = ExtResource("1_27sl3") diff --git a/scripts/atlas_ui.gd b/scripts/atlas_ui.gd new file mode 100644 index 0000000..cb86094 --- /dev/null +++ b/scripts/atlas_ui.gd @@ -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) diff --git a/scripts/atlas_ui.gd.uid b/scripts/atlas_ui.gd.uid new file mode 100644 index 0000000..9ad5335 --- /dev/null +++ b/scripts/atlas_ui.gd.uid @@ -0,0 +1 @@ +uid://dbj3vbayteqo0 diff --git a/scripts/big_wipe.gd b/scripts/big_wipe.gd new file mode 100644 index 0000000..927a1ee --- /dev/null +++ b/scripts/big_wipe.gd @@ -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)) diff --git a/scripts/big_wipe.gd.uid b/scripts/big_wipe.gd.uid new file mode 100644 index 0000000..c9e698f --- /dev/null +++ b/scripts/big_wipe.gd.uid @@ -0,0 +1 @@ +uid://qe52wshn58wj diff --git a/scripts/black_hole.gd b/scripts/black_hole.gd new file mode 100644 index 0000000..fbcee04 --- /dev/null +++ b/scripts/black_hole.gd @@ -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)) diff --git a/scripts/black_hole.gd.uid b/scripts/black_hole.gd.uid new file mode 100644 index 0000000..a1322bc --- /dev/null +++ b/scripts/black_hole.gd.uid @@ -0,0 +1 @@ +uid://dmqsqq8etgp7e diff --git a/scripts/boss_ship.gd b/scripts/boss_ship.gd new file mode 100644 index 0000000..8552b81 --- /dev/null +++ b/scripts/boss_ship.gd @@ -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) diff --git a/scripts/boss_ship.gd.uid b/scripts/boss_ship.gd.uid new file mode 100644 index 0000000..290b959 --- /dev/null +++ b/scripts/boss_ship.gd.uid @@ -0,0 +1 @@ +uid://r3q2arux2t1n diff --git a/scripts/bullet.gd b/scripts/bullet.gd new file mode 100644 index 0000000..0aca93f --- /dev/null +++ b/scripts/bullet.gd @@ -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)) diff --git a/scripts/bullet.gd.uid b/scripts/bullet.gd.uid new file mode 100644 index 0000000..addec2a --- /dev/null +++ b/scripts/bullet.gd.uid @@ -0,0 +1 @@ +uid://6a8ufydt7pd8 diff --git a/scripts/cosmic_objects.gd b/scripts/cosmic_objects.gd new file mode 100644 index 0000000..5215962 --- /dev/null +++ b/scripts/cosmic_objects.gd @@ -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)) diff --git a/scripts/cosmic_objects.gd.uid b/scripts/cosmic_objects.gd.uid new file mode 100644 index 0000000..f457d74 --- /dev/null +++ b/scripts/cosmic_objects.gd.uid @@ -0,0 +1 @@ +uid://j7pdrterps08 diff --git a/scripts/enemy_ship.gd b/scripts/enemy_ship.gd new file mode 100644 index 0000000..d178edd --- /dev/null +++ b/scripts/enemy_ship.gd @@ -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) diff --git a/scripts/enemy_ship.gd.uid b/scripts/enemy_ship.gd.uid new file mode 100644 index 0000000..1339115 --- /dev/null +++ b/scripts/enemy_ship.gd.uid @@ -0,0 +1 @@ +uid://fycowvc7jmfy diff --git a/scripts/game_world.gd b/scripts/game_world.gd new file mode 100644 index 0000000..5f2bb47 --- /dev/null +++ b/scripts/game_world.gd @@ -0,0 +1,1211 @@ +extends Node2D + +var W: float = 960.0 +var H: float = 600.0 +const STAR_COUNT := 80 +const STAR_MAX := 160 # hard cap including white hole injections +const PLANET_COUNT := 4 +const PLANET_MAX := 20 # hard cap including supernova spawns +const NEBULA_COUNT := 3 +const GALAXY_COUNT := 4 +const ENEMY_COUNT := 2 +const BH_START_COUNT := 1 +const BH_MAX_COUNT := 8 +const WHITE_HOLE_MAX := 2 # cap white holes so they don't pile up +const COMET_SPAWN_MIN := 3.0 +const COMET_SPAWN_MAX := 10.0 +const ANTIMATTER_SPAWN_MIN := 180.0 +const ANTIMATTER_SPAWN_MAX := 360.0 +const DIFFICULTY_RAMP_SECS := 300.0 # full difficulty at 5 minutes +const OBJECT_WIPE_THRESHOLD := 500 +const PHYS_DT := 1.0 / 60.0 +const MAX_ENEMEYS := 10000 + +var frame: int = 0 +var is_playing: bool = false +var is_multiplayer: bool = false +var palette_p1: Dictionary +var palette_p2: Dictionary + +var stars: Array = [] +var planets: Array = [] +var nebulae: Array = [] +var comets: Array = [] +var galaxies: Array = [] +var black_holes: Array = [] +var quasars: Array = [] +var white_holes: Array = [] +var neutron_stars: Array = [] +var antimatter: Array = [] +var bullets: Array = [] +var particles: Array = [] +var antimatter_stars: Array = [] + +var players: Array = [] +var enemies: Array = [] + +var comet_timer: float = 0.0 +var comet_next: float = 5.0 +var antimatter_timer: float = 0.0 +var antimatter_next: float = 50.0 +var credit_tick_timer: float = 0.0 + +var big_wipe: BigWipe = null +var wipe_count_p1: int = 0 +var wipe_count_p2: int = 0 +var main_node: Node = null +var menu_mode: bool = false + +var game_time: float = 0.0 +var difficulty: float = 0.0 + +var wave_duration: float = 60.0 # dynamisch: 20s Welle 1, +8s pro Welle, max 120s +var wave_timer: float = 0.0 +var wave_complete_flag: bool = false +var _boss: BossShip = null +var _wave_number_local: int = 1 + +var _phys_accum: float = 0.0 +var _planet_spawn_t: float = 0.0 + +func _ready() -> void: + _update_viewport_size() + get_viewport().size_changed.connect(_update_viewport_size) + set_process(false) + +func _update_viewport_size() -> void: + var vs: Vector2 = get_viewport_rect().size + W = vs.x + H = vs.y + +func init_menu_mode() -> void: + menu_mode = true + is_playing = false + frame = 0 + _phys_accum = 0.0 + _clear_all() + _spawn_universe() + # Spawn enemy ships so the simulation looks alive + for i in ENEMY_COUNT: + var e := EnemyShip.new() + var ex := W * 0.85 if i == 0 else W * 0.15 + var ey := H * 0.15 if i == 0 else H * 0.85 + e.init(ex, ey, i % 2, i) + enemies.append(e) + big_wipe = BigWipe.new() + big_wipe.wipe_complete.connect(_on_wipe_complete) + set_process(true) + +func init_world(pal1: Dictionary, pal2, multi: bool, stats1: ShipStats = null, stats2: ShipStats = null, wave: int = 1) -> void: + menu_mode = false + palette_p1 = pal1 + palette_p2 = pal2 if pal2 != null else {} + is_multiplayer = multi + is_playing = false + frame = 0 + _phys_accum = 0.0 + game_time = 0.0 + difficulty = 0.0 + credit_tick_timer = 0.0 + wave_timer = 0.0 + wave_complete_flag = false + _boss = null + _wave_number_local = wave + wave_duration = clamp(20.0 + float(wave - 1) * 8.0, 20.0, 120.0) + _clear_all() + _spawn_universe() + var p1 := Spaceship.new() + p1.init(W * 0.3, H * 0.5, pal1, 0) + p1.invuln_timer = Spaceship.INVULN_FRAMES * 3 # 4.5s grace at game start + if stats1 != null: p1.apply_stats(stats1) + players.append(p1) + if multi and pal2 != null: + var p2 := Spaceship.new() + p2.init(W * 0.7, H * 0.5, pal2, 1) + p2.invuln_timer = Spaceship.INVULN_FRAMES * 3 + if stats2 != null: p2.apply_stats(stats2) + players.append(p2) + # 2 enemies on wave 1, +1 per wave (faster scale than before), capped by MAX_ENEMEYS + var enemy_count: int = min(2 + (wave - 1), MAX_ENEMEYS) + for i in enemy_count: + var e := EnemyShip.new() + var ex: float + var ey: float + if i == 0: + ex = W * 0.85; ey = H * 0.15 + elif i == 1: + ex = W * 0.15; ey = H * 0.85 + else: + # Extra enemies from random edges + match randi() % 4: + 0: ex = randf_range(0.0, W); ey = 0.0 + 1: ex = randf_range(0.0, W); ey = H + 2: ex = 0.0; ey = randf_range(0.0, H) + _: ex = W; ey = randf_range(0.0, H) + e.init(ex, ey, i % 2, i) + if wave >= 10: + var item: Dictionary = ItemDB.roll_shop(1, [])[0] + var es := ShipStats.new() + es.apply_item(item["effects"]) + e.apply_stats(es) + enemies.append(e) + # Boss-Spawn auf Welle 5 und 8 + if wave == 5: + _boss = BossShip.new() + _boss.init_miniboss(W * 0.5, H * 0.5) + elif wave == 8: + _boss = BossShip.new() + _boss.init_boss(W * 0.5, H * 0.5) + big_wipe = BigWipe.new() + big_wipe.wipe_complete.connect(_on_wipe_complete) + set_process(true) + +func _clear_all() -> void: + stars.clear(); planets.clear(); nebulae.clear(); comets.clear() + galaxies.clear(); black_holes.clear(); quasars.clear() + white_holes.clear(); neutron_stars.clear(); antimatter.clear() + bullets.clear(); particles.clear(); players.clear(); enemies.clear() + antimatter_stars.clear() + +func _spawn_universe() -> void: + var star_count: int = [40, STAR_COUNT, 160][Settings.star_density] + for _i in star_count: + var s := CosmicObjects.Star.new() + s.init(randf()*W, randf()*H, W, H) + stars.append(s) + for _i in PLANET_COUNT: + var p := CosmicObjects.Planet.new() + p.init(randf()*W, randf()*H, W, H) + planets.append(p) + for _i in NEBULA_COUNT: + var n := CosmicObjects.Nebula.new() + n.init(W, H) + nebulae.append(n) + for _i in GALAXY_COUNT: + var g := CosmicObjects.Galaxy.new() + g.init(randf()*W, randf()*H) + galaxies.append(g) + # Spawn mobile black holes — keep them away from player spawn areas + var bh_attempts := 0 + var spawned_bhs := 0 + while spawned_bhs < BH_START_COUNT and bh_attempts < 30: + bh_attempts += 1 + var bx := randf_range(W * 0.1, W * 0.9) + var by := randf_range(H * 0.1, H * 0.9) + # Reject if too close to either player start + var d1 := sqrt((bx - W*0.3)*(bx - W*0.3) + (by - H*0.5)*(by - H*0.5)) + var d2 := sqrt((bx - W*0.7)*(bx - W*0.7) + (by - H*0.5)*(by - H*0.5)) + if d1 < 220.0 or d2 < 220.0: + continue + var bh := BlackHole.new() + bh.init(bx, by, true) + black_holes.append(bh) + spawned_bhs += 1 + var q := CosmicObjects.Quasar.new() + q.init(randf()*W, randf()*H) + quasars.append(q) + +func start_playing() -> void: + is_playing = true + wave_timer = 0.0 + wave_complete_flag = false + # Boss-Musik starten wenn Welle einen Boss hat + if _boss != null and main_node and main_node.has_method("start_boss_music"): + # LEVIATHAN (Welle 8) bekommt eigenen Track; WRAITH (Welle 5) den Standard-Boss-Track + main_node.start_boss_music(not _boss.is_miniboss) + +func on_player_died() -> void: + is_playing = false + +func get_score_data() -> Dictionary: + var data: Dictionary = {"p1_time": 0.0, "p1_kills": 0, "p1_wipe": 0, + "p2_time": 0.0, "p2_kills": 0, "p2_wipe": 0, "multiplayer": is_multiplayer} + if players.size() > 0: + var p0: Spaceship = players[0] + data["p1_time"] = p0.survival_time + data["p1_kills"] = p0.kills + data["p1_wipe"] = wipe_count_p1 * BigWipe.WIPE_BONUS + if players.size() > 1: + var p1: Spaceship = players[1] + data["p2_time"] = p1.survival_time + data["p2_kills"] = p1.kills + data["p2_wipe"] = wipe_count_p2 * BigWipe.WIPE_BONUS + return data + +func _total_object_count() -> int: + return stars.size() + planets.size() + comets.size() + galaxies.size() \ + + black_holes.size() + quasars.size() + white_holes.size() \ + + neutron_stars.size() + antimatter.size() + antimatter_stars.size() + +func _process(delta: float) -> void: + if not is_playing and not menu_mode: + queue_redraw() + return + _phys_accum = min(_phys_accum + delta, PHYS_DT * 5.0) + while _phys_accum >= PHYS_DT: + _phys_accum -= PHYS_DT + frame += 1 + _tick(PHYS_DT) + queue_redraw() + +func _tick(dt: float) -> void: + _handle_input(dt) + _update_objects(dt) + _update_bullets() + # Boss-Bewegung und Schüsse (vor Kollisionscheck, damit Kugeln im Array sind) + if _boss != null and not _boss.dead and is_playing and not menu_mode: + var boss_bullets: Array = _boss.update(dt, W * 0.5, H * 0.5) + for bb in boss_bullets: + bullets.append(bb) + _update_particles(dt) + _check_collisions() + _check_big_wipe() + if big_wipe and big_wipe.is_active(): + big_wipe.update(dt, is_multiplayer) + # Advance difficulty only during real gameplay + if not menu_mode and is_playing: + game_time += dt + difficulty = clamp(game_time / DIFFICULTY_RAMP_SECS, 0.0, 1.0) + # Passive credit trickle: 5 credits per 10 seconds + credit_tick_timer += dt + if credit_tick_timer >= 10.0: + credit_tick_timer = 0.0 + if main_node and players.size() > 0: + main_node.add_credits(0, 5) + # Wave timer — skaliert von 20s (Welle 1) auf 120s + wave_timer += dt + if wave_timer >= wave_duration and not wave_complete_flag: + wave_complete_flag = true + if main_node: + # Boss überlebt → Musik trotzdem zurückschalten + if _boss != null and main_node.has_method("stop_boss_music"): + main_node.stop_boss_music() + main_node.on_wave_complete() + # Boss gestorben → Welle sofort abschließen + Bonus-Credits + if _boss != null and _boss.dead and not wave_complete_flag: + wave_complete_flag = true + var boss_bonus: int = 150 if _boss.is_miniboss else 300 + if main_node: + main_node.add_credits(0, boss_bonus) + if players.size() > 1: + main_node.add_credits(1, boss_bonus) + # Zurück zur normalen Musik + if main_node.has_method("stop_boss_music"): + main_node.stop_boss_music() + main_node.on_wave_complete() + # Boss Phase-2-Übergang: schwarzes Loch spawnen + Musik verdichtet + if _boss != null and not _boss.is_miniboss and _boss._just_entered_phase2: + _boss._just_entered_phase2 = false + if black_holes.size() < BH_MAX_COUNT: + var phase_bh := BlackHole.new() + phase_bh.init(_boss.x, _boss.y, true) + black_holes.append(phase_bh) + if main_node and main_node.has_method("boss_phase_changed"): + main_node.boss_phase_changed(2) + + comet_timer += dt + var comet_max_interval: float = lerp(float(COMET_SPAWN_MAX), float(COMET_SPAWN_MIN), difficulty) + if comet_timer >= comet_next: + comet_timer = 0.0 + comet_next = randf_range(COMET_SPAWN_MIN, comet_max_interval) + var c := CosmicObjects.Comet.new() + c.init(W, H) + comets.append(c) + antimatter_timer += dt + var am_max_interval: float = lerp(float(ANTIMATTER_SPAWN_MAX), float(ANTIMATTER_SPAWN_MIN), difficulty) + if antimatter_timer >= antimatter_next: + antimatter_timer = 0.0 + antimatter_next = randf_range(ANTIMATTER_SPAWN_MIN, am_max_interval) + _spawn_antimatter_swarm(W * 0.5 + randf_range(-100, 100), H * 0.5 + randf_range(-100, 100)) + SoundManager.play_sfx("antimatter_swarm") + var all_dead: bool = true + for player in players: + var sp: Spaceship = player + if not sp.dead: + all_dead = false + if all_dead and is_playing: + is_playing = false + if main_node: + main_node.on_game_over() + +func _handle_input(dt: float) -> void: + if players.size() > 0: + var p0: Spaceship = players[0] + if not p0.dead: + var thrust: bool = Input.is_action_pressed("p1_thrust") + var turn: int = 0 + if Input.is_action_pressed("p1_left"): turn = -1 + elif Input.is_action_pressed("p1_right"): turn = 1 + p0.update(thrust, turn, W, H, dt) + if p0.has_charge_weapon(): + if Input.is_action_just_pressed("p1_shoot"): + p0.charge_timer = 0.0 + elif Input.is_action_pressed("p1_shoot"): + p0.charge_timer = min(p0.charge_timer + dt, p0.CHARGE_MAX) + elif Input.is_action_just_released("p1_shoot"): + var b: Bullet = p0.shoot_charged(p0.charge_timer / p0.CHARGE_MAX) + if b != null: + bullets.append(b) + SoundManager.play_sfx("player_shoot") + elif Input.is_action_pressed("p1_shoot"): + for b: Bullet in p0.shoot_burst(): + bullets.append(b) + SoundManager.play_sfx("player_shoot") + if Input.is_action_pressed("p1_wipe") and big_wipe and big_wipe.is_active(): + big_wipe.player_press_survive(0) + if Input.is_action_just_pressed("p1_boost"): + if p0.try_boost(): + SoundManager.play_sfx("player_shoot") + if players.size() > 1: + var p1: Spaceship = players[1] + if not p1.dead: + var thrust: bool = Input.is_action_pressed("p2_thrust") + var turn: int = 0 + if Input.is_action_pressed("p2_left"): turn = -1 + elif Input.is_action_pressed("p2_right"): turn = 1 + p1.update(thrust, turn, W, H, dt) + if p1.has_charge_weapon(): + if Input.is_action_just_pressed("p2_shoot"): + p1.charge_timer = 0.0 + elif Input.is_action_pressed("p2_shoot"): + p1.charge_timer = min(p1.charge_timer + dt, p1.CHARGE_MAX) + elif Input.is_action_just_released("p2_shoot"): + var b: Bullet = p1.shoot_charged(p1.charge_timer / p1.CHARGE_MAX) + if b != null: + bullets.append(b) + SoundManager.play_sfx("player_shoot") + elif Input.is_action_pressed("p2_shoot"): + for b: Bullet in p1.shoot_burst(): + bullets.append(b) + SoundManager.play_sfx("player_shoot") + if Input.is_action_pressed("p2_wipe") and big_wipe and big_wipe.is_active(): + big_wipe.player_press_survive(1) + if Input.is_action_just_pressed("p2_boost"): + if p1.try_boost(): + SoundManager.play_sfx("player_shoot") + +func _update_objects(delta: float) -> void: + for star in stars: + var s: CosmicObjects.Star = star + var spawn_swarm: bool = s.update(delta, W, H) + if spawn_swarm: + _spawn_antimatter_swarm(s.x, s.y) + s.dead = true + stars = stars.filter(func(s): return not s.dead) + while stars.size() < STAR_COUNT: + var ns := CosmicObjects.Star.new() + ns.init(randf()*W, H + 2.0, W, H) + stars.append(ns) + for planet in planets: + var p: CosmicObjects.Planet = planet + p.update(delta) + planets = planets.filter(func(p): return not p.dead) + # Slow refill: one planet every 20 s while below baseline, up to hard cap + if planets.size() < PLANET_COUNT: + _planet_spawn_t += delta + if _planet_spawn_t >= 20.0: + _planet_spawn_t = 0.0 + var rp := CosmicObjects.Planet.new() + rp.init(randf_range(W * 0.05, W * 0.95), randf_range(H * 0.05, H * 0.95), W, H) + planets.append(rp) + for neb in nebulae: + var n: CosmicObjects.Nebula = neb + n.update(W, H) + for comet in comets: + var c: CosmicObjects.Comet = comet + c.update(W, H) + comets = comets.filter(func(c): return not c.dead) + for gal in galaxies: + var g: CosmicObjects.Galaxy = gal + g.update(delta, W, H) + # When galaxy finishes being consumed, trigger SMBH + if g.dead and not g.respawning and g.consuming_bh != null: + var cbh: BlackHole = g.consuming_bh + _spawn_spiral_particles(g.x, g.y, cbh.x, cbh.y, 16, Color(1.0, 0.8, 0.3), 3.0) + cbh.trigger_flash(Color(1.0, 0.8, 0.3), 1.0) + g.consuming_bh = null + g.respawning = true + g.respawn_timer = randf_range(5.0, 10.0) + if cbh.consumed >= 12 and not cbh.is_smbh: + cbh.become_smbh() + SoundManager.play_sfx("smbh_spawn") + for qsr in quasars: + var q: CosmicObjects.Quasar = qsr + q.update(delta) + # Radiation push — always active, no thrust required + for player in players: + var p: Spaceship = player + if p.dead: continue + var nv: Vector2 = q.push_object(p.x, p.y, p.vx, p.vy) + p.vx = nv.x; p.vy = nv.y + for en in enemies: + var e: EnemyShip = en + if not e.dead: + var nv: Vector2 = q.push_object(e.x, e.y, e.vx, e.vy) + e.vx = nv.x; e.vy = nv.y + for comet in comets: + var c: CosmicObjects.Comet = comet + if not c.dead: + var nv: Vector2 = q.push_object(c.x, c.y, c.vx, c.vy) + c.vx = nv.x; c.vy = nv.y + # Jet boost — only while thrusting + for player in players: + var p: Spaceship = player + if p.dead or not p.is_thrusting: continue + var nv: Vector2 = q.boost_if_in_jet(p.x, p.y, p.vx, p.vy) + if nv.x != p.vx or nv.y != p.vy: + p.vx = nv.x; p.vy = nv.y + p.boost_active_t = max(p.boost_active_t, 0.25) + if q.boost_sound_cd <= 0.0: + q.boost_sound_cd = 1.2 + SoundManager.play_sfx("quasar_boost") + quasars = quasars.filter(func(q): return not q.dead) + _merge_quasars() + var new_stars_from_wh: Array = [] + var new_planets_from_wh: Array = [] + for whole in white_holes: + var wh: CosmicObjects.WhiteHole = whole + var result: Dictionary = wh.update(delta) + new_stars_from_wh.append_array(result["stars"]) + new_planets_from_wh.append_array(result["planets"]) + for player in players: + var p: Spaceship = player + if not p.dead: + var nv: Vector2 = wh.push_object(p.x, p.y, p.vx, p.vy) + p.vx = nv.x; p.vy = nv.y + for en in enemies: + var e: EnemyShip = en + if not e.dead: + var nv: Vector2 = wh.push_object(e.x, e.y, e.vx, e.vy) + e.vx = nv.x; e.vy = nv.y + for comet in comets: + var c: CosmicObjects.Comet = comet + if not c.dead: + var nv: Vector2 = wh.push_object(c.x, c.y, c.vx, c.vy) + c.vx = nv.x; c.vy = nv.y + for star in stars: + var s: CosmicObjects.Star = star + if s.dead or s.is_spiraling: continue + var nv: Vector2 = wh.push_object(s.x, s.y, s.grav_vx, s.grav_vy) + s.grav_vx = nv.x; s.grav_vy = nv.y + # Only inject up to the hard caps + for ws in new_stars_from_wh: + if stars.size() < STAR_MAX: stars.append(ws) + for wp in new_planets_from_wh: + if planets.size() < PLANET_MAX: planets.append(wp) + white_holes = white_holes.filter(func(wh): return not wh.dead) + for nstar in neutron_stars: + var ns: CosmicObjects.NeutronStar = nstar + ns.update(delta) + for player in players: + var p: Spaceship = player + if not p.dead: + var nv: Vector2 = ns.push_if_in_beam(p.x, p.y, p.vx, p.vy) + p.vx = nv.x; p.vy = nv.y + neutron_stars = neutron_stars.filter(func(ns): return not ns.dead) + for am_obj in antimatter: + var am: CosmicObjects.Antimatter = am_obj + am.update(W, H, delta, antimatter) + antimatter = antimatter.filter(func(am): return not am.dead) + _check_antimatter_clustering() + for as_obj in antimatter_stars: + var astar: CosmicObjects.AntimatterStar = as_obj + astar.update(delta) + for player in players: + var p: Spaceship = player + if not p.dead: + var nv: Vector2 = astar.apply_repulsion(p.x, p.y, p.vx, p.vy) + p.vx = nv.x; p.vy = nv.y + antimatter_stars = antimatter_stars.filter(func(a): return not a.dead) + _apply_gravity_all(delta) + for en in enemies: + var e: EnemyShip = en + if e.dead: + e.respawn_timer -= delta + if e.respawn_timer <= 0: + # Spawn from a random screen edge, away from players + var edge := randi() % 4 + var ex: float; var ey: float + match edge: + 0: ex = randf_range(0.0, W); ey = 0.0 + 1: ex = randf_range(0.0, W); ey = H + 2: ex = 0.0; ey = randf_range(0.0, H) + _: ex = W; ey = randf_range(0.0, H) + e.init(ex, ey, e.enemy_index, e.role_id) + continue + var b: Bullet = e.update(players, black_holes, enemies, W, H, delta) + if b != null: + bullets.append(b) + SoundManager.play_sfx("enemy_shoot") + +func _apply_gravity_all(_delta: float) -> void: + for bhole in black_holes: + var bh: BlackHole = bhole + if bh.dead: continue # skip BHs already killed this tick + bh.update(_delta, players, W, H) + + # ── Stars ────────────────────────────────────────────────────────── + for star in stars: + if bh.dead: break # BH just supernovaed — stop immediately + var s: CosmicObjects.Star = star + var nv: Vector2 = bh.apply_gravity(s.x, s.y, s.grav_vx, s.grav_vy) + s.grav_vx = nv.x * 0.99 + s.grav_vy = nv.y * 0.99 + var gsp := sqrt(s.grav_vx*s.grav_vx + s.grav_vy*s.grav_vy) + if gsp > BlackHole.OBJ_MAX_VEL: + s.grav_vx = s.grav_vx/gsp * BlackHole.OBJ_MAX_VEL + s.grav_vy = s.grav_vy/gsp * BlackHole.OBJ_MAX_VEL + if not s.is_spiraling and bh.check_swallow(s.x, s.y): + s.start_spiral(bh.x, bh.y) + bh.trigger_flash(Color(0.6, 0.7, 1.0), 0.6) + var trigger: bool = bh.on_swallow() + SoundManager.play_sfx("bh_swallow") + if trigger: + _trigger_supernova(bh) + break # BH is dead — do not consume more objects this tick + + if bh.dead: continue + + # ── Planets ──────────────────────────────────────────────────────── + for planet in planets: + if bh.dead: break + var p: CosmicObjects.Planet = planet + if not p.captured: + var dx: float = p.x - bh.x; var dy: float = p.y - bh.y + if sqrt(dx*dx + dy*dy) < bh.pull_radius * 0.6: + p.start_capture(bh.x, bh.y) + if p.captured and bh.check_swallow(p.x, p.y): + _spawn_spiral_particles(p.x, p.y, bh.x, bh.y, int(p.radius * 3.0), p.color, 5.0) + bh.trigger_flash(p.color, 0.8) + p.dead = true + var trigger: bool = bh.on_swallow() + if trigger: + _trigger_supernova(bh) + break + + if bh.dead: continue + + # ── Bullets, Players, Enemies ────────────────────────────────────── + for bul in bullets: + var b: Bullet = bul + if bh.check_swallow(b.x, b.y): + b.dead = true + for player in players: + var p: Spaceship = player + if p.dead or p.is_invulnerable(): continue + var prev_vx := p.vx; var prev_vy := p.vy + var nv: Vector2 = bh.apply_gravity(p.x, p.y, p.vx, p.vy) + if p.stats and p.stats.bh_resist > 0.0: + var resist := 1.0 - p.stats.bh_resist + p.vx = lerp(prev_vx, nv.x, resist) + p.vy = lerp(prev_vy, nv.y, resist) + else: + p.vx = nv.x; p.vy = nv.y + if bh.check_swallow(p.x, p.y): + _kill_player(p) + SoundManager.play_sfx("player_die") + for en in enemies: + var e: EnemyShip = en + if e.dead: continue + var nv: Vector2 = bh.apply_gravity(e.x, e.y, e.vx, e.vy) + e.vx = nv.x; e.vy = nv.y + + # ── Galaxies ─────────────────────────────────────────────────────── + for gal in galaxies: + var g: CosmicObjects.Galaxy = gal + if g.dead or g.respawning or g.being_consumed: continue + var gdx: float = bh.x - g.x; var gdy: float = bh.y - g.y + if sqrt(gdx*gdx + gdy*gdy) < g.radius * 0.55: + g.being_consumed = true + g.consuming_bh = bh + g.consume_bh_x = bh.x + g.consume_bh_y = bh.y + g.consume_initial_radius = g.radius + g.consume_initial_alpha = g.alpha + g.original_color = g.color + for gal in galaxies: + var g: CosmicObjects.Galaxy = gal + if g.being_consumed and g.consuming_bh != null and g.consuming_bh == bh: + g.consume_bh_x = bh.x + g.consume_bh_y = bh.y + + # ── Antimatter ───────────────────────────────────────────────────── + for am_obj in antimatter: + var am: CosmicObjects.Antimatter = am_obj + if am.dead: continue + if bh.check_swallow(am.x, am.y): + am.dead = true + _spawn_spiral_particles(am.x, am.y, bh.x, bh.y, 10, Color(1.0, 0.2, 0.9), 4.0) + bh.trigger_flash(Color(1.0, 0.2, 0.9), 0.5) + # ── Antimatter Stars ─────────────────────────────────────────────── + for as_obj in antimatter_stars: + if bh.dead: break + var astar: CosmicObjects.AntimatterStar = as_obj + if astar.dead: continue + if bh.check_swallow(astar.x, astar.y): + astar.dead = true + _spawn_spiral_particles(astar.x, astar.y, bh.x, bh.y, 20, Color(0.9, 0.1, 1.0), 5.0) + bh.trigger_flash(Color(0.9, 0.1, 1.0), 0.8) + var trigger: bool = bh.on_swallow() + if trigger: + _trigger_supernova(bh) + break + + # ── White holes (one per tick, break after any consumption) ──────── + for whole in white_holes: + if bh.dead: break + var wh: CosmicObjects.WhiteHole = whole + if wh.dead: continue + var wdx: float = wh.x - bh.x; var wdy: float = wh.y - bh.y + if sqrt(wdx*wdx + wdy*wdy) < bh.radius + wh.radius: + wh.dead = true + _spawn_spiral_particles(wh.x, wh.y, bh.x, bh.y, 20, Color(0.6, 0.9, 1.0), 5.0) + bh.trigger_flash(Color(0.6, 0.9, 1.0), 1.0) + var trigger: bool = bh.on_swallow() + SoundManager.play_sfx("bh_swallow") + if trigger: + _trigger_supernova(bh) + break # only one white hole consumed per tick + + # ── Quasars (only eaten if BH is larger) ────────────────────────── + for qsr in quasars: + if bh.dead: break + var q: CosmicObjects.Quasar = qsr + if q.dead or bh.radius <= q.radius: continue + var qdx := q.x - bh.x; var qdy := q.y - bh.y + if sqrt(qdx*qdx + qdy*qdy) < bh.radius + q.radius: + q.dead = true + _spawn_spiral_particles(q.x, q.y, bh.x, bh.y, 25, Color(1.0, 0.9, 0.3), 5.0) + bh.trigger_flash(Color(1.0, 0.9, 0.3), 1.0) + var trigger: bool = bh.on_swallow() + SoundManager.play_sfx("bh_swallow") + if trigger: + _trigger_supernova(bh) + break + + # BH-BH merging — larger eats smaller when they overlap + _merge_black_holes() + +func _merge_quasars() -> void: + var n := quasars.size() + for i in n: + var qa: CosmicObjects.Quasar = quasars[i] + if qa.dead: continue + for j in range(i + 1, n): + var qb: CosmicObjects.Quasar = quasars[j] + if qb.dead: continue + var dx := qa.x - qb.x; var dy := qa.y - qb.y + if sqrt(dx*dx + dy*dy) < qa.radius + qb.radius + 60.0: + var bigger := qa if qa.radius >= qb.radius else qb + var smaller := qb if qa.radius >= qb.radius else qa + bigger.radius = minf(55.0, bigger.radius + smaller.radius * 0.6) + bigger.life = maxf(0.0, bigger.life - 8.0) + _spawn_spiral_particles(smaller.x, smaller.y, bigger.x, bigger.y, + 18, Color(1.0, 0.9, 0.3), 4.0) + smaller.dead = true + +func _merge_black_holes() -> void: + var pending_supernovas: Array = [] + var n := black_holes.size() + for i in n: + var bh1: BlackHole = black_holes[i] + if bh1.dead: continue + for j in range(i + 1, n): + var bh2: BlackHole = black_holes[j] + if bh2.dead: continue + var dx: float = bh1.x - bh2.x + var dy: float = bh1.y - bh2.y + if sqrt(dx*dx + dy*dy) < bh1.radius + bh2.radius: + var bigger: BlackHole = bh1 if bh1.radius >= bh2.radius else bh2 + var smaller: BlackHole = bh2 if bh1.radius >= bh2.radius else bh1 + _spawn_spiral_particles(smaller.x, smaller.y, bigger.x, bigger.y, 20, Color(0.5, 0.1, 1.0), 6.0) + bigger.trigger_flash(Color(0.6, 0.2, 1.0), 1.0) + smaller.dead = true + var trigger: bool = bigger.on_swallow() + if trigger: + pending_supernovas.append(bigger) + else: + SoundManager.play_sfx("bh_swallow") + # Collect dying SMBHs before filtering + var pending_smbh_collapses: Array = [] + for bhole in black_holes: + var bh: BlackHole = bhole + if bh.dead and bh.is_smbh and bh.smbh_dying: + pending_smbh_collapses.append(bh) + # Filter first, then trigger events to avoid mid-loop array modification + black_holes = black_holes.filter(func(b): return not b.dead) + for sn in pending_supernovas: + _trigger_supernova(sn) + for bh in pending_smbh_collapses: + _trigger_smbh_collapse(bh) + +func _trigger_supernova(bh: BlackHole) -> void: + SoundManager.play_sfx("wipe_flash") + _spawn_explosion_particles(bh.x, bh.y, 40, Color(1.0, 0.8, 0.3)) + bh.dead = true + black_holes = black_holes.filter(func(b): return not b.dead) + # Spawn Quasar from the explosion + var q := CosmicObjects.Quasar.new() + q.init(bh.x, bh.y) + quasars.append(q) + # Spawn 1-2 new mobile black holes — ejected outward so they don't re-eat fresh objects + var bh_cap := int(lerp(1.0, float(BH_MAX_COUNT), difficulty)) + var spawn_count := randi_range(1, 2) + for _si in spawn_count: + if black_holes.size() < bh_cap: + var nbh := BlackHole.new() + var bea := randf() * TAU + nbh.init(bh.x + cos(bea) * randf_range(120.0, 220.0), + bh.y + sin(bea) * randf_range(120.0, 220.0), true) + black_holes.append(nbh) + # Also spawn a white hole (capped) or neutron star + if randf() > 0.5 and white_holes.size() < WHITE_HOLE_MAX: + var wh := CosmicObjects.WhiteHole.new() + wh.init(bh.x + randf_range(-60,60), bh.y + randf_range(-60,60)) + white_holes.append(wh) + else: + var ns := CosmicObjects.NeutronStar.new() + ns.init(bh.x, bh.y) + neutron_stars.append(ns) + # Stars ejected radially — they fly outward from the explosion + for _si in randi_range(8, 14): + if stars.size() >= STAR_MAX: break + var ns2 := CosmicObjects.Star.new() + var ea := randf() * TAU + var ed := randf_range(25.0, 150.0) + ns2.x = bh.x + cos(ea) * ed + ns2.y = bh.y + sin(ea) * ed + ns2.speed = randf_range(0.15, 0.7) + ns2.size = randf_range(1.5, 4.5) + ns2.alpha = 0.7 + randf() * 0.3 + ns2.glow = ns2.size >= 3.0 + ns2.color = Color(randf_range(0.85, 1.0), randf_range(0.55, 0.95), randf_range(0.2, 0.6)) + ns2.grav_vx = cos(ea) * randf_range(0.5, 2.0) + ns2.grav_vy = sin(ea) * randf_range(0.5, 2.0) + ns2.twinkle_phase = randf() * TAU + ns2.twinkle_speed = randf_range(0.5, 2.0) + stars.append(ns2) + for _pi in randi_range(3, 5): + if planets.size() >= PLANET_MAX: break + var np := CosmicObjects.Planet.new() + var ea := randf() * TAU + np.init(bh.x + cos(ea) * randf_range(60.0, 200.0), + bh.y + sin(ea) * randf_range(60.0, 200.0), W, H) + planets.append(np) + # Antimatter burst only during actual gameplay (not menu background) + if not menu_mode: + _spawn_antimatter_swarm(bh.x, bh.y) + +func _trigger_smbh_collapse(bh: BlackHole) -> void: + SoundManager.play_sfx("wipe_flash") + _spawn_explosion_particles(bh.x, bh.y, 60, Color(1.0, 0.65, 0.0)) + var bh_cap := int(lerp(1.0, float(BH_MAX_COUNT), difficulty)) + # Large birth burst — stars ejected radially in all directions + for _i in randi_range(12, 18): + if stars.size() >= STAR_MAX: break + var s := CosmicObjects.Star.new() + var ea := randf() * TAU + var ed := randf_range(40.0, 260.0) + s.x = bh.x + cos(ea) * ed + s.y = bh.y + sin(ea) * ed + s.speed = randf_range(0.2, 0.8) + s.size = randf_range(2.0, 5.5) + s.alpha = 0.8 + randf() * 0.2 + s.glow = true + s.color = Color(randf_range(0.9, 1.0), randf_range(0.6, 0.9), randf_range(0.2, 0.5)) + s.twinkle_phase = randf() * TAU + s.twinkle_speed = randf_range(0.5, 2.0) + s.grav_vx = cos(ea) * randf_range(1.0, 3.5) + s.grav_vy = sin(ea) * randf_range(1.0, 3.5) + stars.append(s) + # Planets + for _i in randi_range(5, 8): + if planets.size() >= PLANET_MAX: break + var p := CosmicObjects.Planet.new() + var ea := randf() * TAU + p.init(bh.x + cos(ea) * randf_range(80.0, 300.0), + bh.y + sin(ea) * randf_range(80.0, 300.0), W, H) + planets.append(p) + # New smaller BHs — scattered wide so they don't cluster on fresh objects + for _i in 3: + if black_holes.size() >= bh_cap: break + var nb := BlackHole.new() + var ea := randf() * TAU + nb.init(bh.x + cos(ea) * randf_range(160.0, 300.0), + bh.y + sin(ea) * randf_range(160.0, 300.0), true) + black_holes.append(nb) + # White hole at the collapse site + if white_holes.size() < WHITE_HOLE_MAX: + var wh := CosmicObjects.WhiteHole.new() + wh.init(bh.x, bh.y) + white_holes.append(wh) + +func _check_antimatter_clustering() -> void: + const CLUSTER_RADIUS := 30.0 + const CLUSTER_MIN := 5 + var used := {} + var to_remove: Array = [] + for i in antimatter.size(): + if i in used: continue + var a: CosmicObjects.Antimatter = antimatter[i] + var group: Array = [a] + for j in range(i + 1, antimatter.size()): + if j in used: continue + var b: CosmicObjects.Antimatter = antimatter[j] + var dx := b.x - a.x; var dy := b.y - a.y + if sqrt(dx*dx + dy*dy) < CLUSTER_RADIUS: + group.append(b) + used[j] = true + if group.size() >= CLUSTER_MIN: + used[i] = true + var cx := 0.0; var cy := 0.0 + for m in group: + cx += float(m.x); cy += float(m.y) + cx /= group.size(); cy /= group.size() + var astar := CosmicObjects.AntimatterStar.new() + astar.init(cx, cy) + antimatter_stars.append(astar) + for m in group: + to_remove.append(m) + for m in to_remove: + antimatter.erase(m) + +func _update_bullets() -> void: + for bul in bullets: + var b: Bullet = bul + b.update(W, H) + bullets = bullets.filter(func(b): return not b.dead) + +func _update_particles(delta: float) -> void: + for pt in particles: + pt["prev_x"] = pt["x"] + pt["prev_y"] = pt["y"] + if pt.has("target_x"): + var pdx: float = float(pt["target_x"]) - float(pt["x"]) + var pdy: float = float(pt["target_y"]) - float(pt["y"]) + var pdist: float = sqrt(pdx * pdx + pdy * pdy) + if pdist > 1.0: + var grav: float = float(pt.get("gravity", 3.0)) + var force: float = grav / maxf(pdist * 0.3, 1.0) + pt["vx"] = float(pt["vx"]) + (pdx / pdist) * force * delta * 60.0 + pt["vy"] = float(pt["vy"]) + (pdy / pdist) * force * delta * 60.0 + pt["x"] = float(pt["x"]) + float(pt["vx"]) + pt["y"] = float(pt["y"]) + float(pt["vy"]) + pt["life"] = float(pt["life"]) - delta + particles = particles.filter(func(p): return float(p["life"]) > 0.0) + +func _check_collisions() -> void: + for bul in bullets: + var b: Bullet = bul + if b.dead: continue + if b.owner_type == "p1" or b.owner_type == "p2": + for en in enemies: + var e: EnemyShip = en + if e.dead or e.invuln_timer > 0: continue + var dx: float = b.x - e.x + var dy: float = b.y - e.y + var hit_r := b.effective_hit_radius + if dx*dx + dy*dy < hit_r * hit_r: + # Pierce: bullet survives if it hasn't hit too many targets + if b.pierce: + b.pierce_hits += 1 + if b.pierce_hits >= 2: b.dead = true + else: + b.dead = true + if e.current_shields > 0: + e.current_shields -= 1 + e.invuln_timer = 60 + _spawn_explosion_particles(e.x, e.y, 8, Color(0.3, 0.6, 1.0)) + continue + e.dead = true + e.respawn_timer = randf_range(EnemyShip.RESPAWN_MIN, EnemyShip.RESPAWN_MAX) + _spawn_explosion_particles(e.x, e.y, 16, Color(1,0.4,0.2)) + SoundManager.play_sfx("enemy_die") + for player in players: + var p: Spaceship = player + if b.owner_type == ("p1" if p.player_index == 0 else "p2"): + p.kills += 1 + # Award kill credits + if main_node and not menu_mode: + var bonus: float = p.stats.credit_bonus if p.stats else 1.0 + main_node.add_credits(p.player_index, int(15.0 * bonus)) + for bul in bullets: + var b: Bullet = bul + if b.dead: continue + if b.owner_type == "enemy" or b.owner_type == "boss": + for player in players: + var p: Spaceship = player + if p.dead or p.is_invulnerable(): continue + var dx: float = b.x - p.x + var dy: float = b.y - p.y + if dx*dx + dy*dy < Bullet.HIT_RADIUS * Bullet.HIT_RADIUS: + b.dead = true + _kill_player(p) + SoundManager.play_sfx("player_die") + for am_obj in antimatter: + var am: CosmicObjects.Antimatter = am_obj + if am.dead: continue + for player in players: + var p: Spaceship = player + if p.dead or p.is_invulnerable(): continue + var dx: float = am.x - p.x + var dy: float = am.y - p.y + if dx*dx + dy*dy < 100.0: + am.dead = true + _kill_player(p) + SoundManager.play_sfx("antimatter_hit") + for am_obj in antimatter: + var am: CosmicObjects.Antimatter = am_obj + if am.dead: continue + for en in enemies: + var e: EnemyShip = en + if e.dead or e.invuln_timer > 0: continue + var dx: float = am.x - e.x + var dy: float = am.y - e.y + if dx*dx + dy*dy < 100.0: + am.dead = true + if e.current_shields > 0: + e.current_shields -= 1 + e.invuln_timer = 60 + _spawn_explosion_particles(e.x, e.y, 8, Color(0.3, 0.6, 1.0)) + else: + e.dead = true + e.respawn_timer = randf_range(EnemyShip.RESPAWN_MIN, EnemyShip.RESPAWN_MAX) + _spawn_explosion_particles(e.x, e.y, 12, Color(1,0.2,0.8)) + SoundManager.play_sfx("antimatter_hit") + # ── INFERNO ram damage ────────────────────────────────────────────────────── + const INFERNO_RAM_SPEED := 4.5 + const INFERNO_RAM_RADIUS := 10.0 + for player in players: + var p: Spaceship = player + if p.dead or p.is_invulnerable(): continue + if p.stats == null or p.stats.ship_id != "inferno": continue + var spd := sqrt(p.vx*p.vx + p.vy*p.vy) + if spd < INFERNO_RAM_SPEED: continue + for en in enemies: + var e: EnemyShip = en + if e.dead or e.invuln_timer > 0: continue + var dx := p.x - e.x; var dy := p.y - e.y + if dx*dx + dy*dy < INFERNO_RAM_RADIUS * INFERNO_RAM_RADIUS: + p.invuln_timer = Spaceship.INVULN_FRAMES + _spawn_explosion_particles(e.x, e.y, 16, Color(1.0, 0.35, 0.1)) + SoundManager.play_sfx("enemy_die") + if e.current_shields > 0: + e.current_shields -= 1 + e.invuln_timer = 60 + _spawn_explosion_particles(e.x, e.y, 8, Color(0.3, 0.6, 1.0)) + else: + e.dead = true + e.respawn_timer = randf_range(EnemyShip.RESPAWN_MIN, EnemyShip.RESPAWN_MAX) + p.kills += 1 + if main_node and not menu_mode: + main_node.add_credits(p.player_index, int(15.0 * (p.stats.credit_bonus if p.stats else 1.0))) + break + + # Spieler-Bullets treffen Boss + if _boss != null and not _boss.dead: + for bul in bullets: + var b: Bullet = bul + if b.dead: continue + if b.owner_type != "p1" and b.owner_type != "p2": continue + var dx: float = b.x - _boss.x + var dy: float = b.y - _boss.y + var hr: float = b.effective_hit_radius * 2.5 # Boss ist ein großes Ziel + if dx * dx + dy * dy < hr * hr: + b.dead = true + _boss.take_hit() + _spawn_explosion_particles(_boss.x, _boss.y, 6, Color(1.0, 0.45, 0.1)) + +func _kill_player(p: Spaceship) -> void: + if p.dead: return + # Shield absorbs the hit + if p.current_shields > 0: + p.current_shields -= 1 + var invuln_frames := int(float(Spaceship.INVULN_FRAMES) * (p.stats.invuln_mult if p.stats else 1.0)) + p.invuln_timer = invuln_frames + _spawn_explosion_particles(p.x, p.y, 12, Color(0.3, 0.6, 1.0)) + return + p.dead = true + _spawn_explosion_particles(p.x, p.y, 30, Color(1, 0.6, 0.2)) + +func _check_big_wipe() -> void: + if not big_wipe: return # no wipe in menu mode + if big_wipe.is_active(): return + if _total_object_count() > OBJECT_WIPE_THRESHOLD: + big_wipe.start() + SoundManager.play_sfx("wipe_start") + +func _on_wipe_complete(p1_ok: bool, p2_ok: bool) -> void: + if p1_ok: + wipe_count_p1 += 1 + if players.size() > 0: + var p: Spaceship = players[0] + p.invuln_timer = Spaceship.INVULN_FRAMES * 2 # 3s post-wipe grace + if main_node and not menu_mode: + var bonus: float = p.stats.credit_bonus if p.stats else 1.0 + main_node.add_credits(0, int(25.0 * bonus)) + else: + if players.size() > 0: + _kill_player(players[0]) + if is_multiplayer: + if p2_ok: + wipe_count_p2 += 1 + if players.size() > 1: + var p: Spaceship = players[1] + p.invuln_timer = Spaceship.INVULN_FRAMES * 2 + else: + if players.size() > 1: + _kill_player(players[1]) + SoundManager.play_sfx("wipe_survived" if (p1_ok or p2_ok) else "wipe_flash") + _clear_universe_for_rebirth() + +func _clear_universe_for_rebirth() -> void: + stars.clear(); comets.clear(); antimatter.clear() + quasars.clear(); white_holes.clear(); neutron_stars.clear() + antimatter_stars.clear() + black_holes = black_holes.slice(0, 1) + _spawn_universe_partial() + +func _spawn_universe_partial() -> void: + while stars.size() < STAR_COUNT: + var s := CosmicObjects.Star.new() + s.init(randf()*W, randf()*H, W, H) + stars.append(s) + while planets.size() < PLANET_COUNT: + var p := CosmicObjects.Planet.new() + p.init(randf()*W, randf()*H, W, H) + planets.append(p) + +func _spawn_antimatter_swarm(cx: float, cy: float) -> void: + var count: int = randi_range(6, 12) + for _i in count: + var a: float = randf() * TAU + var spd: float = randf_range(1.0, 3.0) + var am := CosmicObjects.Antimatter.new() + am.init(cx + randf_range(-10,10), cy + randf_range(-10,10), a, spd) + antimatter.append(am) + + +func _spawn_explosion_particles(cx: float, cy: float, count: int, col: Color) -> void: + for _i in count: + var a: float = randf() * TAU + var spd: float = randf_range(1.0, 6.0) + particles.append({"x": cx, "y": cy, + "vx": cos(a)*spd, "vy": sin(a)*spd, + "life": randf_range(0.3, 0.9), "max_life": 0.9, "color": col}) + +func _spawn_spiral_particles(cx: float, cy: float, bh_x: float, bh_y: float, + count: int, col: Color, grav: float = 4.0) -> void: + var dx: float = bh_x - cx; var dy: float = bh_y - cy + var dist: float = sqrt(dx * dx + dy * dy) + var nx: float = dx / maxf(dist, 1.0) + var ny: float = dy / maxf(dist, 1.0) + for _i in count: + var tangent_dir: float = 1.0 if randf() > 0.5 else -1.0 + var tx: float = -ny * tangent_dir + var ty: float = nx * tangent_dir + var spd: float = randf_range(1.5, 4.0) + var radial_spd: float = randf_range(0.3, 1.5) + particles.append({ + "x": cx + randf_range(-3.0, 3.0), "y": cy + randf_range(-3.0, 3.0), + "vx": tx * spd + nx * radial_spd, "vy": ty * spd + ny * radial_spd, + "life": randf_range(0.4, 1.2), "max_life": 1.2, + "color": col, "target_x": bh_x, "target_y": bh_y, + "gravity": grav, "stretch": true, "size": randf_range(1.0, 3.0)}) + +func _draw() -> void: + draw_rect(get_viewport_rect(), Color("#0a0a14")) + if Settings.nebula_enabled: + for neb in nebulae: + var n: CosmicObjects.Nebula = neb + n.draw(self) + for star in stars: + var s: CosmicObjects.Star = star + s.draw(self) + for gal in galaxies: + var g: CosmicObjects.Galaxy = gal + g.draw(self) + for planet in planets: + var p: CosmicObjects.Planet = planet + p.draw(self) + for comet in comets: + var c: CosmicObjects.Comet = comet + c.draw(self) + for qsr in quasars: + var q: CosmicObjects.Quasar = qsr + q.draw(self) + for whole in white_holes: + var wh: CosmicObjects.WhiteHole = whole + wh.draw(self) + for nstar in neutron_stars: + var ns: CosmicObjects.NeutronStar = nstar + ns.draw(self) + for bhole in black_holes: + var bh: BlackHole = bhole + bh.draw(self) + for am_obj in antimatter: + var am: CosmicObjects.Antimatter = am_obj + am.draw(self) + for as_obj in antimatter_stars: + var astar: CosmicObjects.AntimatterStar = as_obj + astar.draw(self) + for bul in bullets: + var b: Bullet = bul + b.draw(self) + for en in enemies: + var e: EnemyShip = en + e.draw(self) + # Boss nach Enemies, vor Spielern zeichnen (nur im aktiven Spiel) + if _boss != null and not _boss.dead and is_playing: + _boss.draw(self) + _draw_boss_hp_bar() + for player in players: + var p: Spaceship = player + p.draw(self, frame) + for pt in particles: + var a: float = float(pt["life"]) / float(pt["max_life"]) + var c: Color = pt["color"] + var sz: float = float(pt.get("size", 4.0)) + if pt.get("stretch", false) and pt.has("prev_x"): + var col := Color(c.r, c.g, c.b, a) + draw_line(Vector2(float(pt["prev_x"]), float(pt["prev_y"])), + Vector2(float(pt["x"]), float(pt["y"])), + col, maxf(1.0, sz * 0.5 * a)) + else: + var hs: float = sz * 0.5 + draw_rect(Rect2(float(pt["x"]) - hs, float(pt["y"]) - hs, sz, sz), + Color(c.r, c.g, c.b, a)) + if big_wipe and big_wipe.is_active(): + var t: float = float(frame) / 60.0 + big_wipe.draw_overlay(self, W, H, t) + +func _draw_boss_hp_bar() -> void: + if _boss == null or _boss.dead: + return + var bw := 300.0; var bh := 14.0 + var bx := (W - bw) * 0.5; var by := H - 52.0 + var name_str: String = "▸ WRAITH" if _boss.is_miniboss else "▸ LEVIATHAN" + var phase_str: String = "" if _boss.is_miniboss else (" [ PHASE %d ]" % _boss.phase) + + # Schatten-Box + draw_rect(Rect2(bx - 3.0, by - 14.0, bw + 6.0, bh + 20.0), Color(0.0, 0.0, 0.0, 0.72)) + # Leere Bar + draw_rect(Rect2(bx, by, bw, bh), Color(0.08, 0.0, 0.04, 0.95)) + # HP-Füllung + var fill: float = (float(_boss.hp) / float(_boss.max_hp)) * bw + var hp_col: Color + if _boss.is_miniboss: + hp_col = Color(0.92, 0.08, 0.88, 1.0) # Magenta + elif _boss.phase == 2: + hp_col = Color(1.0, 0.12, 0.0, 1.0) # Feuerrot (Phase 2) + else: + hp_col = Color(1.0, 0.52, 0.0, 1.0) # Orange (Phase 1) + draw_rect(Rect2(bx, by, fill, bh), hp_col) + # Rahmen + draw_rect(Rect2(bx, by, bw, bh), Color(hp_col.r, hp_col.g, hp_col.b, 0.55), false, 1.5) + # Name + HP-Zahl + var font := ThemeDB.fallback_font + var label: String = name_str + phase_str + " %d / %d" % [_boss.hp, _boss.max_hp] + var tw: float = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x + draw_string(font, Vector2((W - tw) * 0.5, by - 2.0), label, + HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color(1.0, 0.92, 0.92, 0.92)) diff --git a/scripts/game_world.gd.uid b/scripts/game_world.gd.uid new file mode 100644 index 0000000..2e72b92 --- /dev/null +++ b/scripts/game_world.gd.uid @@ -0,0 +1 @@ +uid://dgjwy8o1rgfd7 diff --git a/scripts/hud.gd b/scripts/hud.gd new file mode 100644 index 0000000..23b6f0b --- /dev/null +++ b/scripts/hud.gd @@ -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 diff --git a/scripts/hud.gd.uid b/scripts/hud.gd.uid new file mode 100644 index 0000000..ebec699 --- /dev/null +++ b/scripts/hud.gd.uid @@ -0,0 +1 @@ +uid://kn3e6fiywxr7 diff --git a/scripts/hud_draw.gd b/scripts/hud_draw.gd new file mode 100644 index 0000000..822e263 --- /dev/null +++ b/scripts/hud_draw.gd @@ -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) diff --git a/scripts/hud_draw.gd.uid b/scripts/hud_draw.gd.uid new file mode 100644 index 0000000..3e1bdef --- /dev/null +++ b/scripts/hud_draw.gd.uid @@ -0,0 +1 @@ +uid://dhqnupvfv7tnt diff --git a/scripts/item_db.gd b/scripts/item_db.gd new file mode 100644 index 0000000..e18e3f0 --- /dev/null +++ b/scripts/item_db.gd @@ -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 {} diff --git a/scripts/item_db.gd.uid b/scripts/item_db.gd.uid new file mode 100644 index 0000000..0897bb9 --- /dev/null +++ b/scripts/item_db.gd.uid @@ -0,0 +1 @@ +uid://sfb1ybt3r8ne diff --git a/scripts/item_def.gd b/scripts/item_def.gd new file mode 100644 index 0000000..9698002 --- /dev/null +++ b/scripts/item_def.gd @@ -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 diff --git a/scripts/item_def.gd.uid b/scripts/item_def.gd.uid new file mode 100644 index 0000000..4e868f3 --- /dev/null +++ b/scripts/item_def.gd.uid @@ -0,0 +1 @@ +uid://cxa4d1ome3iap diff --git a/scripts/leaderboard.gd b/scripts/leaderboard.gd new file mode 100644 index 0000000..987ec88 --- /dev/null +++ b/scripts/leaderboard.gd @@ -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 diff --git a/scripts/leaderboard.gd.uid b/scripts/leaderboard.gd.uid new file mode 100644 index 0000000..756f85e --- /dev/null +++ b/scripts/leaderboard.gd.uid @@ -0,0 +1 @@ +uid://cs0jfxgljs4rl diff --git a/scripts/main.gd b/scripts/main.gd new file mode 100644 index 0000000..104744f --- /dev/null +++ b/scripts/main.gd @@ -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) diff --git a/scripts/main.gd.uid b/scripts/main.gd.uid new file mode 100644 index 0000000..238d7fe --- /dev/null +++ b/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://ctbcvqj0aoo3v diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd new file mode 100644 index 0000000..ba42589 --- /dev/null +++ b/scripts/main_menu.gd @@ -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 diff --git a/scripts/main_menu.gd.uid b/scripts/main_menu.gd.uid new file mode 100644 index 0000000..ac5dc82 --- /dev/null +++ b/scripts/main_menu.gd.uid @@ -0,0 +1 @@ +uid://cp36y6rjo8aqs diff --git a/scripts/music_player.gd b/scripts/music_player.gd new file mode 100644 index 0000000..5a39adb --- /dev/null +++ b/scripts/music_player.gd @@ -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 diff --git a/scripts/music_player.gd.uid b/scripts/music_player.gd.uid new file mode 100644 index 0000000..13fd6fe --- /dev/null +++ b/scripts/music_player.gd.uid @@ -0,0 +1 @@ +uid://dxun4apx8cxf1 diff --git a/scripts/online_leaderboard.gd b/scripts/online_leaderboard.gd new file mode 100644 index 0000000..69de6b0 --- /dev/null +++ b/scripts/online_leaderboard.gd @@ -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 = "" diff --git a/scripts/online_leaderboard.gd.uid b/scripts/online_leaderboard.gd.uid new file mode 100644 index 0000000..378378e --- /dev/null +++ b/scripts/online_leaderboard.gd.uid @@ -0,0 +1 @@ +uid://bs401f5368qos diff --git a/scripts/pause_menu.gd b/scripts/pause_menu.gd new file mode 100644 index 0000000..7b5b314 --- /dev/null +++ b/scripts/pause_menu.gd @@ -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 diff --git a/scripts/pause_menu.gd.uid b/scripts/pause_menu.gd.uid new file mode 100644 index 0000000..e14f36b --- /dev/null +++ b/scripts/pause_menu.gd.uid @@ -0,0 +1 @@ +uid://lmuic5md3b5p diff --git a/scripts/planet.gd b/scripts/planet.gd new file mode 100644 index 0000000..801ae70 --- /dev/null +++ b/scripts/planet.gd @@ -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) diff --git a/scripts/planet.gd.uid b/scripts/planet.gd.uid new file mode 100644 index 0000000..71a1512 --- /dev/null +++ b/scripts/planet.gd.uid @@ -0,0 +1 @@ +uid://b78slt2taoxyc diff --git a/scripts/settings.gd b/scripts/settings.gd new file mode 100644 index 0000000..0acbfc2 --- /dev/null +++ b/scripts/settings.gd @@ -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) diff --git a/scripts/settings.gd.uid b/scripts/settings.gd.uid new file mode 100644 index 0000000..8a830fa --- /dev/null +++ b/scripts/settings.gd.uid @@ -0,0 +1 @@ +uid://c1yb21fhib2c1 diff --git a/scripts/ship_select.gd b/scripts/ship_select.gd new file mode 100644 index 0000000..57ee724 --- /dev/null +++ b/scripts/ship_select.gd @@ -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) diff --git a/scripts/ship_select.gd.uid b/scripts/ship_select.gd.uid new file mode 100644 index 0000000..b6c18b4 --- /dev/null +++ b/scripts/ship_select.gd.uid @@ -0,0 +1 @@ +uid://bc1kys1vqyll6 diff --git a/scripts/ship_stats.gd b/scripts/ship_stats.gd new file mode 100644 index 0000000..a0a3b28 --- /dev/null +++ b/scripts/ship_stats.gd @@ -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) diff --git a/scripts/ship_stats.gd.uid b/scripts/ship_stats.gd.uid new file mode 100644 index 0000000..e36ebec --- /dev/null +++ b/scripts/ship_stats.gd.uid @@ -0,0 +1 @@ +uid://c1b87vp4lr17r diff --git a/scripts/shop_ui.gd b/scripts/shop_ui.gd new file mode 100644 index 0000000..115e3ae --- /dev/null +++ b/scripts/shop_ui.gd @@ -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) diff --git a/scripts/shop_ui.gd.uid b/scripts/shop_ui.gd.uid new file mode 100644 index 0000000..10861c6 --- /dev/null +++ b/scripts/shop_ui.gd.uid @@ -0,0 +1 @@ +uid://18s6j8gme2ne diff --git a/scripts/sound_manager.gd b/scripts/sound_manager.gd new file mode 100644 index 0000000..caac644 --- /dev/null +++ b/scripts/sound_manager.gd @@ -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)) diff --git a/scripts/sound_manager.gd.uid b/scripts/sound_manager.gd.uid new file mode 100644 index 0000000..e187267 --- /dev/null +++ b/scripts/sound_manager.gd.uid @@ -0,0 +1 @@ +uid://bivpkig7j7mor diff --git a/scripts/spaceship.gd b/scripts/spaceship.gd new file mode 100644 index 0000000..4499102 --- /dev/null +++ b/scripts/spaceship.gd @@ -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) diff --git a/scripts/spaceship.gd.uid b/scripts/spaceship.gd.uid new file mode 100644 index 0000000..88b39f1 --- /dev/null +++ b/scripts/spaceship.gd.uid @@ -0,0 +1 @@ +uid://57llmkpuptgb diff --git a/scripts/touch_controls.gd b/scripts/touch_controls.gd new file mode 100644 index 0000000..2274c63 --- /dev/null +++ b/scripts/touch_controls.gd @@ -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) diff --git a/scripts/touch_controls.gd.uid b/scripts/touch_controls.gd.uid new file mode 100644 index 0000000..eb75fe1 --- /dev/null +++ b/scripts/touch_controls.gd.uid @@ -0,0 +1 @@ +uid://cn7sutxtu7qym diff --git a/scripts/tr.gd b/scripts/tr.gd new file mode 100644 index 0000000..8179eef --- /dev/null +++ b/scripts/tr.gd @@ -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) diff --git a/scripts/tr.gd.uid b/scripts/tr.gd.uid new file mode 100644 index 0000000..45553e3 --- /dev/null +++ b/scripts/tr.gd.uid @@ -0,0 +1 @@ +uid://ddebuue2ry42v diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..7a82c56 --- /dev/null +++ b/server/package.json @@ -0,0 +1,14 @@ +{ + "name": "spacel-leaderboard", + "version": "1.0.0", + "description": "spacel online leaderboard server", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "better-sqlite3": "^11.0.0", + "express": "^4.19.0", + "express-rate-limit": "^7.3.0" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..78a0550 --- /dev/null +++ b/server/server.js @@ -0,0 +1,100 @@ +// spacel leaderboard server +// Setup: cd server && npm install && node server.js +// Config: set env var SPACEL_SECRET to the same value as HMAC_SECRET in online_leaderboard.gd +// set env var PORT to override default 3000 + +const express = require('express'); +const Database = require('better-sqlite3'); +const crypto = require('crypto'); +const rateLimit = require('express-rate-limit'); + +const app = express(); +const db = new Database(process.env.DB_PATH || 'scores.db'); +const SECRET = process.env.SPACEL_SECRET || 'CHANGE-ME-BEFORE-DEPLOY'; +const PORT = parseInt(process.env.PORT || '3000', 10); + +db.exec(` + CREATE TABLE IF NOT EXISTS scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + score INTEGER NOT NULL CHECK(score >= 0 AND score <= 500000), + wave INTEGER NOT NULL CHECK(wave >= 1 AND wave <= 100), + created_at TEXT NOT NULL + ) +`); + +app.use(express.json({ limit: '4kb' })); + +const submitLimit = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 1, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Rate limit: 1 submission per 10 minutes per IP.' } +}); + +// GET /scores — top 10 by score +app.get('/scores', (_req, res) => { + const rows = db + .prepare('SELECT name, score, wave, created_at FROM scores ORDER BY score DESC LIMIT 10') + .all(); + res.json(rows); +}); + +// POST /scores — submit a new score +// Body: { name, score, wave, timestamp, hmac } +// HMAC = SHA256(SECRET, name + score + wave + timestamp) +app.post('/scores', submitLimit, (req, res) => { + const { name, score, wave, timestamp, hmac } = req.body ?? {}; + + if (name == null || score == null || wave == null || timestamp == null || hmac == null) + return res.status(400).json({ error: 'Missing fields' }); + + if (typeof name !== 'string' || + !Number.isInteger(score) || + !Number.isInteger(wave) || + !Number.isInteger(timestamp) || + typeof hmac !== 'string') + return res.status(400).json({ error: 'Invalid field types' }); + + // Reject stale timestamps (±5 minutes) + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > 300) + return res.status(400).json({ error: 'Timestamp expired' }); + + if (score < 0 || score > 500000) + return res.status(400).json({ error: 'Score out of range' }); + if (wave < 1 || wave > 100) + return res.status(400).json({ error: 'Wave out of range' }); + + const cleanName = name.trim().slice(0, 12); + if (cleanName.length === 0) + return res.status(400).json({ error: 'Name required' }); + + // Verify HMAC — must match what online_leaderboard.gd sends + const raw = name + String(score) + String(wave) + String(timestamp); + const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex'); + let valid = false; + try { + // timingSafeEqual requires equal-length buffers; mismatch length → invalid + if (hmac.length === expected.length) { + valid = crypto.timingSafeEqual( + Buffer.from(hmac, 'hex'), + Buffer.from(expected, 'hex') + ); + } + } catch { /* invalid hex */ } + + if (!valid) + return res.status(403).json({ error: 'Invalid signature' }); + + db.prepare( + 'INSERT INTO scores (name, score, wave, created_at) VALUES (?, ?, ?, ?)' + ).run(cleanName, score, wave, new Date().toISOString().split('T')[0]); + + res.json({ ok: true }); +}); + +app.listen(PORT, () => + console.log(`spacel leaderboard running on port ${PORT}`) +);