Compare commits
2
Commits
118061b8d7
...
e63e08ddde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63e08ddde | ||
|
|
56077e88a5 |
No files matched your search
+52
-2
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4.7.2.stable
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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
|
||||||
@@ -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,9 +266,13 @@ func _ready() -> void:
|
|||||||
_select_map(selected_map)
|
_select_map(selected_map)
|
||||||
_load_progress()
|
_load_progress()
|
||||||
_load_audio_settings()
|
_load_audio_settings()
|
||||||
music_player.stream = _build_ambient_stream()
|
# The Android AudioTrack backend currently crashes on this generated PCM stream
|
||||||
music_player.play()
|
# on the supported emulator/device test target. Keep the game playable there
|
||||||
ui_sound_player.stream = _build_ui_click_stream()
|
# 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.play()
|
||||||
|
ui_sound_player.stream = _build_ui_click_stream()
|
||||||
ads_manager.energy_reward_granted.connect(_on_energy_reward_granted)
|
ads_manager.energy_reward_granted.connect(_on_energy_reward_granted)
|
||||||
ads_manager.ad_unavailable.connect(_show_status)
|
ads_manager.ad_unavailable.connect(_show_status)
|
||||||
ads_manager.victory_interstitial_finished.connect(_on_victory_interstitial_finished)
|
ads_manager.victory_interstitial_finished.connect(_on_victory_interstitial_finished)
|
||||||
@@ -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,8 +2090,13 @@ 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:
|
||||||
draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0)
|
if bool(beam.get("piercing", false)):
|
||||||
draw_line(beam.from, beam.to, Color.WHITE, 1.0)
|
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.WHITE, 1.0)
|
||||||
for particle in particles:
|
for particle in particles:
|
||||||
draw_circle(particle.pos, maxf(1.0, particle.ttl * 8.0), Color(particle.color, minf(1.0, particle.ttl * 3.0)))
|
draw_circle(particle.pos, maxf(1.0, particle.ttl * 8.0), Color(particle.color, minf(1.0, particle.ttl * 3.0)))
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user