Initial commit — Godot space roguelite source

- Touch controls: direct InputEventScreenTouch in shop_ui (bypass relay)
- ItemDB: static preload list instead of DirAccess scan (export fix)
- All 18 items with EN localisation (name_en, desc_en, category_en)
- Ship playstyles: NOVA-1 shield, INFERNO ram, AURORA agile/tank
- Quasar: SMBH visual, jet boost, merge, push, BH-eating
- Atlas & UI text updated EN+DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 14:38:09 +02:00
commit edc40f9008
108 changed files with 10068 additions and 0 deletions
+22
View File
@@ -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"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8
+2
View File
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+5
View File
@@ -0,0 +1,5 @@
# Godot 4+ specific ignores
.godot/
/android/
addons/
build/
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": [
"C:/_gamedev/godot_mcp_server/build/index.js"
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"godotTools.editorPath.godot4": "c:\\Program Files\\Godot\\Godot_v4.6-stable_win64.exe"
}
+431
View File
@@ -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 12 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 1N 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 48s 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 | 00.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 19 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.081.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
+146
View File
@@ -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.
+344
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 995 B

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

Some files were not shown because too many files have changed in this diff Show More