Compare commits

...
Author SHA1 Message Date
Emporex e63e08ddde Prepare Void Bastion alpha 7 release 2026-10-06 23:59:12 +02:00
Emporex 56077e88a5 Release Void Bastion alpha.5 2026-10-06 18:34:49 +02:00
29 changed files with 1244 additions and 92 deletions

No files matched your search

+52 -2
View File
@@ -1,6 +1,6 @@
# Void Bastion – Alpha-Testcheckliste # Void Bastion – Alpha-Testcheckliste
Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet. Automatische Prüfungen sind mit **A**, manuelle Sicht- und Bedienprüfungen mit **M** gekennzeichnet. Diese Liste wird fortlaufend erweitert und vor jeder öffentlichen Version abgearbeitet. Automatische Prüfungen sind mit **A**, manuelle Sicht- und Bedienprüfungen mit **M** gekennzeichnet. Neue Wünsche werden hier ergänzt; Punkte werden erst nach erfolgreicher Prüfung abgehakt.
## Build und Installation ## Build und Installation
@@ -15,7 +15,7 @@ Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet
## Darstellung und Bedienung ## Darstellung und Bedienung
- [ ] **M** 1920 × 1080, 2560 × 1440 und eine kleinere 16:9-Auflösung prüfen: Karte und UI bleiben scharf und vollständig sichtbar. - [ ] **M** 1920 × 1080, 2560 × 1440 und eine kleinere 16:9-Auflösung prüfen: Karte und UI bleiben scharf und vollständig sichtbar.
- [ ] **M** Alle drei Karten prüfen: Straße, Gegnerlaufweg, Raster und Bausperren stimmen exakt überein. - [ ] **M** Alle sieben Karten prüfen: Straße, Gegnerlaufweg, Raster und Bausperren stimmen exakt überein.
- [ ] **M** Reaktoren und freie Weltraumbereiche sind auf allen Karten unbebaubar. - [ ] **M** Reaktoren und freie Weltraumbereiche sind auf allen Karten unbebaubar.
- [ ] **M** Reichweitenvorschau erscheint vor dem Bau; Turmwerte und Upgrade-Mehrwert sind lesbar. - [ ] **M** Reichweitenvorschau erscheint vor dem Bau; Turmwerte und Upgrade-Mehrwert sind lesbar.
- [ ] **M** Zurück-Schaltfläche bricht eine laufende Runde verständlich ab. - [ ] **M** Zurück-Schaltfläche bricht eine laufende Runde verständlich ab.
@@ -44,3 +44,53 @@ Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet
- [ ] **M** Download-Schaltfläche auf `https://void-bastion.gergames.de/` lädt die aktuelle Setup-Datei. - [ ] **M** Download-Schaltfläche auf `https://void-bastion.gergames.de/` lädt die aktuelle Setup-Datei.
- [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar. - [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar.
- [ ] **M** Änderungen, bekannte Fehler und Testhinweise stehen in den Release-Notizen. - [ ] **M** Änderungen, bekannte Fehler und Testhinweise stehen in den Release-Notizen.
## Neue offene Punkte (Stand 06.10.2026)
- [ ] **M** Android-APK auf einem echten Android-Handy installieren und Start, Touch-Steuerung, verschiedene Bildschirmformate, App-Wechsel/Fortsetzen und Performance prüfen. Bisheriger erfolgreicher Start war nur im Emulator.
- [ ] **A/M** Android-Audiofehler beheben: Laufzeit-generierte Audioeffekte sind auf Android derzeit deaktiviert; importierte Audioassets einsetzen und auf echtem Gerät prüfen.
- [ ] **A/M** Android-AdMob-Einbindung prüfen: Beim Test wurde das native AdMob-Modul nicht gefunden; Testanzeigen erst nach erfolgreicher Integration und Einwilligungsprüfung aktivieren.
- [ ] **A** Android-Release-Pipeline erstellen: signierter Release-Build/AAB, sichere Ablage des Signierschlüssels und fortlaufender `versionCode`. Export-Presets stehen jetzt auf Code 2; das vorhandene APK ist weiterhin der alte Debug-Testbuild mit Code 1. Export des neuen APK wartet auf den Java-SDK-Pfad in Godot.
- [ ] **M** Schwierigkeit neu balancieren: aktuell schafft der Entwickler alle Karten auf allen Schwierigkeitsstufen problemlos. Schwerpunkt ist **Schwer**, ohne Leicht/Mittel unfair zu machen.
- [ ] **A/M** Weitere Gegnerschiffe mit klar unterscheidbaren Rollen, visueller Silhouette und passenden Kontern entwerfen, implementieren und gegen alle sechs Türme testen.
- [ ] **M** Online-Koop mit zwei echten Geräten vollständig spielen; Emulator-/Integrationstests ersetzen diesen Test nicht.
- [ ] **M** Datenschutzhinweise, Store-Datenerklärungen und Kontolöschung vor einer öffentlichen Store-Veröffentlichung final prüfen.
## Abarbeitung am 06.10.2026
- [x] Drei zusätzliche Gegnertypen mit eigener Grafik und Daten angelegt: schneller Abfangjäger, dauerhafter Schildkreuzer und schwerer Belagerer.
- [x] Gegnertypen kommen wellenabhängig ins Spiel; auf Schwer erscheinen sie früher (Abfangjäger ab Welle 3, Schildkreuzer ab 5, Belagerer ab 8). Bosswellen verwenden den Belagerer.
- [x] Schwer startet jetzt mit 340 Energie und 12 Reaktorleben; die Belohnung beträgt 12 Kernkristalle. Gegnerzahl und Gegnerhülle ziehen ab der Missionsmitte zusätzlich an.
- [x] Automatisierter Godot-Smoke-Test besteht (`VOID_BASTION_SMOKE_TEST_OK`); Windows-Testexport wurde erstellt.
- [x] Nakama-Laufzeitsimulation besteht (`VOID_BASTION_SERVER_SIM_OK`).
- [ ] **A** Windows-Testexport war erfolgreich, meldete aber `app_icon.png` als ungültige Icon-Datei; vor dem nächsten Installer-/Store-Build das Iconformat prüfen und reparieren.
- [ ] Manuelle Spieltests für Schwierigkeitsbalance und alle neuen Gegner bleiben offen; die automatisierten Tests beweisen noch nicht, dass Schwer in echten Läufen fair und fordernd ist.
- [ ] Android-Testbuild mit den neuen Änderungen noch nicht exportiert: Godot meldet, dass `export/android/java_sdk_path` leer ist. Das JDK ist unter `C:\Program Files\Android\Android Studio\jbr` vorhanden; in Godot unter Editor-Einstellungen > Export > Android als Java-SDK-Pfad setzen, dann Export erneut ausführen. Das vorhandene Emulator-APK ist noch älter; ein echter Android-Gerätetest steht ebenfalls aus.
## Gegnervielfalt und Balance
- [x] **A** Drei Bodeneinheiten ergänzt: schneller Rover, Sturmpanzer und Belagerungsläufer; Werte enthalten Lebenspunkte, Tempo, Reaktorschaden und Belohnung.
- [x] **A** Türme besitzen Luft-/Boden-Zielklassen; Plasma und Railgun treffen beide Kategorien mit reduziertem Schaden.
- [x] **A** Aktuelle und nächste Welle werden im HUD mit Luft-/Bodensymbolen, kleinen Einheitengrafiken und Anzahlen angezeigt.
- [x] **A** Die Vorschau zeigt die Gesamtzahl der Schiffe und lesbare Stückzahlen neben den Gegnerminiaturen.
- [x] **A** Lasertreffer bauen ein separates Schild-Lebenskonto ab; der blaue Schutzring zeigt den verbleibenden Anteil gegen den Uhrzeigersinn an und Überschussschaden trifft die Hülle.
- [x] **A** Lokaler Entwicklungsmultiplikator für Gegner-Lebenspunkte (0,5×–3,0×) ist über F9 und die +/- Schaltflächen einstellbar; Online-Koop bleibt davon unabhängig.
- [x] **A** Entwicklermodus kann den Kristallstand in 25er-Schritten erhöhen oder senken und speichert den Teststand.
- [ ] **M** Neue Luft- und Bodentypen im laufenden Spiel visuell prüfen: Silhouetten, Trefferreaktionen, Flug-/Bodenbewegung und Zielauswahl der Türme.
- [ ] **M** Die neuen leuchtend grünen Bodenfahrzeuge im Spiel und in der Wellenvorschau auf Lesbarkeit bei kleiner Darstellung prüfen.
- [ ] **M** Turmprioritäten erste/letzte/MEISTE HP/SCHILD testen; Laser muss bei mehreren Zielen immer ein erreichbares Schildziel vor ungeschützten Zielen wählen.
- [ ] **M** Bei einem Schildgegner den Laser einsetzen und prüfen, dass der blaue Ring sichtbar gegen den Uhrzeigersinn schrumpft und erst danach der überschüssige Schaden die Hülle trifft.
- [ ] **M** Pro Gegnertyp Konter und Werte im Spiel prüfen: Lebenspunkte, Tempo, Schild/Resistenz, Reaktor-Schaden, Belohnung und passende Türme.
- [ ] **M** Alle sieben Karten auf Leicht, Mittel und Schwer vollständig testen und Schwierigkeit, Ressourcen, Gegnerzusammensetzung sowie Sieg-/Niederlagenquote protokollieren.
- [ ] **M** Schwer muss spürbar fordernd sein, aber mit sinnvoller Strategie schaffbar; Leicht und Mittel bleiben fair abgestuft.
- [ ] **A/M** Wellenmischung je Schwierigkeit testen; Schwierigkeit soll nicht allein durch mehr Lebenspunkte entstehen.
- [ ] **M** Testläufe mit Siegquote, Reaktorleben und verwendeten Türmen festhalten; Balance iterativ nach den Ergebnissen anpassen.
- [ ] **M** Gegner-Stärkemultiplikator für Entwicklung verwenden, Testwert notieren und vor dem Release auf 1,0× zurücksetzen.
## Android-Test und Store-Vorbereitung
- [ ] **M** Auf echtem Gerät: Installation, erster Start, Touch-Bauen/Upgraden/Verkaufen, Wellen, Pause, Zurück, App-Wechsel und erneutes Öffnen prüfen.
- [ ] **M** Auf kleinem und großem Display prüfen, dass Spielfeld und Bedienelemente erreichbar und nicht abgeschnitten sind.
- [ ] **M** Android-Audio auf echtem Gerät prüfen; bis dahin bleibt die Android-spezifische Audiodeaktivierung bestehen.
- [ ] **M** Android-Testanzeigen erst freigeben, wenn das native Plugin geladen wird, Anzeigen sichtbar sind und Datenschutz-/Einwilligungsablauf geprüft wurde.
- [ ] **M** Google-Play-internen Test mit signiertem AAB durchführen; Release-Schlüssel geschützt und außerhalb des Repositories verwahren.
+22
View File
@@ -50,6 +50,28 @@ Color palette: dark titanium, worn steel, deep red markings, red-orange emissive
Constraints: genuinely transparent background, no labels, no text, no logos, no watermark, no cropped parts, no perspective tilt Constraints: genuinely transparent background, no labels, no text, no logos, no watermark, no cropped parts, no perspective tilt
``` ```
## Neue Gegnerschiffe (Balance-Iteration)
Die Rollenbilder liegen separat, damit die zusätzlichen Schiffe trotz unterschiedlicher Silhouette ohne Sprite-Sheet-Cropping angezeigt werden: `enemy_interceptor.png`, `enemy_shield_carrier.png` und `enemy_siege_destroyer.png`. Alle drei zeigen die Flugrichtung nach rechts; der Laufcode dreht das jeweilige Bild passend zur Route. Quelle: integrierter OpenAI-Bildgenerator, transparenter Hintergrund, Stilreferenz war `enemy_ships.png`.
**Abfangjäger** — schnell, schmal, geringe Hülle und höherer Reaktorschaden bei Durchbruch.
```text
Einzelner transparenter feindlicher Abfangjäger für Void Bastion, scharf-winklige schmale Flügel, vollständig sichtbar und nach rechts ausgerichtet (Bug rechts, Triebwerke links), verwitterte gunmetal- und rote Panzerplatten, orange Triebwerke, realistischer 3D-gerenderter Top-down-3/4-Sci-Fi-Spielgrafikstil passend zum bestehenden Gegner-Sprite, keine Umgebung, kein Text, kein Rahmen, kein Wasserzeichen.
```
**Schildkreuzer** — mittelgroß, dauerhaft abgeschirmt und mit blau-cyanfarbenen Emittern deutlich erkennbar.
```text
Einzelner transparenter feindlicher Schild-Unterstützer für Void Bastion, breite kompakte Silhouette, cyanfarbene Energie-Emitter-Ringe und leuchtender Kern direkt im Schiff, keine äußere Schutzblase, vollständig sichtbar und nach rechts ausgerichtet, verwitterte gunmetal- und rote Panzerplatten, realistische 3D-gerenderte Sci-Fi-Spielgrafik passend zum bestehenden Gegner-Sprite, keine Umgebung, kein Text, kein Rahmen, kein Wasserzeichen.
```
**Belagerer** — schweres, langsames Schiff mit breiter Silhouette und hohem Reaktorschaden; wird außerdem für Bosswellen genutzt.
```text
Einzelner transparenter schwerer feindlicher Belagerungszerstörer für Void Bastion, breite massive gepanzerte Silhouette mit mehreren sichtbaren Kanonen und drei starken orangefarbenen Triebwerken, vollständig sichtbar und nach rechts ausgerichtet, verwitterte gunmetal- und rote Panzerplatten, realistischer 3D-gerenderter Sci-Fi-Spielgrafikstil passend zum bestehenden Gegner-Sprite, keine Umgebung, kein Text, kein Rahmen, kein Wasserzeichen.
```
## app_icon.png ## app_icon.png
```text ```text
+21
View File
@@ -0,0 +1,21 @@
# Vollständiges Projekt-Backup
- Erstellt: 2026-10-06
- Quelle: `C:\Users\Domin\Documents\Codex\2026-10-02\ich\outputs\void-bastion`
- Enthält den gesamten Projektordner inklusive `.git`-Historie, Assets, Godot-Importdaten, Plattformprojektordner, lokalen Builds und Installerdateien.
- Startpunkt für die Wiederaufnahme: `PROJEKT-HANDBUCH-FORTSETZUNG.md` und danach `README.md`.
- Das Backup ist eine Sicherheitskopie. Änderungen darin nicht als Arbeitskopie verwenden; zuerst in einen normalen Projektordner zurückkopieren.
- Nicht enthalten sind Geheimnisse/Zugangsdaten, die ausschließlich auf Unraid, im Webhosting oder in einem Passwortmanager liegen.
## Prüfsumme prüfen
Nach dem Kopieren lassen sich wichtige Dokumente und Builds mit PowerShell vergleichen:
```powershell
Get-FileHash -Algorithm SHA256 .\PROJEKT-HANDBUCH-FORTSETZUNG.md
Get-FileHash -Algorithm SHA256 .\project.godot
Get-FileHash -Algorithm SHA256 .\build\android\Void-Bastion-Android-x64-alpha4.apk
Get-FileHash -Algorithm SHA256 .\dist\Void-Bastion-Setup-x64.exe
```
Die Ausgabe dieser Befehle muss für Quelldatei und Sicherung identisch sein.
+180
View File
@@ -0,0 +1,180 @@
# Void Bastion – Projekt- und Fortsetzungs-Handbuch
Stand: 6. Oktober 2026 · Version: 0.1.0-alpha.4 · Engine: Godot 4.7.2
Dieses Dokument ist die zentrale technische Übergabe für die Weiterarbeit an Void Bastion – auch nach einem PC-Wechsel. Es beschreibt den aktuellen Stand, die Projektstruktur und die verbindlichen Gestaltungs- und Datenregeln. Bei Änderungen immer zuerst diese Datei, `README.md`, `ART_ASSETS.md` und die Tests lesen.
## Projektziele und aktueller Stand
Void Bastion ist ein 2D-Tower-Defense-Spiel im realistischen Science-Fiction-Stil für Windows, Android und später iOS. Der aktuelle Spielstand ist Alpha 4. Einzelspieler mit sieben Karten, sechs Türmen, drei Schwierigkeitsstufen, 15 Wellen plus optionalem Endlosmodus, Shop, lokalem Profil und Bestenlisten ist vorhanden. Ein lokaler Koop-Modus gegen KI sowie eine Nakama-Onlineanbindung sind vorbereitet/implementiert.
Wichtige Einschränkungen des aktuellen Android-Teststands:
- Der Android-Emulator (Android 17, x86_64, SwiftShader) startet Alpha 4 und bleibt mindestens 45 Sekunden aktiv. Der Bildschirm zeigt das Hauptmenü; im Log gab es während dieses Laufs keinen Absturz.
- Android verwendet den Mobile/Vulkan-Renderer. Die GLES-Compatibility-Variante überschritt auf dem Emulator die verfügbare Shader-Uniform-Grenze.
- Wegen eines reproduzierbaren nativen SIGSEGV in Androids AudioTrack/OpenSL-Ausgabepfad sind Musik und UI-Klicktöne derzeit nur auf Android deaktiviert. Windows-Audio bleibt aktiv. Vor dem Wiederaktivieren erst mit regulären, eingebundenen Audioassets und auf echten Geräten erneut testen.
- Im Android-Log meldet das AdMob-Plugin, dass sein natives Singleton nicht initialisiert ist. Android-Werbung ist deshalb nicht als funktionsfähig getestet anzusehen.
- Die geprüfte Emulator-Datei ist `build/android/Void-Bastion-Android-x64-alpha4.apk`. Sie hat die Paketkennung `de.gergames.voidbastion`, Versionsnamen `0.1.0-alpha.4` und ist mit dem lokalen Debug-Schlüssel signiert. Sie ist nur zum Sideload-/Emulator-Test bestimmt, nicht zur Store-Veröffentlichung.
- Es wurde kein echtes Android-Handy in diesem Testlauf geprüft. Touch-Bedienung, Geräteperformance, verschiedene Seitenverhältnisse, Anzeigen und Audio brauchen noch echte Gerätetests.
## Projektdateien und Zuständigkeiten
- `project.godot`: Godot-Projekt, Fenster-/Viewport-Einstellungen, Autoloads, Editor-Plugins und Renderer. Desktop bleibt auf `gl_compatibility`; Mobilgeräte nutzen derzeit `mobile`/Vulkan.
- `main.gd`: Spielzustand, Karten-/Weg-/Bau-/Turmdaten, Wellen, KI, Kampf, UI, Zeichnen und lokale Navigation. Die zentralen Datenkonstanten stehen am Dateianfang.
- `main.tscn`: Hauptszene und Manager-Nodes.
- `multiplayer_manager.gd`: lokale Koop-/KI-Regeln und Multiplayer-Helfer.
- `online_manager.gd`, `account_manager.gd`: Nakama-Verbindung, Profil, Anmeldung und Ranglisten.
- `ads_manager.gd`: Werbeintegration. Testmodus beibehalten, bis Konten, Einwilligung und Store-Konfiguration rechtlich/technisch geprüft sind.
- `update_manager.gd`: Prüft öffentliche Releases und bietet einen Update-Link an; installiert nichts ungefragt.
- `assets/`: Kartenbasen, Turm-/Gegnersprites, Straßenmaterial und UI-Grafiken.
- `tests/smoke_test.gd`: Offline-/Logikregressionstest. Nach Karten-, Weg-, Turm-, Wellen- oder Balancingänderungen immer ausführen.
- `tests/online_integration_test.gd`: Nakama-/Onlineintegrationstest; nur gegen ausdrücklich gewählte Testumgebung ausführen.
- `packaging/windows/build.ps1` und `packaging/windows/installer.nsi`: Windows-Export und Setup-Installer.
- `export_presets.cfg`: Windows-, Android- und iOS-Exportprofile.
- `server/nakama/`: Serverkonfiguration/Module. Keine Servergeheimnisse hier ablegen.
- `README.md`, `ART_ASSETS.md`, `ONLINE_SETUP.md`, `LEGAL_RELEASE_CHECKLIST.md`, `PLATFORM_ACCOUNT_SETUP.md`, `THIRD_PARTY_NOTICES.md`: ergänzende Projektunterlagen.
- `.godot/`, `build/`, `dist/`, `android/build/`: generierte Imports, Builds, Caches und Artefakte. Sie gehören zum vollständigen PC-Backup, aber normalerweise nicht in Git-Commits.
## Karten: verbindliche Regeln
### Koordinatensystem und Raster
- Die logische Spielfläche ist `SCREEN = Vector2(1600, 900)` in `main.gd`.
- `GRID_SIZE = 80` Pixel; Karten, Wege, Bauzellen und Turm-Snapping müssen mit demselben Raster arbeiten.
- Das Rasterzentrum eines Feldes liegt bei `(40 + 80·x, 40 + 80·y)`. Wegpunkte sollen auf diesen Feldmitten liegen. Der auslaufende Start darf links bei `x = -40` liegen.
- Wegsegmente verlaufen nur waagerecht oder senkrecht. Keine diagonalen Segmente. Kurven entstehen durch aufeinanderfolgende rechtwinklige Segmente.
- Gegnerrouten und Straßenzeichnung beziehen ihre Daten gemeinsam aus `MAP_PATHS`/`path_cells`. Niemals zusätzlich einen unabhängigen Weg in die Hintergrundgrafik malen oder eine zweite separate Laufroute definieren.
### Drei parallele Kartendatenlisten
Jeder Karteneintrag muss denselben Index in ALLEN folgenden Arrays besitzen (in `main.gd`):
1. `MAP_TEXTURES`: Hintergrundtextur.
2. `MAP_NAMES`, `MAP_DESCRIPTIONS`, `MAP_UNLOCK_COSTS`: Name, Kurzbeschreibung und Shoppreis.
3. `MAP_PATHS`: geordnete Mittellinienpunkte für Gegner und Straße.
4. `MAP_BUILD_ZONES`: erlaubte Deckbereiche als ein oder mehrere `Rect2`-Rechtecke.
5. `MAP_REACTOR_ZONES`: Reaktormittelpunkt(e) und Sperrradius/-radien.
Alle Listen müssen dieselbe Kartenanzahl und identische Reihenfolge haben. Nach Hinzufügen oder Umsortieren einer Karte die Anzahl und Reihenfolge prüfen und Smoke-Test ergänzen.
### Routenende, Reaktor und Bauverbote
- Letzter Punkt in `MAP_PATHS[index]` muss exakt im Mittelpunkt des Hauptreaktors landen (Toleranz in `_validate_route_ends_at_reactor()` ist 1 Pixel). `_select_map()` validiert die Route; der Smoke-Test validiert alle Karten.
- Die Reaktorgrafik im Hintergrund, `MAP_REACTOR_ZONES[index][0].center` und letzter Wegpunkt müssen optisch/technisch deckungsgleich sein.
- Auf jedem Reaktor ist Bauen verboten. Alle Reaktoren/offenen Reaktorkammern derselben Karte in `MAP_REACTOR_ZONES` eintragen. Pyrax hat zusätzlich die offene Reaktorkammer als zweite Sperrzone.
- Erlaubte Deckzellen werden über `MAP_BUILD_ZONES` definiert. Das Spiel sperrt zusätzlich Wegzellen, Reaktorzonen, Randzellen und bereits belegte Zellen.
- Türme werden auf Feldmitten eingerastet. Weder Türme noch Wegkacheln sollen nur über Bildkoordinaten korrigiert werden: zuerst Raster-/Routendaten korrigieren.
### Kartengrafik
- Karte ist eine saubere, hochauflösende Draufsicht im Querformat (bestehende HD-Basen etwa 3344×1882 Pixel).
- Hintergrundgrafik ohne eingebauten Weg, Fahrbahn, Raster, Bauplätze, Türme, Gegner, UI, Text oder Logos erstellen. Straße/Raster/Route werden im Spiel gezeichnet; so können sie nicht gegeneinander verrutschen.
- Der Reaktor soll gut erkennbar am rechten Rand ungefähr auf Höhe des Spielfeldzentrums liegen, außer die Route/Zone definiert absichtlich eine andere Lage.
- Behalte realistische aktive Stationsbeleuchtung, Metallpaneele, Cyan-Energieelemente und kleine warme Positionslichter. Keine dunkle/ausgewaschene Karte und keine starke gelbe Umrandung der kompletten Straße.
- Die Straße ist eine Rasterzelle breit (80 px) und nutzt `road_straight.svg`, `road_corner.svg`, `road_surface_material_v1.png`, `road_corner_surface_v1.png`.
- Der letzte Abschnitt zum Reaktor wird in `_draw_reactor_approach()` mit leisem Lichtimpuls und langsamem Transparenzverlauf gestaltet. Den Schluss nicht hart abschneiden und keine groben Doppelbögen ans Reaktorzentrum setzen.
- Asset-Prompts und Asset-Herkunft in `ART_ASSETS.md` dokumentieren.
### Kartenprüfung
1. Für jeden Wegpunkt Rastermitte prüfen, horizontale/vertikale Segmente sicherstellen.
2. Ende exakt am Reaktormittelpunkt kontrollieren.
3. Reaktor im Hintergrund mit Sperrzone vergleichen; keine Bauzellen auf dem Reaktor.
4. Das Projekt starten: Weg muss sichtbar mit Gegnerlaufspur, Raster und Hintergrund zusammenpassen.
5. Zwischen Wegen müssen Bauzellen frei bleiben, soweit sie innerhalb einer Build-Zone und außerhalb aller Route-/Reaktor-Sperren liegen.
6. `tests/smoke_test.gd` für jede neue Karte um Routenende, Reactor-Sperre, baubare Zellen und Shop-Kosten erweitern und ausführen.
## Türme und Gegner
### Turmdaten und Darstellung
Die Liste `TOWER_DATA` in `main.gd` ist die zentrale Quelle für Turmname, Baukosten, Basisschaden, Reichweite, Schussintervall (`rate`, Sekunden pro Schuss), Projektilgeschwindigkeit, Schadensart, Farbe, Freischaltpreis und Beschreibung. Schüsse/Sekunde wird aus dem Intervall berechnet. Beim Hinzufügen eines Turms müssen UI-Karte, Shop, Platzierung, Reichweitenvorschau, Angriff/Schadenslogik, Upgrade, Speichern/Freischaltung und Tests konsistent ergänzt werden.
Sprites:
- Kinetik, Laser, Rakete: drei horizontale Frames aus `assets/defense_towers.png`.
- Plasma, EMP-Puls, Railgun: separate Texturen (`tower_plasma.png`, `tower_emp.png`, `tower_railgun.png`).
- Ergänzende Sprites im bestehenden maßstäblichen Top-Down-Stil und mit transparenter Fläche erzeugen. In `_draw_tower()` Textur/Fall ergänzen. Bei der Kinetik-Ausrichtung beachten, dass ein falscher Basiswinkel den Lauf um 180° verdreht erscheinen lässt.
| Turm | Kosten | Schaden | Reichweite | Intervall | Besonderheit |
|---|---:|---:|---:|---:|---|
| Kinetik | 120 EN | 22 | 190 | 0,55 s | Einzelziel |
| Laser | 180 EN | 12 | 165 | 0,18 s | Schnell, effektiv gegen Schilde |
| Rakete | 250 EN | 65 | 260 | 1,40 s | Flächenschaden 76 |
| Plasma | 230 EN | 48 | 240 | 1,05 s | Flächenschaden 92; 18 Kernkristalle Freischaltung |
| EMP-Puls | 210 EN | 34 | 215 | 0,92 s | Flächeneffekt 118; 24 Kernkristalle |
| Railgun | 300 EN | 110 | 330 | 1,85 s | Durchschlägt Schild; 30 Kernkristalle |
Alle Türme unten in der Leiste darstellen. Reichweite schon vor dem Bauen zeigen; Vorschaukasten kompakt/transluzent halten. Turminfo soll Schaden, Schüsse pro Sekunde, Reichweite und Einzel-/Flächentyp zeigen. Upgrade-Vorschau muss den echten Zuwachs darstellen und darf keine verdeckten Felder erzeugen. Bei Balancingänderungen Shoptext und Tests mit anpassen.
### Gegner
Gegnerwerte liegen in `ENEMY_DATA`: Jäger (70 HP, Tempo 105, Belohnung 18), Fregatte (185 HP, Tempo 72, Belohnung 34), Kreuzer (520 HP, Tempo 43, Belohnung 78). Skalierung und Wellenlogik werden zusätzlich durch `DIFFICULTY_DATA`/Wellenfunktionen beeinflusst. Schildgegner sollen Laserschaden bevorzugt wirksam bekommen; andere Türme dürfen reduziert, aber nicht grundsätzlich null Schaden verursachen. Railgun ignoriert den Schildschutz.
## Wellen, Wirtschaft, Highscore
- `MAX_WAVES = 15`; Bossgegner alle fünf Wellen.
- Leicht entspricht dem früheren Balancing, Mittel/Schwer steigern Lebenspunkte, Tempo und Gegneranzahl und reduzieren Startressourcen. Belohnung aktuell 5/7/10 Kernkristalle nach Schwierigkeit.
- Nach Welle 15 gibt es die Wahl: Mission beenden oder freiwillig Endlosmodus. Endlos-Verlust gilt nicht als gescheiterte Mission.
- Punkte sollen neben erreichter Welle auch Restenergie und effiziente Turmverwendung abbilden. Verkauf aller Türme in der Schlusswelle darf keinen Multiplikator-Trick ermöglichen; deshalb Turmbau-/Verkaufshistorie berücksichtigen, nicht nur die am Ende noch stehenden Türme.
- Spielwährung heißt konsistent **Kernkristalle** (nicht abwechselnd „Kern“ oder „Kristalle“). Energie/EN ist die missionsinterne Bauressource.
- Für neue Türme/Karten Shopfreischaltungen und Belohnung konsistent speichern; kein echter Geldeinkauf ist für diese Testfassung erforderlich.
## Multiplayer
- Lokale KI-Arena verwendet `COOP_LEFT_PATH` und eine Spiegelung exakt um die Achse durch den gemeinsamen Reaktor (`COOP_MIRROR_X = 1480`). Weltbreite ist doppelt; beide Seiten sollen voll groß bleiben, nicht zusammengeschrumpft.
- Reaktorleben sind gemeinsam, Credits getrennt; jeder baut auf seiner Seite. KI-Platzierung in `_find_ai_build_position()` anhand echter Gegnerroute und effektiver Turmreichweite bewerten, niemals zufällige/off-route Plätze wählen.
- Onlinekonten, Räume, Ranglisten und Ergebnisprüfung laufen über Nakama. Niemals Admin-/Server-/HTTP-Schlüssel in Godot-Projekt, `.env`-Beispiel, Git, Website oder diese Anleitung kopieren.
- Server-/Unraid-Konfiguration und öffentliche Adresse sind in `ONLINE_SETUP.md` bzw. `server/nakama/README.md` beschrieben; echte Geheimnisse liegen nur auf dem Server/Webhosting.
## Builds, Checks und Release
### Logiktest
```powershell
& 'E:\Program Files\Godot\Godot_v4.7.2-stable_win64_console.exe' --headless --path . --script tests/smoke_test.gd
```
Erfolg endet mit `VOID_BASTION_SMOKE_TEST_OK`. Bekannte Umgebungswarnungen zum CA-Zertifikat oder user://-Log sind nicht automatisch ein Spielfehler; entscheidend ist Rückgabecode und Ergebnis.
### Windows-Installer
```powershell
& '.\packaging\windows\build.ps1' `
-GodotExe 'E:\Program Files\Godot\Godot_v4.7.2-stable_win64_console.exe' `
-MakeNsisExe '.\build\nsis-portable\nsis-3.13\makensis.exe'
```
Artefakt: `dist/Void-Bastion-Setup-x64.exe`. Das Skript blendet das Entwicklungs-MCP aus dem Release aus, exportiert und stellt anschließend `project.godot` wieder her.
### Android
- Godot Exportprofil heißt `Android Studio Emulator x86_64` (emulator) bzw. `Android Test APK` (ARM64/ARMv7-Geräte).
- Erfordert Android SDK, Java 17, Gradle und Godot-Android-Templates. Android-Paketkennung ist `de.gergames.voidbastion`.
- Das Editor-MCP-Autoload/Plugin darf nicht ins Release gelangen: temporär vor dem Export `_MCPGameBridge` und `res://addons/agent_tools/plugin.cfg` aus der Exportkopie entfernen und Quelldatei danach wiederherstellen (Windows-Skript zeigt das sichere try/finally-Muster). Nie eine Änderung versehentlich dauerhaft aus der Entwicklerdatei löschen.
- Emulator Alpha 4 wurde mit Gradle 8.11.1, AGP 8.6.1, Java 17 und SDK 36 gebaut. Bei einem Gradle `AccessDeniedException` in einer Transform-Cache-Datei neues separates `GRADLE_USER_HOME` nutzen; vorhandene Cache-Datei nicht blind löschen.
- Für einen Testbuild prüfen: `aapt dump badging` zeigt `de.gergames.voidbastion` und `0.1.0-alpha.4`; `apksigner verify --verbose` muss `Verifies` melden; danach auf Emulator installieren und mindestens 45 Sekunden starten, Logcat auf SIGSEGV/FATAL prüfen und Screenshot kontrollieren.
- Erfolgreiche Testdatei liegt in `build/android/Void-Bastion-Android-x64-alpha4.apk` (Debugsignatur; nur intern). APK ist im Git-Verlauf nicht garantiert; im Vollbackup enthalten.
- Für neue Google-Play-/Sideload-Versionen Android `versionCode` erhöhen. Signaturschlüssel für Produktion getrennt und sicher aufbewahren, nicht Debugschlüssel als Store-Schlüssel verwenden.
### iOS und Stores
iOS-Export erzeugt ein Xcode-Projekt, erfordert macOS, Xcode, Zertifikate/Provisioning und ein Apple-Developer-Konto. Ein Windows-Rechner kann die iOS-App nicht final signieren oder in den App Store hochladen. Google Play-/Apple Store-/Steam-Accounts, Datenschutz- und Storefreigaben bleiben manuelle Betreiberaufgaben. Vor öffentlichem Release Checklisten/Third-party-Lizenzen und echte Geräteprüfung abschließen.
### Webseite und Releases
Homepage: `https://void-bastion.gergames.de/`. Öffentliche Releases/Setup liegen im separaten Gitea-Releases-Projekt; der Ingame-Updater fragt die Release-API ab und soll automatisch das neueste passende Setup/APK verlinken. Nach einer Veröffentlichung Release-Tag, Version in Godot, Windows Installer, Android versionCode und Release-Assetnamen synchron halten. Keine Websiteänderung ist erforderlich, wenn Release-API/Assets korrekt gepflegt sind.
## Weiterarbeit nach einem PC-Wechsel
1. Backupordner auf neuen PC kopieren und vorzugsweise in einen stabilen Arbeitsordner legen.
2. Git installieren; `git status` und `git log -1` prüfen. Den mitgesicherten `.git`-Ordner nicht entfernen, damit die Historie erhalten bleibt.
3. Godot 4.7.2 installieren und `project.godot` importieren; ersten Assetimport abwarten.
4. `PROJEKT-HANDBUCH-FORTSETZUNG.md`, `README.md`, `ART_ASSETS.md`, `ALPHA_TEST_CHECKLIST.md`, `ONLINE_SETUP.md` lesen.
5. Android-Buildtools/JDK 17 nur einrichten, wenn Android gebraucht wird. Emulator neu anlegen; lokale Gradle-Caches und lokale Godot-Einstellungen sind maschinengebunden und können auf dem neuen PC neu erzeugt werden.
6. `tests/smoke_test.gd` ausführen, dann Spiel im Editor starten und zuletzt Plattformbuilds prüfen.
7. Serverzugänge, Signierzertifikate, echte AdMob-/Nakama-Schlüssel und Hostingpasswörter separat aus einem Passwortmanager/Server sichern. Diese sind absichtlich nicht Teil des Repositories oder dieser Anleitung.
## Grundsatz bei jeder Karten-/Turmänderung
Eine visuelle Änderung ist erst fertig, wenn Datenquelle, Darstellung, Kollisions-/Bauprüfung, Kauf-/Freischaltstatus, UI-Text und automatisierter Test zueinander passen. Bei Karten besonders Route, Straßenkacheln, Raster, Bauflächen, Sperrzonen und Reaktorhintergrund gemeinsam prüfen. Keine unabhängige „optische Reparatur“ über die Route zeichnen.
+10 -3
View File
@@ -16,9 +16,16 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
- jede geeignete Plattformzelle ist bebaubar; ausschließlich Wegzellen, Reaktoren und Bereiche außerhalb der Plattform sind gesperrt - jede geeignete Plattformzelle ist bebaubar; ausschließlich Wegzellen, Reaktoren und Bereiche außerhalb der Plattform sind gesperrt
- neu erzeugte, straßenfreie HD-Kartenbasen mit 3344 × 1882 Pixeln und Mipmaps für eine schärfere Windows-Darstellung - neu erzeugte, straßenfreie HD-Kartenbasen mit 3344 × 1882 Pixeln und Mipmaps für eine schärfere Windows-Darstellung
- sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun - sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun
- drei Gegnerklassen mit unterschiedlichen Werten - neun Gegnerklassen mit Luft- und Bodeneinheiten: Jäger, Fregatte, Kreuzer, Abfangjäger, Schildkreuzer, Luft-Belagerer, schneller Boden-Rover, Panzer und schwerer Läufer
- neue Bodeneinheiten-Grafiken mit leuchtend grünen Warnmarkierungen und Energielichtern
- Zielprioritäten für Türme: erste, letzte, höchste verbleibende Hülle oder Schildziele zuerst; Laser priorisiert Schilde immer automatisch
- Luft-/Bodenvorschau wird durch gleich gestaltete grüne Pfeile unterschieden: Luft nach oben, Boden nach unten
- Schildgegner haben einen blauen, gegen den Uhrzeigersinn abnehmenden Schildring; Lasertreffer bauen zuerst Schildpunkte ab und übertragen Überschuss auf die Hülle
- sechs Türme mit klar ausgewiesenen Zielen: Boden, Luft oder beide Kategorien; universelle Türme verursachen reduziert Schaden
- HUD-Vorschau mit kleinen Einheitensymbolen und Stückzahl für aktuelle und nächste Welle
- Entwickler-Testregler für Gegner-Lebenspunkte (F9 ein-/ausblenden, +/− verändern; Faktor 0,5×–3,0×, im Online-Koop deaktiviert)
- 15 automatisch skalierende Missionswellen - 15 automatisch skalierende Missionswellen
- drei wählbare Schwierigkeitsgrade: Leicht entspricht dem bisherigen Balancing, Mittel ist der neue Standard und Schwer erhöht Gegnerhülle, Tempo und Anzahl deutlich - drei wählbare Schwierigkeitsgrade: Leicht behält die bisherige Kurve; Schwer startet knapper und erhöht ab Missionsmitte Gegnerhülle, Gegnerzahl und fortgeschrittene Gegnertypen stärker
- höhere Schwierigkeitsgrade beginnen mit knapperen Ressourcen und belohnen einen Kartensieg mit mehr Kernkristallen - höhere Schwierigkeitsgrade beginnen mit knapperen Ressourcen und belohnen einen Kartensieg mit mehr Kernkristallen
- freiwilliger Endlosmodus nach Welle 15 mit unbegrenzt weiter skalierenden Gegnern - freiwilliger Endlosmodus nach Welle 15 mit unbegrenzt weiter skalierenden Gegnern
- das Ende eines Endloslaufs zählt ausdrücklich nicht als gescheiterte Mission - das Ende eines Endloslaufs zählt ausdrücklich nicht als gescheiterte Mission
@@ -86,7 +93,7 @@ Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path
Für reproduzierbare Windows-Builds steht `packaging/windows/build.ps1` bereit. Godot und NSIS werden als Parameter übergeben; das Skript exportiert das Spiel, erstellt den Installer und gibt anschließend Größe und SHA-256-Prüfsumme aus. Die manuellen Freigabeprüfungen stehen in `ALPHA_TEST_CHECKLIST.md`. Für reproduzierbare Windows-Builds steht `packaging/windows/build.ps1` bereit. Godot und NSIS werden als Parameter übergeben; das Skript exportiert das Spiel, erstellt den Installer und gibt anschließend Größe und SHA-256-Prüfsumme aus. Die manuellen Freigabeprüfungen stehen in `ALPHA_TEST_CHECKLIST.md`.
Die fertigen Hinweise für die erste öffentliche Testversion stehen in `RELEASE_NOTES_0.1.0-alpha.md`. Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.7.md`.
## Werbung und Datenschutz ## Werbung und Datenschutz
+18
View File
@@ -0,0 +1,18 @@
# Void Bastion 0.1.0-alpha.5
Diese Windows-Testversion erweitert die Gegnerflotte und zieht die Balance der schwierigeren Einsätze an.
## Neu
- drei zusätzliche Gegnertypen: Abfangjäger, Schildträger und Belagerungszerstörer
- neue Flugzeug- und Schiffsgrafiken für die zusätzlichen Gegnertypen
## Verbessert
- höhere Gegnerzahl und stärkere Hüllenwerte in mittleren und späten Wellen auf den Schwierigkeitsgraden Mittel und Schwer
- fortgeschrittene Gegner erscheinen auf höheren Schwierigkeitsgraden früher und häufiger
- Wellen nach dem Missionssieg skalieren im Endlosmodus weiter
## Hinweis
Void Bastion befindet sich weiterhin in einer frühen Alpha-Phase. Unter Windows erscheint keine Werbung. Updates werden im Spiel angekündigt, aber niemals ungefragt installiert. Ruckmeldungen und Fehlerberichte bitte an support@gergames.de senden.
+18
View File
@@ -0,0 +1,18 @@
# Void Bastion 0.1.0-alpha.6
## Luft- und Bodeneinheiten
- Drei neue Bodengegner: schneller Rover, schwerer Panzer und Belagerungsläufer.
- Alle drei Bodeneinheiten haben jetzt deutlich sichtbare leuchtend grüne Akzente.
- Türme zeigen ihre Zielklasse an und greifen nur passende Einheiten an. Laser, Plasma und Railgun können beide Klassen treffen, verursachen dabei aber weniger Schaden.
- Für ausgewählte Türme lässt sich die Zielpriorität zwischen erster, letzter, höchster verbleibender Hülle und Schildzielen wechseln. Lasertürme priorisieren Schildgegner immer automatisch, sowohl lokal als auch im Online-Koop.
- Schildgegner haben jetzt ein eigenes Schild-Lebenskonto. Lasertreffer bauen es zuerst ab; überschüssiger Schaden geht danach auf die Hülle. Das Schild wird als blauer Ring dargestellt, dessen verbleibender Anteil gegen den Uhrzeigersinn schrumpft.
- In der Wellenvorschau zeigen gleich gestaltete grüne Richtungspfeile nach oben für Luft und nach unten für Boden.
- Die Vorschau nennt jetzt zusätzlich die Gesamtzahl aller Schiffe der Welle und zeigt die Stückzahl pro sichtbarem Gegnertyp gut lesbar neben dessen Miniatur.
- Gegnerwellen werden vorab deterministisch geplant. Die HUD-Leiste zeigt kompakt die aktuelle und nächste Welle mit Kategorie, Einheitensymbol und Anzahl.
- Im lokalen Testmodus lässt sich die Gegner-Lebenspunkte-Stärke mit **F9** einblenden und über **− / +** zwischen **0,5× und 3,0×** einstellen. Der Faktor gilt auch für bereits aktive lokale Gegner und ist im Online-Koop gesperrt.
- Dasselbe Testfeld kann mit **−25 / +25** den lokalen Kernkristallstand für Shop- und Freischalttests verändern; der Teststand wird gespeichert und fällt nie unter null.
## Hinweis
Diese Änderung ist ein Entwicklungsstand und wurde noch nicht als öffentliches Setup veröffentlicht. Online-Koop-Wellen verwenden dieselben Einheitentypen, Klassenregeln und Wellenplanung; der lokale Testmultiplikator verändert keine Online-Partie.
+16
View File
@@ -0,0 +1,16 @@
# Void Bastion 0.1.0-alpha.7
## Neue Einheiten und Türme
- Drei Bodengegner – Rover, Panzer und Belagerungsläufer – sind mit grünen Leuchtelementen besser von Lufteinheiten unterscheidbar.
- Die Turmauswahl ist nach Zielklasse sortiert: zuerst reine Luftabwehr, dann Bodenabwehr, danach Mehrzwecktürme. Aktuell gibt es noch keinen reinen Luftabwehrturm.
- Die Felder der Turmauswahl sind leicht eingefärbt: Rot für Luft, Grün für Boden und Blau-Cyan für Luft/Boden.
- Der Railgun-Strahl ist lila und durchschlägt alle Gegner auf seiner Schusslinie, auch Ziele hinter dem zuerst anvisierten Gegner. Schilde werden weiterhin durchschlagen.
- Der EMP-Puls trifft Luft- und Bodeneinheiten im Explosionsradius mit je 20 Schaden.
- Schildgegner, Zielprioritäten und die Wellenübersicht zeigen jetzt die Luft-/Bodenklassen und Stückzahlen.
- Im lokalen Entwicklermodus lassen sich Gegnerstärke und Kernkristalle zum Testen anpassen.
## Testhinweise
- Spiel- und Servertests wurden im Headless-Modus ausgeführt.
- Windows-Setup und Online-Koop müssen vor einer öffentlichen Freigabe zusätzlich praktisch auf dem Zielsystem geprüft werden.
+1
View File
@@ -0,0 +1 @@
4.7.2.stable
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bnkdjurvbhyxn"
path="res://.godot/imported/enemy_ground_rover.png-78b1629e1d50e53aefbd44cf187db541.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_ground_rover.png"
dest_files=["res://.godot/imported/enemy_ground_rover.png-78b1629e1d50e53aefbd44cf187db541.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dw874rt1gjjq4"
path="res://.godot/imported/enemy_ground_tank.png-a69deadc2b1612bb3304c457aae904a7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_ground_tank.png"
dest_files=["res://.godot/imported/enemy_ground_tank.png-a69deadc2b1612bb3304c457aae904a7.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cpa443bn3712c"
path="res://.godot/imported/enemy_ground_walker.png-97fb77912dc8b92904da3219a9ad1771.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_ground_walker.png"
dest_files=["res://.godot/imported/enemy_ground_walker.png-97fb77912dc8b92904da3219a9ad1771.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dq8ng1q3sipdj"
path="res://.godot/imported/enemy_interceptor.png-25fe15248366da5309a562c7fda1695b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_interceptor.png"
dest_files=["res://.godot/imported/enemy_interceptor.png-25fe15248366da5309a562c7fda1695b.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d0c55da7vg2x8"
path="res://.godot/imported/enemy_shield_carrier.png-5797af82d40d1f3dfd002a92a5efe490.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_shield_carrier.png"
dest_files=["res://.godot/imported/enemy_shield_carrier.png-5797af82d40d1f3dfd002a92a5efe490.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://7vnc3kiipsqj"
path="res://.godot/imported/enemy_siege_destroyer.png-b87dfe2af9a477c9a1f1ee85d55de962.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_siege_destroyer.png"
dest_files=["res://.godot/imported/enemy_siege_destroyer.png-b87dfe2af9a477c9a1f1ee85d55de962.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
+4 -2
View File
@@ -24,8 +24,8 @@ application/modify_resources=true
application/icon="res://assets/app_icon.png" application/icon="res://assets/app_icon.png"
application/console_wrapper_icon="" application/console_wrapper_icon=""
application/icon_interpolation=4 application/icon_interpolation=4
application/file_version="0.1.0.4" application/file_version="0.1.0.7"
application/product_version="0.1.0.4" application/product_version="0.1.0.7"
application/company_name="GerGames" application/company_name="GerGames"
application/product_name="Void Bastion" application/product_name="Void Bastion"
application/file_description="Void Bastion – Alpha-Testversion" application/file_description="Void Bastion – Alpha-Testversion"
@@ -62,6 +62,7 @@ architectures/x86=false
architectures/x86_64=false architectures/x86_64=false
package/name="Void Bastion" package/name="Void Bastion"
package/unique_name="de.gergames.voidbastion" package/unique_name="de.gergames.voidbastion"
version/code=2
package/signed=true package/signed=true
screen/immersive_mode=true screen/immersive_mode=true
permissions/internet=true permissions/internet=true
@@ -118,6 +119,7 @@ architectures/x86=false
architectures/x86_64=true architectures/x86_64=true
package/name="Void Bastion" package/name="Void Bastion"
package/unique_name="de.gergames.voidbastion" package/unique_name="de.gergames.voidbastion"
version/code=2
package/signed=true package/signed=true
screen/immersive_mode=true screen/immersive_mode=true
permissions/internet=true permissions/internet=true
+406 -48
View File
@@ -14,6 +14,12 @@ const PLASMA_TEXTURE := preload("res://assets/tower_plasma.png")
const EMP_TEXTURE := preload("res://assets/tower_emp.png") const EMP_TEXTURE := preload("res://assets/tower_emp.png")
const RAILGUN_TEXTURE := preload("res://assets/tower_railgun.png") const RAILGUN_TEXTURE := preload("res://assets/tower_railgun.png")
const ENEMY_TEXTURE := preload("res://assets/enemy_ships.png") const ENEMY_TEXTURE := preload("res://assets/enemy_ships.png")
const ENEMY_INTERCEPTOR_TEXTURE := preload("res://assets/enemy_interceptor.png")
const ENEMY_SHIELD_CARRIER_TEXTURE := preload("res://assets/enemy_shield_carrier.png")
const ENEMY_SIEGE_DESTROYER_TEXTURE := preload("res://assets/enemy_siege_destroyer.png")
const ENEMY_GROUND_ROVER_TEXTURE := preload("res://assets/enemy_ground_rover.png")
const ENEMY_GROUND_TANK_TEXTURE := preload("res://assets/enemy_ground_tank.png")
const ENEMY_GROUND_WALKER_TEXTURE := preload("res://assets/enemy_ground_walker.png")
const ROAD_STRAIGHT_TEXTURE := preload("res://assets/road_straight.svg") const ROAD_STRAIGHT_TEXTURE := preload("res://assets/road_straight.svg")
const ROAD_CORNER_TEXTURE := preload("res://assets/road_corner.svg") const ROAD_CORNER_TEXTURE := preload("res://assets/road_corner.svg")
const ROAD_SURFACE_TEXTURE := preload("res://assets/road_surface_material_v1.png") const ROAD_SURFACE_TEXTURE := preload("res://assets/road_surface_material_v1.png")
@@ -69,27 +75,33 @@ const COOP_REACTOR_RADIUS := 112.0
const DIFFICULTY_DATA := [ const DIFFICULTY_DATA := [
{"name": "LEICHT", "hp": 1.0, "speed": 1.0, "count": 1.0, "income": 1.0, "spawn_delay": 1.0, "start_credits": 450, "lives": 20, "crystals": 5}, {"name": "LEICHT", "hp": 1.0, "speed": 1.0, "count": 1.0, "income": 1.0, "spawn_delay": 1.0, "start_credits": 450, "lives": 20, "crystals": 5},
{"name": "MITTEL", "hp": 1.32, "speed": 1.07, "count": 1.24, "income": 0.94, "spawn_delay": 0.91, "start_credits": 430, "lives": 17, "crystals": 7}, {"name": "MITTEL", "hp": 1.32, "speed": 1.07, "count": 1.24, "income": 0.94, "spawn_delay": 0.91, "start_credits": 430, "lives": 17, "crystals": 7},
{"name": "SCHWER", "hp": 1.72, "speed": 1.15, "count": 1.48, "income": 0.84, "spawn_delay": 0.82, "start_credits": 400, "lives": 14, "crystals": 10} {"name": "SCHWER", "hp": 1.72, "speed": 1.15, "count": 1.48, "income": 0.78, "spawn_delay": 0.82, "start_credits": 340, "lives": 12, "crystals": 12}
] ]
const DIFFICULTY_DESCRIPTIONS := [ const DIFFICULTY_DESCRIPTIONS := [
"Bisheriges Balancing • 20 Reaktor • Kartenbonus 5", "Bisheriges Balancing • 20 Reaktor • Kartenbonus 5",
"+32 % Gegnerhülle • +24 % Gegner • Kartenbonus 7", "+32 % Gegnerhülle • +24 % Gegner • Kartenbonus 7",
"+72 % Gegnerhülle • +48 % Gegner • Kartenbonus 10" "+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12"
] ]
const TOWER_DATA := [ const TOWER_DATA := [
{"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässiges Projektilgeschütz."}, {"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "target_class": "ground", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässig gegen Bodenziele."},
{"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "color": Color("62fff1"), "unlock_cost": 0, "description": "Schneller Strahl, stark gegen Schilde."}, {"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "target_class": "both", "both_damage_scale": 0.82, "color": Color("62fff1"), "unlock_cost": 0, "description": "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen."},
{"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Explosionsschaden gegen Gruppen."}, {"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "target_class": "ground", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Bodenraketen treffen Gegnergruppen."},
{"name": "PLASMA", "cost": 230, "damage": 48.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "color": Color("e27bff"), "unlock_cost": 18, "description": "Plasmaexplosion trifft Gegner im Umkreis."}, {"name": "PLASMA", "cost": 230, "damage": 34.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "target_class": "both", "both_damage_scale": 0.72, "color": Color("e27bff"), "unlock_cost": 18, "description": "Mehrzweckwaffe mit reduziertem Schaden."},
{"name": "EMP-PULS", "cost": 210, "damage": 34.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118", "color": Color("77e8ff"), "unlock_cost": 24, "description": "Breiter Impuls trifft ganze Gegnergruppen."}, {"name": "EMP-PULS", "cost": 210, "damage": 20.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118 · ALLE", "target_class": "both", "both_damage_scale": 1.0, "color": Color("77e8ff"), "unlock_cost": 24, "description": "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden."},
{"name": "RAILGUN", "cost": 300, "damage": 110.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "SCHILD-DURCHSCHLAG", "color": Color("87efff"), "unlock_cost": 30, "description": "Weitreichender Schuss, ignoriert Schildschutz."} {"name": "RAILGUN", "cost": 300, "damage": 77.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "PIERCING-STRAHL", "target_class": "both", "both_damage_scale": 0.70, "color": Color("c86bff"), "unlock_cost": 30, "description": "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie."}
] ]
const ENEMY_DATA := [ const ENEMY_DATA := [
{"name": "JAEGER", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0}, {"name": "JAEGER", "category": "air", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28},
{"name": "FREGATTE", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0}, {"name": "FREGATTE", "category": "air", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "KREUZER", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0} {"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "ABFANGJAEGER", "category": "air", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "SCHILDKREUZER", "category": "air", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true},
{"name": "BELAGERER", "category": "air", "hp": 740.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28},
{"name": "SPAEHER-ROVER", "category": "ground", "hp": 88.0, "speed": 142.0, "reward": 22, "size": 54.0, "breach_damage": 1, "shield_resistance": 0.28},
{"name": "STURMPANZER", "category": "ground", "hp": 390.0, "speed": 61.0, "reward": 61, "size": 92.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "BELAGERUNGSLAEUFER", "category": "ground", "hp": 980.0, "speed": 36.0, "reward": 132, "size": 124.0, "breach_damage": 5, "shield_resistance": 0.28}
] ]
var path_points := PackedVector2Array() var path_points := PackedVector2Array()
@@ -133,6 +145,11 @@ var next_enemy_id := 1
var wave_active := false var wave_active := false
var enemies_to_spawn := 0 var enemies_to_spawn := 0
var spawn_timer := 0.0 var spawn_timer := 0.0
var wave_type_schedule: Array[Dictionary] = []
var wave_spawn_index := 0
var online_wave_types_remaining: Array[int] = []
var enemy_strength_multiplier := 1.0
var developer_panel_open := false
var status_text := "" var status_text := ""
var status_timer := 0.0 var status_timer := 0.0
var elapsed := 0.0 var elapsed := 0.0
@@ -157,6 +174,13 @@ var wave_button := Rect2(1320, 18, 255, 54)
var pause_button := Rect2(1242, 18, 62, 54) var pause_button := Rect2(1242, 18, 62, 54)
var speed_button := Rect2(1170, 18, 62, 54) var speed_button := Rect2(1170, 18, 62, 54)
var auto_wave_button := Rect2(1320, 73, 255, 22) var auto_wave_button := Rect2(1320, 73, 255, 22)
var wave_current_preview_rect := Rect2(520, 67, 258, 44)
var wave_next_preview_rect := Rect2(786, 67, 258, 44)
var developer_panel_rect := Rect2(1060, 104, 515, 78)
var developer_strength_down_button := Rect2(1074, 140, 42, 30)
var developer_strength_up_button := Rect2(1260, 140, 42, 30)
var developer_crystals_down_button := Rect2(1320, 140, 50, 30)
var developer_crystals_up_button := Rect2(1512, 140, 50, 30)
var back_button := Rect2(1070, 18, 90, 54) var back_button := Rect2(1070, 18, 90, 54)
var tower_buttons := [ var tower_buttons := [
Rect2(15, 768, 255, 122), Rect2(278, 768, 255, 122), Rect2(541, 768, 255, 122), Rect2(15, 768, 255, 122), Rect2(278, 768, 255, 122), Rect2(541, 768, 255, 122),
@@ -167,6 +191,7 @@ var sell_button := Rect2(1430, 187, 145, 44)
var tutorial_skip_button := Rect2(365, 205, 145, 30) var tutorial_skip_button := Rect2(365, 205, 145, 30)
var reward_ad_button := Rect2(1070, 690, 300, 54) var reward_ad_button := Rect2(1070, 690, 300, 54)
var tower_info_rect := Rect2(1275, 103, 300, 76) var tower_info_rect := Rect2(1275, 103, 300, 76)
var target_priority_button := Rect2(1275, 236, 300, 34)
var endless_button := Rect2(545, 515, 510, 70) var endless_button := Rect2(545, 515, 510, 70)
var finish_mission_button := Rect2(625, 610, 350, 62) var finish_mission_button := Rect2(625, 610, 350, 62)
var profile_button := Rect2(1280, 25, 270, 52) var profile_button := Rect2(1280, 25, 270, 52)
@@ -241,6 +266,10 @@ func _ready() -> void:
_select_map(selected_map) _select_map(selected_map)
_load_progress() _load_progress()
_load_audio_settings() _load_audio_settings()
# The Android AudioTrack backend currently crashes on this generated PCM stream
# on the supported emulator/device test target. Keep the game playable there
# without starting native audio until we replace it with a packaged audio asset.
if not OS.has_feature("android"):
music_player.stream = _build_ambient_stream() music_player.stream = _build_ambient_stream()
music_player.play() music_player.play()
ui_sound_player.stream = _build_ui_click_stream() ui_sound_player.stream = _build_ui_click_stream()
@@ -304,17 +333,14 @@ func _update_wave(delta: float) -> void:
func _spawn_enemy() -> void: func _spawn_enemy() -> void:
var type := 0 var schedule_entry: Dictionary = wave_type_schedule[wave_spawn_index] if wave_spawn_index < wave_type_schedule.size() else {}
var is_boss := wave > 0 and wave % BOSS_WAVE_INTERVAL == 0 and enemies_to_spawn <= (2 if coop_mode else 1) var is_boss := bool(schedule_entry.get("boss", wave > 0 and wave % BOSS_WAVE_INTERVAL == 0 and enemies_to_spawn <= (2 if coop_mode else 1)))
var roll := randf() var type := int(schedule_entry.get("type", _enemy_type_for_wave(wave, randf(), is_boss)))
if is_boss: wave_spawn_index += 1
type = 2
elif wave >= 7 and roll > 0.82:
type = 2
elif wave >= 3 and roll > 0.53:
type = 1
var data: Dictionary = ENEMY_DATA[type] var data: Dictionary = ENEMY_DATA[type]
var health_scale := _enemy_health_scale(wave, is_boss) var health_scale := _enemy_health_scale(wave, is_boss) * enemy_strength_multiplier
var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06))
var max_shield_hp: float = data.hp * health_scale * 0.55 if has_shield else 0.0
var route_id := 0 var route_id := 0
var route := path_points var route := path_points
if coop_mode: if coop_mode:
@@ -329,24 +355,124 @@ func _spawn_enemy() -> void:
"route_id": route_id, "route_id": route_id,
"hp": data.hp * health_scale, "hp": data.hp * health_scale,
"max_hp": data.hp * health_scale, "max_hp": data.hp * health_scale,
"base_max_hp": data.hp * _enemy_health_scale(wave, is_boss),
"speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed), "speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed),
"reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)), "reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)),
"angle": 0.0, "angle": 0.0,
"flash": 0.0, "flash": 0.0,
"boss": is_boss, "boss": is_boss,
"size_scale": 1.34 if is_boss else 1.0, "size_scale": 1.34 if is_boss else 1.0,
"shield": wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06) "shield": has_shield,
"shield_hp": max_shield_hp,
"max_shield_hp": max_shield_hp,
"shield_resistance": float(data.shield_resistance)
}) })
if is_boss: if is_boss:
_show_status("WARNUNG: KOMMANDOSCHIFF IM ANFLUG") _show_status("WARNUNG: KOMMANDOSCHIFF IM ANFLUG")
next_enemy_id += 1 next_enemy_id += 1
func _enemy_type_for_wave(for_wave: int, roll: float, is_boss: bool) -> int:
if is_boss:
return 8 if for_wave >= 10 else 5
var advanced_offset := 2 if selected_difficulty == 2 else (1 if selected_difficulty == 1 else 0)
if for_wave >= 10 and roll >= 0.96:
return 8
if for_wave >= 8 and roll >= 0.84 and roll < 0.94:
return 7
if for_wave >= 3 and roll >= 0.52 and roll < 0.64:
return 6
if for_wave >= 10 - advanced_offset and roll >= 0.94:
return 5
if for_wave >= 7 - advanced_offset and roll >= 0.80:
return 4
if for_wave >= 4 - advanced_offset and roll >= 0.65:
return 3
if for_wave >= 7 and roll >= 0.45:
return 2
if for_wave >= 3 and roll >= 0.28:
return 1
return 0
func _wave_population(for_wave: int) -> int:
if for_wave <= 0:
return 0
var population_scale := 1.0 + maxf(0.0, for_wave - 3.0) * 0.08
if selected_difficulty == 2:
population_scale += maxf(0.0, for_wave - 4.0) * 0.025
if for_wave > MAX_WAVES:
population_scale += (for_wave - MAX_WAVES) * 0.035
return ceili((7 + for_wave * 2) * float(_difficulty().count) * population_scale) * (2 if coop_mode else 1)
func _build_wave_schedule(for_wave: int, count: int) -> Array[Dictionary]:
var schedule: Array[Dictionary] = []
var boss_count := mini(2 if coop_mode else 1, count) if for_wave > 0 and for_wave % BOSS_WAVE_INTERVAL == 0 else 0
for index in count:
var is_boss := index >= count - boss_count
var roll := fposmod(float(index + 1) * 0.61803398875 + float(for_wave) * 0.38196601125, 1.0)
var type := _enemy_type_for_wave(for_wave, roll, is_boss)
schedule.append({"type": type, "boss": is_boss})
return schedule
func _enemy_category(enemy_type: int) -> String:
return str(ENEMY_DATA[clampi(enemy_type, 0, ENEMY_DATA.size() - 1)].category)
func _tower_target_class(tower_type: int) -> String:
return str(TOWER_DATA[clampi(tower_type, 0, TOWER_DATA.size() - 1)].get("target_class", "both"))
func _tower_display_order() -> Array[int]:
var ordered_types: Array[int] = []
for target_class in ["air", "ground", "both"]:
for tower_type in TOWER_DATA.size():
if _tower_target_class(tower_type) == target_class:
ordered_types.append(tower_type)
return ordered_types
func _tower_can_target(tower_type: int, enemy_type: int) -> bool:
var target_class := _tower_target_class(tower_type)
return target_class == "both" or target_class == _enemy_category(enemy_type)
func _tower_target_label(tower_type: int) -> String:
match _tower_target_class(tower_type):
"air": return "LUFT"
"ground": return "BODEN"
_: return "BEIDE"
func _set_enemy_strength_multiplier(value: float) -> void:
if coop_online_mode:
_show_status("TESTMULTIPLIKATOR IST IM ONLINE-KOOP DEAKTIVIERT")
return
enemy_strength_multiplier = clampf(roundf(value * 10.0) / 10.0, 0.5, 3.0)
for enemy in enemies:
var health_ratio := clampf(float(enemy.hp) / maxf(1.0, float(enemy.max_hp)), 0.0, 1.0)
var adjusted_max := float(enemy.get("base_max_hp", enemy.max_hp)) * enemy_strength_multiplier
enemy.max_hp = adjusted_max
enemy.hp = adjusted_max * health_ratio
_show_status("TEST: GEGNERSTÄRKE ×%.1f" % enemy_strength_multiplier)
queue_redraw()
func _adjust_core_crystals(amount: int) -> void:
core_crystals = clampi(core_crystals + amount, 0, 999999)
_save_progress()
_show_status("TEST: KERNKRISTALLE %s25 • STAND %d" % ["+" if amount >= 0 else "−", core_crystals])
queue_redraw()
func _enemy_health_scale(for_wave: int, is_boss: bool) -> float: func _enemy_health_scale(for_wave: int, is_boss: bool) -> float:
var early_ramp := maxf(0.0, for_wave - 1.0) * 0.20 var early_ramp := maxf(0.0, for_wave - 1.0) * 0.20
var post_four_ramp := maxf(0.0, for_wave - 3.0) * 0.09 var post_four_ramp := maxf(0.0, for_wave - 3.0) * 0.09
var hard_late_ramp := maxf(0.0, for_wave - 5.0) * 0.055 if selected_difficulty == 2 else 0.0
var endless_ramp := maxf(0.0, for_wave - MAX_WAVES) * 0.035 if endless_mode else 0.0 var endless_ramp := maxf(0.0, for_wave - MAX_WAVES) * 0.035 if endless_mode else 0.0
return (1.0 + early_ramp + post_four_ramp + endless_ramp) * (3.25 if is_boss else 1.0) * float(_difficulty().hp) return (1.0 + early_ramp + post_four_ramp + hard_late_ramp + endless_ramp) * (3.25 if is_boss else 1.0) * float(_difficulty().hp)
func _update_enemies(delta: float) -> void: func _update_enemies(delta: float) -> void:
@@ -362,7 +488,8 @@ func _update_enemies(delta: float) -> void:
enemy.path_index += 1 enemy.path_index += 1
if enemy.path_index >= route.size(): if enemy.path_index >= route.size():
enemies.erase(enemy) enemies.erase(enemy)
reactor_lives -= 1 + enemy.type + (4 if enemy.boss else 0) var enemy_data: Dictionary = ENEMY_DATA[int(enemy.type)]
reactor_lives -= int(enemy_data.breach_damage) + (4 if enemy.boss else 0)
if coop_mode: if coop_mode:
multiplayer_manager.shared_reactor_lives = maxi(0, reactor_lives) multiplayer_manager.shared_reactor_lives = maxi(0, reactor_lives)
_spawn_burst(route[-1], Color("54dfff"), 16) _spawn_burst(route[-1], Color("54dfff"), 16)
@@ -384,24 +511,77 @@ func _update_towers(delta: float) -> void:
func _find_target(tower: Dictionary) -> Dictionary: func _find_target(tower: Dictionary) -> Dictionary:
var best: Dictionary = {} var best: Dictionary = {}
var best_progress := -1.0 var priority := "shield" if int(tower.type) == 1 else str(tower.get("target_priority", "first"))
var best_progress := -INF if priority != "last" else INF
var best_hp := -1.0
var best_is_shielded := false
var effective_range := _tower_range(tower.type, tower.level) var effective_range := _tower_range(tower.type, tower.level)
for enemy in enemies: for enemy in enemies:
if tower.pos.distance_to(enemy.pos) <= effective_range: if _tower_can_target(tower.type, int(enemy.type)) and tower.pos.distance_to(enemy.pos) <= effective_range:
var route: PackedVector2Array = _enemy_route(enemy) var route: PackedVector2Array = _enemy_route(enemy)
var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index]) var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index])
if progress > best_progress: var enemy_hp := float(enemy.hp)
var is_shielded := bool(enemy.get("shield", false))
var is_better := best.is_empty()
match priority:
"last": is_better = is_better or progress < best_progress
"strongest": is_better = is_better or enemy_hp > best_hp
"shield":
is_better = is_better or (is_shielded and not best_is_shielded)
if is_shielded == best_is_shielded and not best.is_empty():
is_better = progress > best_progress
_:
is_better = is_better or progress > best_progress
if is_better:
best_progress = progress best_progress = progress
best_hp = enemy_hp
best_is_shielded = is_shielded
best = enemy best = enemy
return best return best
func _tower_priority_label(priority: String) -> String:
match priority:
"shield": return "SCHILD ZUERST"
"last": return "LETZTE"
"strongest": return "MEISTE HP"
_: return "ERSTE"
func _cycle_selected_tower_priority() -> void:
if selected_tower_index < 0 or selected_tower_index >= towers.size():
return
var tower: Dictionary = towers[selected_tower_index]
if int(tower.type) == 1:
_show_status("LASER VISIERT SCHILDGEGNER IMMER ZUERST AN")
return
var priorities := ["first", "last", "strongest", "shield"]
var current := priorities.find(str(tower.get("target_priority", "first")))
tower.target_priority = priorities[(current + 1) % priorities.size()]
_show_status("TURMZIEL: %s" % _tower_priority_label(str(tower.target_priority)))
queue_redraw()
func _fire_tower(tower: Dictionary, target: Dictionary) -> void: func _fire_tower(tower: Dictionary, target: Dictionary) -> void:
var data: Dictionary = TOWER_DATA[tower.type] var data: Dictionary = TOWER_DATA[tower.type]
var damage := _tower_damage(tower.type, tower.level) var damage := _tower_damage(tower.type, tower.level)
if _tower_target_class(tower.type) == "both":
damage *= float(data.get("both_damage_scale", 0.7))
if tower.type == 1: if tower.type == 1:
_apply_damage(target, damage, int(tower.get("owner", 0)), tower.type) _apply_damage(target, damage, int(tower.get("owner", 0)), tower.type)
beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color}) beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color})
elif tower.type == 5:
var direction: Vector2 = tower.pos.direction_to(target.pos)
var beam_end: Vector2 = tower.pos + direction * _tower_range(tower.type, tower.level)
for enemy in enemies.duplicate():
if not _tower_can_target(tower.type, int(enemy.type)):
continue
var offset: Vector2 = enemy.pos - tower.pos
var along_beam: float = offset.dot(direction)
var across_beam: float = absf(offset.cross(direction))
if along_beam >= 0.0 and along_beam <= _tower_range(tower.type, tower.level) and across_beam <= 18.0:
_apply_damage(enemy, damage, int(tower.get("owner", 0)), tower.type)
beams.append({"from": tower.pos, "to": beam_end, "ttl": 0.14, "color": data.color, "piercing": true})
else: else:
projectiles.append({ projectiles.append({
"pos": tower.pos, "pos": tower.pos,
@@ -425,9 +605,9 @@ func _update_projectiles(delta: float) -> void:
if shot.pos.distance_to(target.pos) < 14.0: if shot.pos.distance_to(target.pos) < 14.0:
if shot.type in [2, 3, 4]: if shot.type in [2, 3, 4]:
var blast_radius := 76.0 if shot.type == 2 else (92.0 if shot.type == 3 else 118.0) var blast_radius := 76.0 if shot.type == 2 else (92.0 if shot.type == 3 else 118.0)
var splash_factor := 0.45 if shot.type == 2 else (0.62 if shot.type == 3 else 0.38) var splash_factor := 0.45 if shot.type == 2 else (0.62 if shot.type == 3 else 1.0)
for enemy in enemies.duplicate(): for enemy in enemies.duplicate():
if enemy.pos.distance_to(target.pos) < blast_radius: if _tower_can_target(int(shot.type), int(enemy.type)) and enemy.pos.distance_to(target.pos) < blast_radius:
_apply_damage(enemy, shot.damage if enemy.id == target.id else shot.damage * splash_factor, int(shot.get("owner", 0)), int(shot.type)) _apply_damage(enemy, shot.damage if enemy.id == target.id else shot.damage * splash_factor, int(shot.get("owner", 0)), int(shot.type))
var blast_color: Color = Color("ff9b4a") if shot.type == 2 else (Color("db65ff") if shot.type == 3 else Color("71eaff")) var blast_color: Color = Color("ff9b4a") if shot.type == 2 else (Color("db65ff") if shot.type == 3 else Color("71eaff"))
_spawn_burst(target.pos, blast_color, 14 if shot.type == 2 else 18) _spawn_burst(target.pos, blast_color, 14 if shot.type == 2 else 18)
@@ -441,8 +621,19 @@ func _apply_damage(enemy: Dictionary, amount: float, owner := 0, weapon_type :=
if not enemies.has(enemy): if not enemies.has(enemy):
return return
var applied_damage := amount var applied_damage := amount
if bool(enemy.get("shield", false)) and weapon_type not in [1, 5]: if bool(enemy.get("shield", false)):
applied_damage *= 0.28 if weapon_type == 1:
var shield_hp := float(enemy.get("shield_hp", enemy.max_hp * 0.55))
var shield_damage := minf(shield_hp, amount)
shield_hp -= shield_damage
enemy.shield_hp = shield_hp
if shield_hp <= 0.0:
enemy.shield = false
applied_damage = amount - shield_damage
else:
applied_damage = 0.0
elif weapon_type != 5:
applied_damage *= float(enemy.get("shield_resistance", 0.28))
enemy.hp -= applied_damage enemy.hp -= applied_damage
enemy.flash = 0.06 enemy.flash = 0.06
if enemy.hp <= 0.0: if enemy.hp <= 0.0:
@@ -485,6 +676,10 @@ func _update_effects(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F9 and game_mode in ["playing", "paused"]:
developer_panel_open = not developer_panel_open
queue_redraw()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]: if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]:
if audio_settings_open: if audio_settings_open:
audio_settings_open = false audio_settings_open = false
@@ -550,6 +745,21 @@ func _unhandled_input(event: InputEvent) -> void:
func _handle_pointer(position: Vector2, is_touch := false) -> void: func _handle_pointer(position: Vector2, is_touch := false) -> void:
_play_ui_sound() _play_ui_sound()
if developer_panel_open and game_mode in ["playing", "paused"]:
if developer_strength_down_button.has_point(position):
_set_enemy_strength_multiplier(enemy_strength_multiplier - 0.1)
return
if developer_strength_up_button.has_point(position):
_set_enemy_strength_multiplier(enemy_strength_multiplier + 0.1)
return
if developer_crystals_down_button.has_point(position):
_adjust_core_crystals(-25)
return
if developer_crystals_up_button.has_point(position):
_adjust_core_crystals(25)
return
if developer_panel_rect.has_point(position):
return
if audio_settings_open: if audio_settings_open:
if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position): if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position):
audio_settings_open = false audio_settings_open = false
@@ -789,9 +999,10 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
if wave_button.has_point(position): if wave_button.has_point(position):
_start_wave() _start_wave()
return return
var tower_display_order := _tower_display_order()
for index in tower_buttons.size(): for index in tower_buttons.size():
if tower_buttons[index].has_point(position): if tower_buttons[index].has_point(position):
var tower_type := index var tower_type: int = tower_display_order[index]
if not unlocked_towers.has(tower_type): if not unlocked_towers.has(tower_type):
store_return_mode = game_mode store_return_mode = game_mode
game_mode = "store" game_mode = "store"
@@ -804,6 +1015,12 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
tutorial_step = 1 tutorial_step = 1
return return
if selected_tower_index >= 0: if selected_tower_index >= 0:
if target_priority_button.has_point(position):
if coop_online_mode:
_show_status("ZIELPRIORITÄT IST IM ONLINE-KOOP NOCH NICHT EINSTELLBAR")
else:
_cycle_selected_tower_priority()
return
if upgrade_button.has_point(position): if upgrade_button.has_point(position):
_upgrade_selected() _upgrade_selected()
return return
@@ -1141,10 +1358,9 @@ func _start_wave() -> void:
_show_status("WELLENSTART AN SERVER GESENDET") _show_status("WELLENSTART AN SERVER GESENDET")
return return
wave += 1 wave += 1
var wave_population_scale := 1.0 + maxf(0.0, wave - 3.0) * 0.08 enemies_to_spawn = _wave_population(wave)
if endless_mode: wave_spawn_index = 0
wave_population_scale += maxf(0.0, wave - MAX_WAVES) * 0.035 wave_type_schedule = _build_wave_schedule(wave, enemies_to_spawn)
enemies_to_spawn = ceili((7 + wave * 2) * float(_difficulty().count) * wave_population_scale) * (2 if coop_mode else 1)
spawn_timer = 0.15 spawn_timer = 0.15
wave_active = true wave_active = true
if coop_ai_mode: if coop_ai_mode:
@@ -1314,7 +1530,7 @@ func _place_tower(position: Vector2) -> void:
return return
_spend_credits(0, int(data.cost)) _spend_credits(0, int(data.cost))
towers_built_this_run += 1 towers_built_this_run += 1
towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0}) towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0, "target_priority": "shield" if selected_tower_type == 1 else "first"})
selected_tower_index = towers.size() - 1 selected_tower_index = towers.size() - 1
selected_tower_type = -1 selected_tower_type = -1
pending_touch_build_position = Vector2(-1, -1) pending_touch_build_position = Vector2(-1, -1)
@@ -1586,6 +1802,9 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
wave = int(snapshot.get("wave", wave)) wave = int(snapshot.get("wave", wave))
wave_active = bool(snapshot.get("wave_active", false)) wave_active = bool(snapshot.get("wave_active", false))
enemies_to_spawn = int(snapshot.get("enemies_to_spawn", 0)) enemies_to_spawn = int(snapshot.get("enemies_to_spawn", 0))
online_wave_types_remaining.clear()
for enemy_type in snapshot.get("wave_types_remaining", []):
online_wave_types_remaining.append(int(enemy_type))
credits = _player_credits(local_player_slot) credits = _player_credits(local_player_slot)
var previous_enemy_positions: Dictionary = {} var previous_enemy_positions: Dictionary = {}
@@ -1871,6 +2090,11 @@ func _draw_world() -> void:
draw_circle(shot.pos, 6.0 if shot.type == 2 else 3.5, shot.color) draw_circle(shot.pos, 6.0 if shot.type == 2 else 3.5, shot.color)
draw_circle(shot.pos, 12.0 if shot.type == 2 else 7.0, Color(shot.color, 0.20)) draw_circle(shot.pos, 12.0 if shot.type == 2 else 7.0, Color(shot.color, 0.20))
for beam in beams: for beam in beams:
if bool(beam.get("piercing", false)):
draw_line(beam.from, beam.to, Color("c86bff", 0.18), 18.0)
draw_line(beam.from, beam.to, Color("c86bff", 0.9), 8.0)
draw_line(beam.from, beam.to, Color("f3ddff", 1.0), 2.0)
else:
draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0) draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0)
draw_line(beam.from, beam.to, Color.WHITE, 1.0) draw_line(beam.from, beam.to, Color.WHITE, 1.0)
for particle in particles: for particle in particles:
@@ -2019,15 +2243,20 @@ func _draw_tower(tower: Dictionary) -> void:
func _draw_enemy(enemy: Dictionary) -> void: func _draw_enemy(enemy: Dictionary) -> void:
var source_width: float = ENEMY_TEXTURE.get_width() / 3.0 var enemy_type := int(enemy.type)
var source := Rect2(source_width * enemy.type, 0, source_width, ENEMY_TEXTURE.get_height()) var destination: Rect2
var size: float = ENEMY_DATA[enemy.type].size * enemy.size_scale var size: float = ENEMY_DATA[enemy.type].size * enemy.size_scale
var enemy_texture := _enemy_texture(enemy_type)
var source := _enemy_source_rect(enemy_type)
destination = Rect2(-size * 0.75, -size * 0.5, size * 1.5, size)
var tint := Color.WHITE if enemy.flash <= 0.0 else Color("ffb6a3") var tint := Color.WHITE if enemy.flash <= 0.0 else Color("ffb6a3")
if bool(enemy.get("shield", false)): if bool(enemy.get("shield", false)):
draw_circle(enemy.pos, size * 0.63, Color(0.25, 0.80, 1.0, 0.12)) var shield_ratio := clampf(float(enemy.get("shield_hp", 1.0)) / maxf(1.0, float(enemy.get("max_shield_hp", 1.0))), 0.0, 1.0)
draw_arc(enemy.pos, size * 0.63, 0.0, TAU, 28, Color(0.38, 0.86, 1.0, 0.76), 2.2) draw_circle(enemy.pos, size * 0.63, Color(0.25, 0.80, 1.0, 0.08))
draw_arc(enemy.pos, size * 0.63, 0.0, TAU, 40, Color(0.28, 0.66, 0.82, 0.28), 2.0)
draw_arc(enemy.pos, size * 0.63, -PI * 0.5, -PI * 0.5 - TAU * shield_ratio, 48, Color(0.38, 0.86, 1.0, 0.92), 3.2)
draw_set_transform(enemy.pos - camera_offset, enemy.angle, Vector2.ONE) draw_set_transform(enemy.pos - camera_offset, enemy.angle, Vector2.ONE)
draw_texture_rect_region(ENEMY_TEXTURE, Rect2(-size * 0.5, -size * 0.5, size, size), source, tint) draw_texture_rect_region(enemy_texture, destination, source, tint)
draw_set_transform(-camera_offset, 0.0, Vector2.ONE) draw_set_transform(-camera_offset, 0.0, Vector2.ONE)
var bar_width := size * 0.72 var bar_width := size * 0.72
var ratio: float = clampf(enemy.hp / enemy.max_hp, 0.0, 1.0) var ratio: float = clampf(enemy.hp / enemy.max_hp, 0.0, 1.0)
@@ -2039,6 +2268,22 @@ func _draw_enemy(enemy: Dictionary) -> void:
draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-45, -size * 0.58), "KOMMANDOSCHIFF", HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78")) draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-45, -size * 0.58), "KOMMANDOSCHIFF", HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78"))
func _enemy_texture(enemy_type: int) -> Texture2D:
if enemy_type < 3:
return ENEMY_TEXTURE
if enemy_type < 6:
return [ENEMY_INTERCEPTOR_TEXTURE, ENEMY_SHIELD_CARRIER_TEXTURE, ENEMY_SIEGE_DESTROYER_TEXTURE][enemy_type - 3]
return [ENEMY_GROUND_ROVER_TEXTURE, ENEMY_GROUND_TANK_TEXTURE, ENEMY_GROUND_WALKER_TEXTURE][enemy_type - 6]
func _enemy_source_rect(enemy_type: int) -> Rect2:
var texture := _enemy_texture(enemy_type)
if enemy_type < 3:
var source_width := texture.get_width() / 3.0
return Rect2(source_width * enemy_type, 0.0, source_width, texture.get_height())
return Rect2(Vector2.ZERO, texture.get_size())
func _draw_hud() -> void: func _draw_hud() -> void:
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
draw_rect(Rect2(0, 0, 1600, 90), Color(0.015, 0.035, 0.07, 0.92)) draw_rect(Rect2(0, 0, 1600, 90), Color(0.015, 0.035, 0.07, 0.92))
@@ -2066,15 +2311,27 @@ func _draw_hud() -> void:
_draw_button(reward_ad_button, "VIDEO: +150 NOTFALLENERGIE", false, Color("285044")) _draw_button(reward_ad_button, "VIDEO: +150 NOTFALLENERGIE", false, Color("285044"))
draw_rect(Rect2(0, 760, 1600, 140), Color(0.012, 0.028, 0.055, 0.97)) draw_rect(Rect2(0, 760, 1600, 140), Color(0.012, 0.028, 0.055, 0.97))
draw_line(Vector2(0, 760), Vector2(1600, 760), Color("2e7593"), 2.0) draw_line(Vector2(0, 760), Vector2(1600, 760), Color("2e7593"), 2.0)
var tower_display_order := _tower_display_order()
for index in tower_buttons.size(): for index in tower_buttons.size():
_draw_tower_card(index, index, selected_tower_type == index) var tower_type: int = tower_display_order[index]
_draw_tower_card(index, tower_type, selected_tower_type == tower_type)
if selected_tower_index >= 0 and selected_tower_index < towers.size(): if selected_tower_index >= 0 and selected_tower_index < towers.size():
var tower: Dictionary = towers[selected_tower_index] var tower: Dictionary = towers[selected_tower_index]
var upgrade_label := "MAX. STUFE" if tower.level >= 3 else "UPGRADE %d" % _upgrade_cost(tower) var upgrade_label := "MAX. STUFE" if tower.level >= 3 else "UPGRADE %d" % _upgrade_cost(tower)
_draw_button(upgrade_button, upgrade_label, false) _draw_button(upgrade_button, upgrade_label, false)
_draw_button(sell_button, "VERKAUFEN", false, Color("6b3542")) _draw_button(sell_button, "VERKAUFEN", false, Color("6b3542"))
if not coop_online_mode:
var priority_label := "ZIEL: %s" % _tower_priority_label(str(tower.get("target_priority", "first")))
if int(tower.type) == 1:
priority_label = "ZIEL: SCHILD ZUERST (LASER FIX)"
_draw_button(target_priority_button, priority_label, int(tower.type) == 1, Color("26394a"))
if selected_tower_type >= 0 or selected_tower_index >= 0: if selected_tower_type >= 0 or selected_tower_index >= 0:
_draw_tower_info_panel() _draw_tower_info_panel()
_draw_wave_preview_panel(wave_current_preview_rect, "JETZT", wave)
var next_wave := wave + 1
if wave >= MAX_WAVES and not endless_mode:
next_wave = 0
_draw_wave_preview_panel(wave_next_preview_rect, "DANN", next_wave)
draw_rect(auto_wave_button, Color("102d40"), true) draw_rect(auto_wave_button, Color("102d40"), true)
draw_rect(auto_wave_button, Color("37677b"), false, 1.0) draw_rect(auto_wave_button, Color("37677b"), false, 1.0)
draw_rect(Rect2(auto_wave_button.position + Vector2(7, 4), Vector2(14, 14)), Color("07131f"), true) draw_rect(Rect2(auto_wave_button.position + Vector2(7, 4), Vector2(14, 14)), Color("07131f"), true)
@@ -2087,11 +2344,99 @@ func _draw_hud() -> void:
draw_rect(option_rect, Color("102d40"), true) draw_rect(option_rect, Color("102d40"), true)
draw_rect(option_rect, Color("37677b"), false, 1.0) draw_rect(option_rect, Color("37677b"), false, 1.0)
draw_string(font, option_rect.position + Vector2(0, 18), "%.0f×" % SPEED_OPTIONS[speed_index], HORIZONTAL_ALIGNMENT_CENTER, option_rect.size.x, 13, Color("8ff4ff") if is_equal_approx(game_speed, SPEED_OPTIONS[speed_index]) else Color.WHITE) draw_string(font, option_rect.position + Vector2(0, 18), "%.0f×" % SPEED_OPTIONS[speed_index], HORIZONTAL_ALIGNMENT_CENTER, option_rect.size.x, 13, Color("8ff4ff") if is_equal_approx(game_speed, SPEED_OPTIONS[speed_index]) else Color.WHITE)
if developer_panel_open:
draw_rect(developer_panel_rect, Color(0.008, 0.024, 0.045, 0.96), true)
draw_rect(developer_panel_rect, Color("ffad62"), false, 1.5)
draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 490, 11, Color("ffc277"))
_draw_button(developer_strength_down_button, "−", false, Color("44333a"))
draw_string(font, developer_panel_rect.position + Vector2(58, 57), "GEGNER ×%.1f" % enemy_strength_multiplier, HORIZONTAL_ALIGNMENT_CENTER, 128, 13, Color.WHITE)
_draw_button(developer_strength_up_button, "+", false, Color("44333a"))
_draw_button(developer_crystals_down_button, "−25", false, Color("44333a"))
draw_string(font, developer_panel_rect.position + Vector2(310, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff"))
_draw_button(developer_crystals_up_button, "+25", false, Color("44333a"))
if status_timer > 0.0: if status_timer > 0.0:
draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true) draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true)
draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff")) draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff"))
func _draw_wave_preview_panel(rect: Rect2, heading: String, for_wave: int) -> void:
var font := ThemeDB.fallback_font
draw_rect(rect, Color(0.008, 0.028, 0.052, 0.90), true)
draw_rect(rect, Color(0.22, 0.54, 0.66, 0.68), false, 1.0)
var summary := _wave_preview_summary(for_wave)
var title := "%s --" % heading if for_wave <= 0 else "%s %02d" % [heading, for_wave]
draw_string(font, rect.position + Vector2(8, 12), title, HORIZONTAL_ALIGNMENT_LEFT, 82, 9, Color("96d8e8"))
draw_string(font, rect.position + Vector2(99, 12), "%d SCHIFFE" % int(summary.total), HORIZONTAL_ALIGNMENT_RIGHT, 150, 9, Color("dceaf0"))
var type_counts: Dictionary = summary.types
var type_order: Array = summary.get("type_order", [])
for category_info in [{"name": "air", "x": 7.0}, {"name": "ground", "x": 133.0}]:
var category: String = category_info.name
var category_x: float = float(category_info.x)
_draw_wave_category_icon(Vector2(rect.position.x + category_x + 7.0, rect.position.y + 32.0), category, Color("78f29a"))
var drawn := 0
for type in type_order:
if _enemy_category(type) != category:
continue
if drawn >= 3:
break
var icon_rect := Rect2(rect.position.x + category_x + 17.0 + drawn * 35.0, rect.position.y + 19.0, 15.0, 13.0)
_draw_enemy_miniature(type, icon_rect)
draw_string(font, icon_rect.position + Vector2(16, 12), "×%d" % int(type_counts[type]), HORIZONTAL_ALIGNMENT_LEFT, 19, 8, Color("dceaf0"))
drawn += 1
func _wave_preview_summary(for_wave: int) -> Dictionary:
var summary := {"total": 0, "air": 0, "ground": 0, "types": {}, "type_order": []}
for enemy_type in _wave_preview_types(for_wave):
var type := int(enemy_type)
var category := _enemy_category(type)
if not summary.types.has(type):
summary.types[type] = 0
summary.type_order.append(type)
summary.types[type] = int(summary.types[type]) + 1
summary[category] = int(summary[category]) + 1
summary.total = int(summary.total) + 1
return summary
func _wave_preview_types(for_wave: int) -> Array[int]:
var result: Array[int] = []
if for_wave <= 0:
return result
if coop_online_mode and for_wave == wave:
for enemy in enemies:
result.append(int(enemy.type))
result.append_array(online_wave_types_remaining)
return result
if for_wave == wave and not wave_type_schedule.is_empty():
for entry in wave_type_schedule:
result.append(int(entry.type))
return result
for entry in _build_wave_schedule(for_wave, _wave_population(for_wave)):
result.append(int(entry.type))
return result
func _draw_wave_category_icon(center: Vector2, category: String, color: Color) -> void:
var direction := 1.0 if category == "air" else -1.0
var arrow := PackedVector2Array([
center + Vector2(0, -7.0 * direction),
center + Vector2(-5, -1.0 * direction),
center + Vector2(-2, -1.0 * direction),
center + Vector2(-2, 5.0 * direction),
center + Vector2(2, 5.0 * direction),
center + Vector2(2, -1.0 * direction),
center + Vector2(5, -1.0 * direction)
])
draw_colored_polygon(arrow, color)
func _draw_enemy_miniature(enemy_type: int, rect: Rect2) -> void:
draw_rect(rect.grow(1.0), Color(0.008, 0.018, 0.03, 0.88), true)
draw_texture_rect_region(_enemy_texture(enemy_type), rect, _enemy_source_rect(enemy_type), Color.WHITE)
draw_rect(rect.grow(1.0), Color("4e8194", 0.75), false, 1.0)
func _speed_option_rect(index: int) -> Rect2: func _speed_option_rect(index: int) -> Rect2:
return Rect2(speed_button.position.x, speed_button.end.y + 2.0 + index * 28.0, speed_button.size.x, 27.0) return Rect2(speed_button.position.x, speed_button.end.y + 2.0 + index * 28.0, speed_button.size.x, 27.0)
@@ -2101,16 +2446,29 @@ func _draw_tower_card(slot: int, type: int, active: bool) -> void:
var data: Dictionary = TOWER_DATA[type] var data: Dictionary = TOWER_DATA[type]
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
var unlocked := unlocked_towers.has(type) var unlocked := unlocked_towers.has(type)
draw_rect(rect, Color("226c88") if active else (Color("173c55") if unlocked else Color("102b3b")), true) var class_color := _tower_class_color(type)
draw_rect(rect, Color("65dfff") if active else (Color("37677b") if unlocked else Color("29485a")), false, 1.5) var panel_color := class_color.lightened(0.28) if active else (class_color.darkened(0.42) if unlocked else class_color.darkened(0.62))
draw_rect(rect, panel_color, true)
draw_rect(rect, class_color.lightened(0.30) if active else class_color.darkened(0.18), false, 1.5)
draw_rect(Rect2(rect.position, Vector2(5, rect.size.y)), class_color.lightened(0.25) if unlocked else class_color.darkened(0.15), true)
draw_string(font, rect.position + Vector2(9, 22), data.name, HORIZONTAL_ALIGNMENT_LEFT, 150, 15, Color.WHITE if unlocked else Color("9ab0ba")) draw_string(font, rect.position + Vector2(9, 22), data.name, HORIZONTAL_ALIGNMENT_LEFT, 150, 15, Color.WHITE if unlocked else Color("9ab0ba"))
draw_string(font, rect.position + Vector2(158, 22), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 86, 12, Color("7ce7ff")) draw_string(font, rect.position + Vector2(158, 22), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 86, 12, Color("7ce7ff"))
draw_string(font, rect.position + Vector2(9, 47), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 237, 11, Color("dceaf0")) draw_string(font, rect.position + Vector2(9, 47), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 237, 11, Color("dceaf0"))
draw_string(font, rect.position + Vector2(9, 72), "RW %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("9fdce9")) draw_string(font, rect.position + Vector2(9, 72), "RW %.0f • %s" % [data.range, _tower_target_label(type)], HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("9fdce9"))
var hint := "ANTIPPEN • BAUPLATZ WÄHLEN" if unlocked else "GESPERRT • IM SHOP FREISCHALTEN" var hint := "ANTIPPEN • BAUPLATZ WÄHLEN" if unlocked else "GESPERRT • IM SHOP FREISCHALTEN"
draw_string(font, rect.position + Vector2(9, 105), hint, HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("719aaa") if unlocked else Color("ffcf7a")) draw_string(font, rect.position + Vector2(9, 105), hint, HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("719aaa") if unlocked else Color("ffcf7a"))
func _tower_class_color(tower_type: int) -> Color:
match _tower_target_class(tower_type):
"air":
return Color("bb5260") # Rot: Luft
"ground":
return Color("668c65") # Grün: Boden
_:
return Color("347b98") # Blau-Cyan: Luft + Boden
func _draw_store() -> void: func _draw_store() -> void:
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true)
@@ -2146,7 +2504,7 @@ func _draw_store() -> void:
draw_texture_rect(textures[slot], Rect2(card.position + Vector2(112, 18), Vector2(136, 136)), false) draw_texture_rect(textures[slot], Rect2(card.position + Vector2(112, 18), Vector2(136, 136)), false)
draw_string(font, card.position + Vector2(20, 190), data.name, HORIZONTAL_ALIGNMENT_CENTER, 320, 23, Color(data.color)) draw_string(font, card.position + Vector2(20, 190), data.name, HORIZONTAL_ALIGNMENT_CENTER, 320, 23, Color(data.color))
draw_string(font, card.position + Vector2(22, 224), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(tower_type, 1)], HORIZONTAL_ALIGNMENT_CENTER, 316, 15, Color.WHITE) draw_string(font, card.position + Vector2(22, 224), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(tower_type, 1)], HORIZONTAL_ALIGNMENT_CENTER, 316, 15, Color.WHITE)
draw_string(font, card.position + Vector2(22, 251), "RW %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9")) draw_string(font, card.position + Vector2(22, 251), "RW %.0f • %s" % [data.range, _tower_target_label(tower_type)], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9"))
draw_string(font, card.position + Vector2(22, 295), data.description, HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("c4d7df")) draw_string(font, card.position + Vector2(22, 295), data.description, HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("c4d7df"))
var is_unlocked := unlocked_towers.has(tower_type) var is_unlocked := unlocked_towers.has(tower_type)
var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(data.unlock_cost) var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(data.unlock_cost)
@@ -2177,7 +2535,7 @@ func _draw_tower_info_panel() -> void:
elif is_built: elif is_built:
draw_string(font, tower_info_rect.position + Vector2(10, 62), "MAXIMALE AUSBAUSTUFE", HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca")) draw_string(font, tower_info_rect.position + Vector2(10, 62), "MAXIMALE AUSBAUSTUFE", HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
else: else:
draw_string(font, tower_info_rect.position + Vector2(10, 62), str(data.damage_type), HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca")) draw_string(font, tower_info_rect.position + Vector2(10, 62), "%s • %s" % [_tower_target_label(type), str(data.damage_type)], HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55")) -> void: func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55")) -> void:
+20 -1
View File
@@ -2,11 +2,30 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$GodotExe, [string]$GodotExe,
[Parameter(Mandatory = $true)]
[string]$MakeNsisExe [string]$MakeNsisExe
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$nsisCandidates = @()
if (-not [string]::IsNullOrWhiteSpace($MakeNsisExe)) {
$nsisCandidates += $MakeNsisExe
} else {
$pathNsis = Get-Command "makensis.exe" -ErrorAction SilentlyContinue
if ($pathNsis) {
$nsisCandidates += $pathNsis.Source
}
$nsisCandidates += @(
(Join-Path $env:LOCALAPPDATA "Programs\NSIS-3.13\makensis.exe"),
"C:\Program Files (x86)\NSIS\makensis.exe",
"C:\Program Files\NSIS\makensis.exe"
)
$MakeNsisExe = $nsisCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
}
if ([string]::IsNullOrWhiteSpace($MakeNsisExe)) {
throw "NSIS wurde nicht gefunden. Installiere NSIS oder übergib -MakeNsisExe mit dem Pfad zu makensis.exe."
}
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe" $gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe"
$installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe" $installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe"
+2 -2
View File
@@ -6,7 +6,7 @@ RequestExecutionLevel user
!include "LogicLib.nsh" !include "LogicLib.nsh"
!define PRODUCT_NAME "Void Bastion" !define PRODUCT_NAME "Void Bastion"
!define PRODUCT_VERSION "0.1.0-alpha.4" !define PRODUCT_VERSION "0.1.0-alpha.7"
!define PRODUCT_PUBLISHER "GerGames" !define PRODUCT_PUBLISHER "GerGames"
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/" !define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
!define PRODUCT_EXE "Void-Bastion.exe" !define PRODUCT_EXE "Void-Bastion.exe"
@@ -21,7 +21,7 @@ BrandingText "GerGames"
ShowInstDetails show ShowInstDetails show
ShowUninstDetails show ShowUninstDetails show
VIProductVersion "0.1.0.4" VIProductVersion "0.1.0.7"
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}" VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}" VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup" VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
+1 -2
View File
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="Void Bastion" config/name="Void Bastion"
config/version="0.1.0-alpha.4" config/version="0.1.0-alpha.7"
run/main_scene="res://main.tscn" run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7") config/features=PackedStringArray("4.7")
config/icon="res://assets/app_icon.png" config/icon="res://assets/app_icon.png"
@@ -43,5 +43,4 @@ enabled=PackedStringArray("res://addons/agent_tools/plugin.cfg", "res://addons/A
[rendering] [rendering]
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
textures/vram_compression/import_etc2_astc=true textures/vram_compression/import_etc2_astc=true
+88 -22
View File
@@ -17,14 +17,24 @@ var RIGHT_PATH = [];
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]); for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
var PATHS = [LEFT_PATH, RIGHT_PATH]; var PATHS = [LEFT_PATH, RIGHT_PATH];
var TOWERS = [ var TOWERS = [
{cost:120,damage:22,range:190,cooldown:0.55}, {cost:120,damage:22,range:190,cooldown:0.55,targetClass:"ground"},
{cost:180,damage:12,range:165,cooldown:0.18}, {cost:180,damage:12,range:165,cooldown:0.18,targetClass:"both",bothScale:0.82},
{cost:250,damage:65,range:260,cooldown:1.40}, {cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
{cost:230,damage:48,range:240,cooldown:1.05}, {cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
{cost:210,damage:34,range:215,cooldown:0.92}, {cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
{cost:300,damage:110,range:330,cooldown:1.85} {cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}
];
var ENEMIES = [
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
{category:"air",hp:185,speed:72,reward:34,breachDamage:2},
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
{category:"air",hp:740,speed:48,reward:108,breachDamage:4},
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
]; ];
var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}];
var DIFFICULTIES = [ var DIFFICULTIES = [
{hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450}, {hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450},
{hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430}, {hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430},
@@ -231,7 +241,7 @@ function matchmakerMatched(ctx, logger, nk, matches) {
function initialState(params) { function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1)))); var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits; var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0}; return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
} }
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); } function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; } function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
@@ -247,7 +257,7 @@ function snapshot(state,tick) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{}; var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined}; lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
} }
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,towers:state.towers,enemies:state.enemies}; return {version:GAME_VERSION, tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:state.towers,enemies:state.enemies};
} }
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; } function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); } function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
@@ -310,16 +320,58 @@ function handleSell(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false; var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true; var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
} }
function enemyTypeForWave(wave,roll,difficulty,boss) {
if (boss) return wave>=10?8:5;
var offset=difficulty===2?2:(difficulty===1?1:0);
if (wave>=10&&roll>=0.96) return 8;
if (wave>=8&&roll>=0.84&&roll<0.94) return 7;
if (wave>=3&&roll>=0.52&&roll<0.64) return 6;
if (wave>=10-offset&&roll>=0.94) return 5;
if (wave>=7-offset&&roll>=0.80) return 4;
if (wave>=4-offset&&roll>=0.65) return 3;
if (wave>=7&&roll>=0.45) return 2;
if (wave>=3&&roll>=0.28) return 1;
return 0;
}
function wavePopulation(state,wave) {
var scale=1+Math.max(0,wave-3)*0.08;
if (state.difficulty===2) scale+=Math.max(0,wave-4)*0.025;
return Math.ceil((7+wave*2)*DIFFICULTIES[state.difficulty].count*scale)*2;
}
function buildWaveSchedule(state,wave,count) {
var schedule=[],bossCount=wave>0&&wave%5===0?2:0;
for (var i=0;i<count;i++) {
var boss=i>=count-bossCount;
var roll=((i+1)*0.61803398875+wave*0.38196601125)%1;
schedule.push({type:enemyTypeForWave(wave,roll,state.difficulty,boss),boss:boss});
}
return schedule;
}
function startWave(state) { function startWave(state) {
if (state.waveActive||state.gameOver||!state.started) return false; if (state.waveActive||state.gameOver||!state.started) return false;
state.wave+=1; state.spawnRemaining=Math.ceil((7+state.wave*2)*DIFFICULTIES[state.difficulty].count)*2; state.spawnTimer=0.1; state.waveActive=true; return true; state.wave+=1; state.waveSchedule=buildWaveSchedule(state,state.wave,wavePopulation(state,state.wave)); state.spawnRemaining=state.waveSchedule.length; state.spawnTimer=0.1; state.waveActive=true; return true;
} }
function spawnEnemy(state) { function spawnEnemy(state) {
var boss=state.wave>0&&state.wave%5===0&&state.spawnRemaining<=2,roll=Math.random(); var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
var type=boss?2:(state.wave>=7&&roll>0.82?2:(state.wave>=3&&roll>0.53?1:0)); var boss=entry.boss,type=entry.type;
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty],scale=(1+Math.max(0,state.wave-1)*0.115)*(boss?3.25:1)*d.hp; var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty];
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId]; var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss}); var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
}
function applyDamageToEnemy(enemy,damage,towerType) {
var applied=damage;
if (enemy.shield) {
if (towerType===1) {
var shieldDamage=Math.min(enemy.shield_hp,damage);
enemy.shield_hp-=shieldDamage;
if (enemy.shield_hp<=0) { enemy.shield=false; applied=damage-shieldDamage; }
else applied=0;
} else if (towerType!==5) applied*=enemy.shield_resistance||0.28;
}
enemy.hp-=applied;
} }
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); } function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
function updateEnemies(state,delta) { function updateEnemies(state,delta) {
@@ -328,24 +380,38 @@ function updateEnemies(state,delta) {
e.angle=Math.atan2(dy,dx); e.angle=Math.atan2(dy,dx);
if (dist<=Math.max(9,e.speed*delta)) { if (dist<=Math.max(9,e.speed*delta)) {
e.x=target[0]; e.y=target[1]; e.path_index+=1; e.x=target[0]; e.y=target[1]; e.path_index+=1;
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(1+e.type+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } } if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(ENEMIES[e.type].breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; } } else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
} }
} }
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); } function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
function updateTowers(state,delta) { function updateTowers(state,delta) {
for (var i=0;i<state.towers.length;i++) { for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1; var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) if (distanceSquared(t.x,t.y,state.enemies[j].x,state.enemies[j].y)<=range*range) { var p=enemyProgress(state.enemies[j]); if (p>best) { best=p; targetIndex=j; } } for (var j=0;j<state.enemies.length;j++) {
var candidate=state.enemies[j];
if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[candidate.type].category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
}
}
if (targetIndex<0) continue; if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0); var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue; if (t.cooldown>0) continue;
var damage=towerDamage(t); var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
if (t.type===2||t.type===3||t.type===4) { if (t.type===5) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:0.38); var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
var railTarget=state.enemies[railIndex];
if (distanceSquared(t.x,t.y,railTarget.x,railTarget.y)>rangeSquared) continue;
var offsetX=railTarget.x-t.x,offsetY=railTarget.y-t.y,along=offsetX*dx+offsetY*dy,across=Math.abs(offsetX*dy-offsetY*dx);
if (along>=0&&along<=range&&across<=18) { applyDamageToEnemy(railTarget,damage,t.type); if (railTarget.hp<=0) removeEnemy(state,railIndex,t.owner); }
}
} else if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
var tx=target.x,ty=target.y,targetId=target.id; var tx=target.x,ty=target.y,targetId=target.id;
for (var k=state.enemies.length-1;k>=0;k--) if (distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*splash; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); } for (var k=state.enemies.length-1;k>=0;k--) if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[state.enemies[k].type].category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); } } else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
t.cooldown=towerCooldown(t); t.cooldown=towerCooldown(t);
} }
} }
+59
View File
@@ -8,6 +8,62 @@ const context = { console, Math, Date, JSON, isFinite };
vm.createContext(context); vm.createContext(context);
vm.runInContext(source, context); vm.runInContext(source, context);
assert.strictEqual(context.ENEMIES.length, 9);
assert.strictEqual(context.ENEMIES[0].category, "air");
assert.strictEqual(context.ENEMIES[6].category, "ground");
assert.strictEqual(context.TOWERS[0].targetClass, "ground");
assert.strictEqual(context.TOWERS[1].targetClass, "both");
assert.ok(context.TOWERS[1].bothScale < 1, "The all-purpose laser should trade damage for shield-breaking ground coverage.");
assert.strictEqual(context.TOWERS[3].targetClass, "both");
assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage for flexibility.");
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
assert.strictEqual(context.TOWERS[4].targetClass, "both", "EMP should cover both air and ground units.");
const shieldTest = { hp: 100, shield: true, shield_hp: 30, max_shield_hp: 30, shield_resistance: 0.28 };
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.hp, 100, "Laser damage should drain shield before hull.");
assert.strictEqual(shieldTest.shield_hp, 10);
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.shield, false);
assert.strictEqual(shieldTest.hp, 90, "Laser damage beyond shield capacity should spill into hull.");
const priorityState = {
towers: [{ id: 1, type: 1, x: 800, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 201, type: 6, x: 850, y: 120, path_index: 1, hp: 100, shield: true, shield_hp: 50, max_shield_hp: 50, shield_resistance: 0.28 },
{ id: 202, type: 0, x: 950, y: 120, path_index: 1, hp: 100, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(priorityState, 1);
assert.ok(priorityState.enemies[0].shield_hp < 50, "Online lasers should drain a shield before firing at unshielded units.");
assert.strictEqual(priorityState.enemies[1].hp, 100);
const railgunState = {
towers: [{ id: 10, type: 5, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 301, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 302, type: 6, x: 500, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 303, type: 0, x: 450, y: 150, path_index: 1, hp: 1000, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(railgunState, 0.01);
assert.ok(railgunState.enemies[0].hp < 1000 && railgunState.enemies[1].hp < 1000, "Online railgun should pierce enemies behind its first hit on the same line.");
assert.strictEqual(railgunState.enemies[2].hp, 1000, "Online railgun should not hit units away from its beam line.");
const empState = {
towers: [{ id: 11, type: 4, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 401, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 402, type: 6, x: 450, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 403, type: 6, x: 570, y: 120, path_index: 1, hp: 1000, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(empState, 0.01);
assert.strictEqual(empState.enemies[0].hp, 980, "EMP should deal 20 damage to its primary target.");
assert.strictEqual(empState.enemies[1].hp, 980, "EMP should deal its reduced 20 damage to nearby ground units too.");
assert.strictEqual(empState.enemies[2].hp, 1000, "EMP should not damage units outside its area radius.");
const sent = []; const sent = [];
const dispatcher = { const dispatcher = {
broadcastMessage(opCode, data) { broadcastMessage(opCode, data) {
@@ -132,6 +188,9 @@ assert.strictEqual(state.credits[1], 250);
state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state; state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
assert.strictEqual(state.wave, 1); assert.strictEqual(state.wave, 1);
assert.strictEqual(state.waveActive, true); assert.strictEqual(state.waveActive, true);
assert.strictEqual(context.snapshot(state, 6).wave_types_remaining.length, state.spawnRemaining);
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state; for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0); assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true)); assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
+86 -10
View File
@@ -29,6 +29,12 @@ func _run() -> void:
var ads_manager = game.get_node("AdsManager") var ads_manager = game.get_node("AdsManager")
var account_manager = game.get_node("AccountManager") var account_manager = game.get_node("AccountManager")
var multiplayer_manager = game.get_node("MultiplayerManager") var multiplayer_manager = game.get_node("MultiplayerManager")
game.set("core_crystals", 0)
game.call("_adjust_core_crystals", 25)
assert(game.get("core_crystals") == 25, "Der Entwicklermodus muss Testkristalle hinzufügen können.")
game.call("_adjust_core_crystals", -25)
game.call("_adjust_core_crystals", -25)
assert(game.get("core_crystals") == 0, "Der Entwicklermodus muss Kristalle abziehen können, ohne unter null zu fallen.")
assert(game.get("MAP_NAMES").size() == 7, "Es müssen sieben Karten vorhanden sein.") assert(game.get("MAP_NAMES").size() == 7, "Es müssen sieben Karten vorhanden sein.")
assert(game.get("MAP_TEXTURES").size() == 7, "Jede Karte benötigt einen eigenen Hintergrund.") assert(game.get("MAP_TEXTURES").size() == 7, "Jede Karte benötigt einen eigenen Hintergrund.")
assert(game.get("map_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.") assert(game.get("map_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.")
@@ -135,10 +141,10 @@ func _run() -> void:
assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.") assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.")
assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.") assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.")
assert(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.") assert(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.")
assert(game.call("_app_version") == "0.1.0-alpha.4", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.") assert(game.call("_app_version") == "0.1.0-alpha.7", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
var support_url: String = game.call("_support_email_url") var support_url: String = game.call("_support_email_url")
assert(support_url.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.") assert(support_url.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.")
assert(support_url.contains("0.1.0-alpha.4") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.") assert(support_url.contains("0.1.0-alpha.7") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
account_manager.storage_enabled = false account_manager.storage_enabled = false
var original_display_name: String = account_manager.display_name var original_display_name: String = account_manager.display_name
assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.") assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.")
@@ -152,14 +158,14 @@ func _run() -> void:
assert(account_manager.get_map_best_wave(0, false) >= 3, "Die Profilansicht muss den gespeicherten Missionsbestwert lesen können.") assert(account_manager.get_map_best_wave(0, false) >= 3, "Die Profilansicht muss den gespeicherten Missionsbestwert lesen können.")
assert(not ads_manager.mobile_ads_supported, "Auf Windows müssen Anzeigen vollständig deaktiviert bleiben.") assert(not ads_manager.mobile_ads_supported, "Auf Windows müssen Anzeigen vollständig deaktiviert bleiben.")
var update_manager = game.get_node("UpdateManager") var update_manager = game.get_node("UpdateManager")
assert(update_manager.call("_is_newer_version", "0.1.0-alpha.4", "0.1.0-alpha.3"), "Eine neuere Alpha-Version muss als Update erkannt werden.") assert(update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.5"), "Eine neuere Alpha-Version muss als Update erkannt werden.")
assert(update_manager.call("_is_newer_version", "0.1.0", "0.1.0-alpha.2"), "Eine stabile Version muss neuer als ihre Alpha-Version sein.") assert(update_manager.call("_is_newer_version", "0.1.0", "0.1.0-alpha.2"), "Eine stabile Version muss neuer als ihre Alpha-Version sein.")
assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.4", "0.1.0-alpha.4"), "Die installierte Version darf nicht als Update angeboten werden.") assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.6"), "Die installierte Version darf nicht als Update angeboten werden.")
var announced_update := {} var announced_update := {}
update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true)) update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true))
var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.5", "name": "Alpha.5", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer() var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.8", "name": "Alpha.8", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body) update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body)
assert(announced_update.get("version") == "0.1.0-alpha.5", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.") assert(announced_update.get("version") == "0.1.0-alpha.8", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(str(announced_update.get("url", "")).begins_with("https://git.dominicspringer.de/"), "Der Update-Hinweis darf nur einen öffentlichen Download-Link öffnen.") assert(str(announced_update.get("url", "")).begins_with("https://git.dominicspringer.de/"), "Der Update-Hinweis darf nur einen öffentlichen Download-Link öffnen.")
assert(game.get("path_points").size() == 9, "Bastion Prime sollte mit einer eigenen Einfahrt im Reaktor enden.") assert(game.get("path_points").size() == 9, "Bastion Prime sollte mit einer eigenen Einfahrt im Reaktor enden.")
_assert_grid_aligned_path(game.get("path_points"), "Bastion Prime") _assert_grid_aligned_path(game.get("path_points"), "Bastion Prime")
@@ -264,6 +270,62 @@ func _run() -> void:
assert(game.get("enemies_to_spawn") > 9, "Mittel muss mehr Gegner als Leicht erzeugen.") assert(game.get("enemies_to_spawn") > 9, "Mittel muss mehr Gegner als Leicht erzeugen.")
game.set("selected_difficulty", 2) game.set("selected_difficulty", 2)
game.call("_start_game") game.call("_start_game")
assert(game.get("credits") == 340 and game.get("reactor_lives") == 12, "Schwer muss mit knapperen Startressourcen und weniger Reaktorleben beginnen.")
assert(int(game.call("_difficulty").crystals) == 12, "Ein Sieg auf Schwer muss den höheren Kristallbonus gewähren.")
assert(game.get("ENEMY_DATA").size() == 9, "Drei zusätzliche Gegnerrollen müssen im Spiel verfügbar sein.")
assert(game.call("_enemy_category", 0) == "air" and game.call("_enemy_category", 6) == "ground", "Luft- und Bodeneinheiten müssen eindeutig klassifiziert sein.")
assert(not game.call("_tower_can_target", 0, 0) and game.call("_tower_can_target", 1, 0) and game.call("_tower_can_target", 1, 6) and game.call("_tower_can_target", 5, 6), "Kinetik muss auf Boden beschränkt sein; Laser und Railgun dürfen beide Kategorien angreifen.")
assert(game.call("_enemy_texture", 6).get_width() > 0 and game.call("_enemy_texture", 8).get_height() > 0, "Bodeneinheiten benötigen eigene lesbare Grafiken.")
assert(game.get("TOWER_DATA")[0].target_class == "ground" and game.get("TOWER_DATA")[1].target_class == "both", "Der Laser muss neben Luft- auch Bodenziele angreifen können.")
assert(game.get("TOWER_DATA")[1].both_damage_scale < 1.0 and game.get("TOWER_DATA")[3].both_damage_scale < 1.0 and game.get("TOWER_DATA")[5].both_damage_scale < 1.0, "Laser, Plasma und Railgun müssen für ihre Mehrzweckfähigkeit einen Schadensmultiplikator erhalten.")
assert(game.get("TOWER_DATA")[4].damage == 20.0 and game.get("TOWER_DATA")[4].target_class == "both", "EMP muss Luft- und Bodenziele mit rund 20 Schaden erfassen.")
assert(game.call("_tower_display_order") == [0, 2, 1, 3, 4, 5], "Die Turmauswahl muss zuerst Luft-, dann Boden- und anschließend Mehrzwecktürme gruppieren.")
assert(game.call("_tower_class_color", 0).g > game.call("_tower_class_color", 0).r and game.call("_tower_class_color", 1).b > game.call("_tower_class_color", 1).r, "Bodenfelder müssen grün und Mehrzweckfelder blau-cyan eingefärbt sein.")
game.get("enemies").clear()
var rail_front := {"id": 96001, "type": 0, "pos": Vector2(400, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var rail_behind := {"id": 96002, "type": 6, "pos": Vector2(500, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var rail_offline := {"id": 96003, "type": 0, "pos": Vector2(450, 326), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
game.get("enemies").append_array([rail_front, rail_behind, rail_offline])
game.call("_fire_tower", {"type": 5, "level": 1, "pos": Vector2(300, 300), "owner": 0}, rail_front)
assert(rail_front.hp < 1000.0 and rail_behind.hp < 1000.0 and is_equal_approx(rail_offline.hp, 1000.0), "Der Railgun-Strahl muss Gegner hinter dem Ziel auf der Linie durchschlagen, aber keine Ziele neben der Linie treffen.")
assert(game.get("beams")[-1].piercing and game.get("beams")[-1].color == Color("c86bff"), "Der durchschlagende Railgun-Strahl muss sichtbar lila sein.")
game.get("enemies").clear()
game.get("projectiles").clear()
var emp_air := {"id": 96101, "type": 0, "pos": Vector2(500, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var emp_ground := {"id": 96102, "type": 6, "pos": Vector2(550, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var emp_outside := {"id": 96103, "type": 6, "pos": Vector2(700, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
game.get("enemies").append_array([emp_air, emp_ground, emp_outside])
game.call("_fire_tower", {"type": 4, "level": 1, "pos": Vector2(400, 500), "owner": 0}, emp_air)
game.get("projectiles")[-1].speed = 0.0
game.get("projectiles")[-1].pos = emp_air.pos
game.call("_update_projectiles", 0.01)
assert(is_equal_approx(emp_air.hp, 980.0) and is_equal_approx(emp_ground.hp, 980.0) and is_equal_approx(emp_outside.hp, 1000.0), "Der EMP muss Luft und Boden im 118er-Radius jeweils um 20 treffen und Gegner außerhalb verschonen.")
game.get("enemies").clear()
game.set("path_points", PackedVector2Array([Vector2(0, 0), Vector2(1000, 0), Vector2(1600, 0)]))
game.get("enemies").clear()
var shield_target := {"id": 201, "type": 6, "pos": Vector2(850, 0), "path_index": 1, "hp": 100.0, "shield": true}
var unshielded_target := {"id": 202, "type": 6, "pos": Vector2(950, 0), "path_index": 1, "hp": 500.0, "shield": false}
game.get("enemies").append(shield_target)
game.get("enemies").append(unshielded_target)
assert(game.call("_find_target", {"type": 1, "level": 1, "pos": Vector2(800, 0)}).id == 201, "Lasertürme müssen unabhängig vom Fortschritt immer zuerst ein Schildziel in Reichweite wählen.")
assert(game.call("_find_target", {"type": 0, "level": 1, "pos": Vector2(800, 0), "target_priority": "strongest"}).id == 202, "Die Priorität MEISTE HP muss das Ziel mit den meisten Lebenspunkten wählen.")
assert(game.call("_find_target", {"type": 0, "level": 1, "pos": Vector2(800, 0), "target_priority": "last"}).id == 201, "Die Priorität LETZTE muss die Einheit mit dem geringsten Wegfortschritt wählen.")
game.get("towers").clear()
game.get("towers").append({"type": 0, "target_priority": "first"})
game.set("selected_tower_index", 0)
game.call("_cycle_selected_tower_priority")
assert(game.get("towers")[0].target_priority == "last", "Die Zielpriorität eines ausgewählten Turms muss umstellbar sein.")
game.set("selected_tower_index", -1)
game.get("towers").clear()
game.get("enemies").clear()
game.call("_select_map", 0)
assert(game.call("_enemy_type_for_wave", 3, 0.9, false) == 3, "Auf Schwer muss der Abfangjäger ab Welle 3 auftreten.")
assert(game.call("_enemy_type_for_wave", 5, 0.85, false) == 4, "Auf Schwer muss der Schildkreuzer ab Welle 5 auftreten.")
assert(game.call("_enemy_type_for_wave", 8, 0.95, false) == 5, "Auf Schwer muss der Belagerer ab Welle 8 auftreten.")
game.set("selected_difficulty", 1)
var medium_late_health: float = game.call("_enemy_health_scale", 12, false)
game.set("selected_difficulty", 2)
assert(game.call("_enemy_health_scale", 12, false) > medium_late_health, "Schwer muss ab der Missionsmitte stärker skalieren als Mittel.")
game.set("wave", 1) game.set("wave", 1)
game.set("enemies_to_spawn", 2) game.set("enemies_to_spawn", 2)
game.call("_spawn_enemy") game.call("_spawn_enemy")
@@ -272,13 +334,25 @@ func _run() -> void:
game.set("wave", 3) game.set("wave", 3)
game.call("_start_wave") game.call("_start_wave")
assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.") assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.")
var preview_types: Array[int] = game.call("_wave_preview_types", 3)
assert(preview_types.has(6) and preview_types.has(0), "Die Vorschau muss Luft- und Bodentypen der gleichen Welle zeigen.")
var preview_summary: Dictionary = game.call("_wave_preview_summary", 3)
assert(preview_summary.total == preview_types.size() and preview_summary.air + preview_summary.ground == preview_summary.total, "Die Vorschau muss die Gesamtzahl der Schiffe sowie die korrekten Luft-/Bodenanteile zählen.")
assert(game.get("wave_type_schedule").size() == game.get("enemies_to_spawn"), "Die Vorschau und der tatsächliche Spawnplan müssen übereinstimmen.")
var test_enemy := {"hp": 100.0, "max_hp": 100.0, "base_max_hp": 100.0, "type": 0, "flash": 0.0, "pos": Vector2.ZERO}
game.get("enemies").append(test_enemy)
game.call("_set_enemy_strength_multiplier", 1.6)
assert(is_equal_approx(test_enemy.max_hp, 160.0) and is_equal_approx(test_enemy.hp, 160.0), "Der Testmultiplikator muss die Hülle laufender Gegner anpassen.")
game.call("_set_enemy_strength_multiplier", 1.0)
game.get("enemies").clear() game.get("enemies").clear()
var shielded_enemy := {"hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "id": 98001, "type": 0, "pos": Vector2.ZERO, "speed": 1.0, "reward": 1, "boss": false, "size_scale": 1.0, "shield": true} var shielded_enemy := {"hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "id": 98001, "type": 0, "pos": Vector2.ZERO, "speed": 1.0, "reward": 1, "boss": false, "size_scale": 1.0, "shield": true, "shield_hp": 50.0, "max_shield_hp": 50.0, "shield_resistance": 0.28}
game.get("enemies").append(shielded_enemy) game.get("enemies").append(shielded_enemy)
game.call("_apply_damage", shielded_enemy, 100.0, 0, 0) game.call("_apply_damage", shielded_enemy, 100.0, 0, 0)
assert(is_equal_approx(shielded_enemy.hp, 972.0), "Schilde müssen kinetischen Schaden deutlich reduzieren, aber nicht vollständig blockieren.") assert(is_equal_approx(shielded_enemy.hp, 972.0), "Schilde müssen kinetischen Schaden deutlich reduzieren, aber nicht vollständig blockieren.")
game.call("_apply_damage", shielded_enemy, 100.0, 0, 1) game.call("_apply_damage", shielded_enemy, 30.0, 0, 1)
assert(is_equal_approx(shielded_enemy.hp, 872.0), "Laser müssen Schildgegner effektiv beschädigen.") assert(is_equal_approx(shielded_enemy.hp, 972.0) and is_equal_approx(shielded_enemy.shield_hp, 20.0), "Laser müssen das Schildleben statt der Hülle abbauen.")
game.call("_apply_damage", shielded_enemy, 30.0, 0, 1)
assert(not shielded_enemy.shield and is_equal_approx(shielded_enemy.hp, 962.0), "Der Laser muss beim Schildbruch überschüssigen Schaden auf die Hülle übertragen.")
game.get("enemies").clear() game.get("enemies").clear()
game.set("wave", 5) game.set("wave", 5)
game.set("reactor_lives", 12) game.set("reactor_lives", 12)
@@ -295,10 +369,12 @@ func _run() -> void:
game.set("wave", 4) game.set("wave", 4)
game.call("_start_wave") game.call("_start_wave")
game.set("enemies_to_spawn", 1) game.set("enemies_to_spawn", 1)
game.set("wave_spawn_index", 0)
game.set("wave_type_schedule", game.call("_build_wave_schedule", 5, 1))
game.call("_spawn_enemy") game.call("_spawn_enemy")
var boss: Dictionary = game.get("enemies")[-1] var boss: Dictionary = game.get("enemies")[-1]
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.") assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")
assert(boss.type == 2, "Das Kommandoschiff sollte die schwere Schiffsklasse verwenden.") assert(boss.type == 5, "Das Kommandoschiff sollte die schwere Belagerer-Klasse verwenden.")
game.call("_start_game") game.call("_start_game")
var crystals_before: int = game.get("core_crystals") var crystals_before: int = game.get("core_crystals")
game.set("wave", 15) game.set("wave", 15)