Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118061b8d7 | ||
|
|
bd980d099b | ||
|
|
6124331067 | ||
|
|
178a6bbc85 | ||
|
|
b4980f0acd | ||
|
|
235d7f3810 | ||
|
|
cad08aafb7 |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
.godot/
|
||||
build/
|
||||
dist/
|
||||
server/nakama/.env
|
||||
server/nakama/backups/
|
||||
tools/godot-agent-tools-mcp/node_modules/
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
|
||||
## Build und Installation
|
||||
|
||||
- [x] **A** `tests/smoke_test.gd` läuft ohne Fehler.
|
||||
- [x] **A** `tests/online_integration_test.gd` läuft gegen `api.void-bastion.gergames.de` ohne Fehler.
|
||||
- [x] **A** Windows-Export und Setup werden mit `packaging/windows/build.ps1` reproduzierbar erstellt.
|
||||
- [x] **A** Entwicklerwerkzeuge, Tests, Serverdateien und interne MCP-Konfigurationen sind nicht im Windows-Paket enthalten.
|
||||
- [ ] **M** Setup installiert ohne Administratorrechte, Startmenü- und Desktop-Verknüpfung funktionieren.
|
||||
- [ ] **M** Deinstallation entfernt Programmdateien und Verknüpfungen vollständig.
|
||||
- [ ] **M** Windows-SmartScreen-Hinweis ist in den Testhinweisen genannt, solange der Installer noch nicht signiert ist.
|
||||
|
||||
## 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** Alle drei Karten prüfen: Straße, Gegnerlaufweg, Raster und Bausperren stimmen exakt überein.
|
||||
- [ ] **M** Reaktoren und freie Weltraumbereiche sind auf allen Karten unbebaubar.
|
||||
- [ ] **M** Reichweitenvorschau erscheint vor dem Bau; Turmwerte und Upgrade-Mehrwert sind lesbar.
|
||||
- [ ] **M** Zurück-Schaltfläche bricht eine laufende Runde verständlich ab.
|
||||
|
||||
## Spielablauf
|
||||
|
||||
- [ ] **M** Jede Karte einmal auf Leicht, Mittel und Schwer starten; Startressourcen und Gegnerdruck unterscheiden sich sichtbar.
|
||||
- [ ] **M** Eine Mission bis Welle 15 spielen: Sieg kann beendet oder freiwillig als Endloslauf fortgesetzt werden.
|
||||
- [ ] **M** Eine Niederlage im Endlosmodus wird nicht als gescheiterte Mission gewertet.
|
||||
- [ ] **M** Kernkristall-Belohnung wird nach erfolgreicher Karte gespeichert.
|
||||
- [ ] **M** Spielstand, Einstellungen und Bestwerte bleiben nach einem Neustart erhalten.
|
||||
|
||||
## Konto und Onlinefunktionen
|
||||
|
||||
- [ ] **M** Gastkonto wird beim ersten Start erzeugt; Spielername lässt sich ändern.
|
||||
- [ ] **M** Profil zeigt Version, gekürzte Spieler-ID, Datenschutz und Support/Feedback.
|
||||
- [ ] **M** Bestenliste lädt; ein neuer Bestwert wird korrekt übertragen und dem sichtbaren Spielernamen zugeordnet.
|
||||
- [ ] **M** Kontolöschung verlangt eine zweite Bestätigung und erzeugt anschließend ein neues Gastprofil.
|
||||
- [ ] **M** Privater Raum: Erstellen, Code eingeben, Bereit-Lobby, Partiestart und Abbruch mit zwei Geräten prüfen.
|
||||
- [ ] **M** Kurzen Netzabbruch während einer Onlinepartie und die Wiederverbindung prüfen.
|
||||
- [ ] **M** Lokaler Koop mit KI: getrennte Ressourcen, zwei Gegnerwege, Seitenwechsel und gemeinsames Reaktorleben prüfen.
|
||||
|
||||
## Veröffentlichung
|
||||
|
||||
- [ ] **M** Setup-Datei und SHA-256-Prüfsumme stimmen mit der öffentlichen Release-Datei überein.
|
||||
- [ ] **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** Änderungen, bekannte Fehler und Testhinweise stehen in den Release-Notizen.
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Erzeugt mit dem integrierten OpenAI-Bildgenerator. Die Sprite-Sheets besitzen echte Transparenz.
|
||||
|
||||
## Zusätzliche Shop-Karten (Alpha 0.1.0-alpha.4 Entwicklung)
|
||||
|
||||
Die vier neuen Hintergründe `orbital_biodome_bg.png`, `cryo_refinery_bg.png`, `solar_forge_bg.png` und `vestige_megastructure_bg.png` wurden als eigenständige, straßenfreie 16:9-Karten erzeugt. Der Reaktor liegt jeweils rechts ungefähr auf Höhe der Spielfeldmitte. Die Fahrbahn, Gegnerroute und Bausperre werden zur Laufzeit ausschließlich aus den gemeinsamen 80-Pixel-Raster-Wegpunkten aufgebaut.
|
||||
|
||||
Gemeinsame Prompt-Vorgabe: realistische, hochauflösende, direkt von oben gesehene Science-Fiction-Plattform mit freier Deckfläche, gut lesbarer Umgebung und sichtbarem Reaktor am rechten Rand; kein eingebauter Weg, keine Fahrbahnmarkierung, kein Raster, keine Bauplätze, keine Einheiten, Türme, UI, Schrift oder Logos. Die Varianten unterscheiden sich thematisch durch Biodom und blauen Nebel, eisige Raffinerie, orangefarbene Sonnen-Schmiede sowie eine verlassene Megastruktur im violetten All.
|
||||
|
||||
## orbital_platform.png
|
||||
|
||||
```text
|
||||
|
||||
@@ -22,6 +22,12 @@ Die Kennungen folgen diesem Muster: `mission_map0_difficulty0`.
|
||||
|
||||
Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`.
|
||||
|
||||
## Interne Nutzungsstatistik
|
||||
|
||||
Nach jeder erfolgreichen Anmeldung meldet der Client Plattform und App-Version an die Serverfunktion `record_activity`. Pro Konto wird nur ein pseudonymer Aktivitätsdatensatz mit erstem und letztem Aktivitätstag gespeichert. Daraus führt Nakama zusammengefasste Werte für registrierte und täglich aktive Spieler sowie die Verteilung nach Plattform und Version.
|
||||
|
||||
Die Serverfunktion `private_stats` ist ausschließlich über den geheimen Nakama-HTTP-Schlüssel für die geschützte, nicht verlinkte Statistikseite der Homepage erreichbar. Angemeldete Spielclients können diese Werte nicht abrufen. Der geheime Schlüssel bleibt auf Unraid und im privaten Webhosting-Verzeichnis; er wird weder exportiert noch im Repository gespeichert.
|
||||
|
||||
Im Hauptmenü ist eine Ranglistenansicht für die aktuell gewählte Karte und Schwierigkeit eingebaut. Sie zeigt Mission und Endlosmodus getrennt, löst aktuelle öffentliche Profilnamen auf und hebt den eigenen Eintrag hervor.
|
||||
|
||||
## Multiplayer
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Void Bastion – spielbarer Prototyp 0.23
|
||||
# Void Bastion - Alpha 0.1.0
|
||||
|
||||
Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt.
|
||||
|
||||
## Enthalten
|
||||
|
||||
- drei auswählbare realistische Weltraumkarten im Querformat
|
||||
- sieben auswählbare realistische Weltraumkarten im Querformat; vier zusätzliche Sektoren werden im internen Shop mit Kernkristallen freigeschaltet
|
||||
- Kartenkatalog mit vier Sektoren pro Seite, persistent gespeicherten Freischaltungen und direktem Shop-Einstieg bei gesperrten Karten
|
||||
- vollständig sichtbare Einsatzkarten ohne Scrollen, Minikarte oder Richtungstasten
|
||||
- hellere, kontrastreichere Stationskarten und eigens gestaltete modulare Metallfahrbahnen mit realistischen Materialtexturen auf Geraden und Kurven, Führungsschienen, gebürsteten Paneelen, cyanfarbenen Energieleitungen, farbigen Reflexionen, Schraubpunkten, warmen Positionslichtern und dezenten Lichtkegeln
|
||||
- feste Benutzeroberfläche mit 1600 × 900 Referenzauflösung
|
||||
@@ -14,7 +15,7 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
|
||||
- sichtbares 80-Pixel-Bauraster mit automatischem Einrasten der Türme
|
||||
- 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
|
||||
- drei Türme: Kinetik, Laser und Rakete
|
||||
- sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun
|
||||
- drei Gegnerklassen mit unterschiedlichen Werten
|
||||
- 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
|
||||
@@ -36,11 +37,13 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
|
||||
- feste Reaktor-Sperrzonen auf allen Karten; auf Reaktoren und offenen Reaktorkammern wird kein Bauraster angezeigt und es können dort keine Türme platziert werden
|
||||
- auf eine Rasterzelle verkleinerte Turmdarstellung, damit angrenzende Türme nicht in die Fahrbahn hineinragen
|
||||
- dauerhaft gespeicherte Kernkristalle als spätere Freischaltwährung
|
||||
- Nova-Arkologie (24), Cryo-Schleuse (32), Solarforge (40) und Vestige-9 (48) als kaufbare Einsatzsektoren
|
||||
- dauerhaftes lokales Gastprofil mit stabiler Spieler-ID als Grundlage für Apple-, Google- und Windows-Anmeldung
|
||||
- eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte
|
||||
- frei wählbarer Spielername mit identischer Prüfung im Spiel und auf dem Server
|
||||
- zweistufige Kontolöschung direkt im Pilotenprofil; danach wird ein neues lokales Gastprofil erstellt
|
||||
- direkter Datenschutz-Link im Pilotenprofil
|
||||
- sichtbare App-Version und vorbereitete Support-Mail an `support@gergames.de` im Pilotenprofil; Version, Plattform und gekürzte Spieler-ID werden für eine schnellere Fehlerzuordnung eingesetzt
|
||||
- wartende, lokal gespeicherte Highscore-Einträge für die spätere sichere Serverübertragung
|
||||
- eigener Multiplayerbereich mit schneller Spielersuche sowie privaten Koop-Räumen mit sechsstelligen Raumcodes
|
||||
- gemeinsame Online-Lobby mit Spielernamen, festen Verteidigungsseiten und manuellem Bereit-Status für beide Piloten
|
||||
@@ -57,7 +60,7 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
|
||||
- Abschussbelohnungen und Baukosten werden dem jeweiligen Spieler beziehungsweise KI-Partner getrennt zugerechnet
|
||||
- farblich getrennte Bauflächen und markierte KI-Türme zur schnellen Zuordnung im Gefecht
|
||||
- fünf Kernkristalle als Belohnung für jede erfolgreich abgeschlossene Karte
|
||||
- normale und doppelte Spielgeschwindigkeit
|
||||
- auswählbare Spielgeschwindigkeit bis 5× sowie automatischer Start der nächsten Welle
|
||||
- Pause, Missionssieg, Niederlage und lokaler Wellenrekord
|
||||
- jederzeit sichtbarer Zurück-Button zum Abbrechen der laufenden Runde und Wechseln ins Kartenmenü
|
||||
- lokaler Speicherstand für Rekord und Tutorialstatus
|
||||
@@ -81,6 +84,10 @@ Der Prototyp wurde mit der offiziellen Godot-Version 4.7.2 importiert und im aut
|
||||
|
||||
Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path . --script res://tests/smoke_test.gd` erneut ausführen.
|
||||
|
||||
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`.
|
||||
|
||||
## Werbung und Datenschutz
|
||||
|
||||
Die mobile Werbeanbindung ist technisch vorbereitet, aber absichtlich noch nicht für echte Werbung freigeschaltet:
|
||||
@@ -105,11 +112,19 @@ Der Stack ist auf dem vorgesehenen Unraid-Server eingerichtet. Nakamas Spiel-API
|
||||
|
||||
Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. DNS, HTTPS/WSS über den vorhandenen Nginx Proxy Manager und die Godot-Nakama-Clientanbindung sind eingerichtet. Website und Spielserver verwenden damit getrennte Adressen.
|
||||
|
||||
### Spielupdates
|
||||
|
||||
Der Client prüft beim Start die öffentlichen Releases des Projekts. Ist eine neuere Version vorhanden, erscheint im Hauptmenü ein kompakter Update-Hinweis. Unter Windows öffnet er direkt das aktuelle Setup, unter Android künftig das aktuelle APK; fehlt ein passendes Paket, wird die öffentliche Release-Seite geöffnet. Ein Update wird niemals ungefragt installiert.
|
||||
|
||||
### Kartenregel: Laufweg und Reaktor
|
||||
|
||||
Jede Einzelspielerroute muss mit ihrem letzten Wegpunkt exakt im Mittelpunkt der primären Reaktorzone enden. `_validate_route_ends_at_reactor()` prüft diese Vorgabe beim Laden jeder Karte; der Smoke-Test prüft sie zusätzlich für alle vorhandenen Karten. Dadurch bleiben sichtbare Straße, Gegnerbewegung, Bausperre und Reaktoreinfahrt deckungsgleich.
|
||||
|
||||
## Steuerung
|
||||
|
||||
> **Online-Stand 0.23:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, servergeprüfte öffentliche Spielernamen, Kontolöschung im Spiel, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop, private Räume per Raumcode, eine Bereit-Lobby und Wiederverbindung sind eingebaut. Ranglisten lösen den aktuellen öffentlichen Profilnamen über die unveränderliche Spieler-ID auf. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let's-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`.
|
||||
> **Online-Stand Alpha 0.1.0:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, servergeprüfte öffentliche Spielernamen, Kontolöschung im Spiel, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop, private Räume per Raumcode, eine Bereit-Lobby und Wiederverbindung sind eingebaut. Ranglisten lösen den aktuellen öffentlichen Profilnamen über die unveränderliche Spieler-ID auf. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let's-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`.
|
||||
|
||||
Die Schaltfläche **Bestenliste** im Hauptmenü zeigt die zwölf besten Piloten für die aktuell gewählte Karte und Schwierigkeit. Zwischen Mission und Endlosmodus kann direkt in der Ansicht umgeschaltet werden; der eigene Eintrag wird farblich hervorgehoben.
|
||||
Die Schaltfläche **Bestenliste** im Hauptmenü zeigt die zwölf besten Piloten. Karte, Schwierigkeitsgrad sowie Mission oder Endlosmodus können direkt in der Ranglistenansicht gefiltert werden. Jede Zeile nennt Karte und Schwierigkeit sichtbar; der eigene Eintrag wird farblich hervorgehoben.
|
||||
|
||||
- Turm unten auswählen und auf eine freie Plattformfläche klicken oder tippen.
|
||||
- Am PC folgt der Reichweitenkreis vor dem Bau dem Mauszeiger. Auf Mobilgeräten markiert der erste Tipp den Bauplatz samt Reichweite; der zweite Tipp auf dasselbe Feld bestätigt den Bau.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Void Bastion 0.1.0-alpha.2
|
||||
|
||||
Diese Testversion erweitert die Online-Grundlage und verbessert die Zuverlässigkeit des Windows-Pakets.
|
||||
|
||||
## Neu
|
||||
|
||||
- interne, pseudonyme Zählung registrierter und täglich aktiver Spieler
|
||||
- Plattform- und Versionsverteilung für die geschützte GerGames-Statistik
|
||||
- Aktivitätswerte sind nicht öffentlich und für normale Spielclients nicht abrufbar
|
||||
|
||||
## Verbessert
|
||||
|
||||
- Windows-Export enthält keine lokalen Entwicklungswerkzeuge mehr
|
||||
- automatischer Koop-KI-Test ist unabhängig von den lokal gewählten Einstellungen
|
||||
- aktualisierte Datenschutzinformation zur internen Nutzungsstatistik
|
||||
|
||||
## Hinweis
|
||||
|
||||
Void Bastion befindet sich weiterhin in einer frühen Alpha-Phase. Rückmeldungen und Fehlerberichte bitte an support@gergames.de senden.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Void Bastion 0.1.0-alpha.3
|
||||
|
||||
Diese Testversion verbessert die Orientierung auf den Karten, die Vergleichbarkeit der Bestenlisten und die Verteilung künftiger Spielupdates.
|
||||
|
||||
## Neu
|
||||
|
||||
- automatische Prüfung auf neue öffentliche Spielversionen beim Start
|
||||
- kompakter Update-Hinweis im Hauptmenü mit direktem Link zum passenden Windows-Setup
|
||||
- Karten- und Schwierigkeitsfilter direkt in der globalen Bestenliste
|
||||
- sichtbare Karten- und Schwierigkeitsangabe in jeder Ranglistenzeile
|
||||
|
||||
## Verbessert
|
||||
|
||||
- alle Einzelspielerrouten enden jetzt sichtbar und technisch exakt im jeweiligen Reaktor
|
||||
- auch beide Koop-Routen treffen sich exakt im gemeinsamen Reaktor
|
||||
- neue Karten werden automatisch auf eine korrekte Reaktoreinfahrt geprüft
|
||||
- deutlich kleinere und transparentere Turm-Bauvorschau
|
||||
- dezentere Reichweiten- und Rasteranzeige beim Platzieren eines Turms
|
||||
- Hauptmenü zeigt die tatsächliche öffentliche Spielversion
|
||||
|
||||
## Hinweis
|
||||
|
||||
Void Bastion befindet sich weiterhin in einer frühen Alpha-Phase. Das Update wird niemals ungefragt installiert. Rückmeldungen und Fehlerberichte bitte an support@gergames.de senden.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Void Bastion 0.1.0-alpha.4
|
||||
|
||||
Diese Windows-Testversion erweitert die Kartenauswahl und verbessert die Wegdarstellung.
|
||||
|
||||
## Neu
|
||||
|
||||
- vier neue Einsatzkarten: Nova-Arkologie, Cryo-Schleuse, Solarforge und Vestige-9
|
||||
- alle sieben Karten werden gleichzeitig im Hauptmenü angezeigt
|
||||
- neue Karten lassen sich im internen Shop dauerhaft mit Kernkristallen freischalten
|
||||
- die Freischaltungen bleiben lokal im Profil gespeichert
|
||||
|
||||
## Verbessert
|
||||
|
||||
- alle Kartenwege folgen dem gemeinsamen 80-Pixel-Raster und enden im sichtbaren Reaktor
|
||||
- sich kreuzende Wegsegmente auf Nova-Arkologie sowie zusätzliche problematische Kreuzungen auf Cryo-Schleuse und Solarforge wurden entfernt
|
||||
- die letzten zwei unnötigen Bögen bei der Reaktoreinfahrt auf Pyrax wurden entfernt
|
||||
- Währungsanzeigen verwenden durchgängig die Bezeichnung „Kernkristalle“
|
||||
- die Profilübersicht und Kartenfilter unterstützen alle sieben Sektoren
|
||||
|
||||
## 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. Rückmeldungen und Fehlerberichte bitte an support@gergames.de senden.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Void Bastion 0.1.0 Alpha – Windows x64
|
||||
|
||||
Dies ist eine frühe öffentliche Testversion. Sie dient zum Prüfen von Spielgefühl, Karten, Balancing, Darstellung und den vorbereiteten Onlinefunktionen.
|
||||
|
||||
## Enthalten
|
||||
|
||||
- drei große Weltraumkarten mit rastergenauen Fahrbahnen und Bauzonen
|
||||
- drei Turmtypen, drei Schwierigkeitsgrade und 15 Missionswellen
|
||||
- freiwilliger Endlosmodus nach einem Missionssieg
|
||||
- lokale Kernkristalle, Bestwerte und ein dauerhaftes Gastprofil
|
||||
- globale Bestenlisten und private Koop-Räume über den GerGames-Server
|
||||
- lokaler Koop-Testmodus mit KI-Partner auf einer gespiegelten Karte
|
||||
- Datenschutz, Kontolöschung sowie Support/Feedback direkt im Pilotenprofil
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Der Installer ist noch nicht digital signiert. Windows kann deshalb einen SmartScreen-Hinweis anzeigen.
|
||||
- Online-Koop ist Alpha-Funktionalität und soll ausdrücklich mit zwei unterschiedlichen Geräten und Netzwerken getestet werden.
|
||||
- Musik, Soundeffekte, weitere Animationen und das Freischaltmenü sind noch in Arbeit.
|
||||
- Android und iOS sind technisch vorgesehen, aber noch nicht als öffentliche Testpakete freigegeben.
|
||||
|
||||
Fehlerberichte bitte über **Support / Feedback** im Pilotenprofil oder an `support@gergames.de` senden.
|
||||
|
||||
## Windows-Paket
|
||||
|
||||
- Datei: `Void-Bastion-Setup-x64.exe`
|
||||
- Größe: 111,0 MiB
|
||||
- SHA-256: `56F12DE4A3E9B233D2F6C40661E5514674F0625E7AEE777A865A0363C4AED3DD`
|
||||
@@ -3,11 +3,9 @@ extends Node
|
||||
|
||||
signal energy_reward_granted(amount: int)
|
||||
signal ad_unavailable(message: String)
|
||||
signal victory_interstitial_finished(was_shown: bool)
|
||||
|
||||
const SAVE_PATH := "user://void_bastion_ads.json"
|
||||
const ENERGY_REWARD := 150
|
||||
const MISSIONS_PER_INTERSTITIAL := 3
|
||||
const INTERSTITIAL_COOLDOWN_SECONDS := 15 * 60
|
||||
const ANDROID_TEST_APP_ID := "ca-app-pub-3940256099942544~3347511713"
|
||||
const IOS_TEST_APP_ID := "ca-app-pub-3940256099942544~1458002511"
|
||||
|
||||
@@ -16,15 +14,13 @@ var initialized := false
|
||||
var rewarded_ready := false
|
||||
var interstitial_ready := false
|
||||
var reward_used_this_mission := false
|
||||
var missions_since_interstitial := 0
|
||||
var last_interstitial_time := 0
|
||||
var _victory_interstitial_pending := false
|
||||
|
||||
var _admob: Node
|
||||
var _initialization_requested := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_state()
|
||||
mobile_ads_supported = OS.has_feature("android") or OS.has_feature("ios")
|
||||
if not mobile_ads_supported:
|
||||
return
|
||||
@@ -55,6 +51,7 @@ func _create_admob_node() -> void:
|
||||
_admob.interstitial_ad_loaded.connect(_on_interstitial_ad_loaded)
|
||||
_admob.interstitial_ad_failed_to_load.connect(_on_interstitial_ad_failed_to_load)
|
||||
_admob.interstitial_ad_dismissed_full_screen_content.connect(_on_interstitial_ad_dismissed)
|
||||
_admob.interstitial_ad_failed_to_show_full_screen_content.connect(_on_interstitial_ad_failed_to_show)
|
||||
add_child(_admob)
|
||||
|
||||
|
||||
@@ -76,31 +73,23 @@ func request_rewarded_energy() -> void:
|
||||
_admob.show_rewarded_ad()
|
||||
|
||||
|
||||
func record_mission_finished() -> void:
|
||||
missions_since_interstitial += 1
|
||||
_save_state()
|
||||
if is_interstitial_due() and interstitial_ready:
|
||||
_show_interstitial()
|
||||
elif is_interstitial_due() and initialized:
|
||||
func show_victory_interstitial() -> void:
|
||||
if _victory_interstitial_pending:
|
||||
return
|
||||
if not mobile_ads_supported or not initialized:
|
||||
victory_interstitial_finished.emit(false)
|
||||
return
|
||||
_victory_interstitial_pending = true
|
||||
if not interstitial_ready:
|
||||
_admob.load_interstitial_ad()
|
||||
return
|
||||
_display_pending_victory_interstitial()
|
||||
|
||||
|
||||
func is_interstitial_due(now_unix: int = -1) -> bool:
|
||||
if missions_since_interstitial < MISSIONS_PER_INTERSTITIAL:
|
||||
return false
|
||||
if last_interstitial_time <= 0:
|
||||
return true
|
||||
var now := int(Time.get_unix_time_from_system()) if now_unix < 0 else now_unix
|
||||
return now - last_interstitial_time >= INTERSTITIAL_COOLDOWN_SECONDS
|
||||
|
||||
|
||||
func _show_interstitial() -> void:
|
||||
if not mobile_ads_supported or not initialized or not interstitial_ready:
|
||||
func _display_pending_victory_interstitial() -> void:
|
||||
if not _victory_interstitial_pending or not interstitial_ready:
|
||||
return
|
||||
interstitial_ready = false
|
||||
missions_since_interstitial = 0
|
||||
last_interstitial_time = int(Time.get_unix_time_from_system())
|
||||
_save_state()
|
||||
_admob.show_interstitial_ad()
|
||||
|
||||
|
||||
@@ -171,33 +160,29 @@ func _on_rewarded_ad_dismissed(_ad_info) -> void:
|
||||
|
||||
func _on_interstitial_ad_loaded(_ad_info, _response_info) -> void:
|
||||
interstitial_ready = true
|
||||
_display_pending_victory_interstitial()
|
||||
|
||||
|
||||
func _on_interstitial_ad_failed_to_load(_ad_info, _error) -> void:
|
||||
interstitial_ready = false
|
||||
_finish_victory_interstitial(false)
|
||||
|
||||
|
||||
func _on_interstitial_ad_dismissed(_ad_info) -> void:
|
||||
interstitial_ready = false
|
||||
_finish_victory_interstitial(true)
|
||||
_admob.load_interstitial_ad()
|
||||
|
||||
|
||||
func _save_state() -> void:
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify({
|
||||
"missions_since_interstitial": missions_since_interstitial,
|
||||
"last_interstitial_time": last_interstitial_time
|
||||
}))
|
||||
func _on_interstitial_ad_failed_to_show(_ad_info, _error) -> void:
|
||||
interstitial_ready = false
|
||||
_finish_victory_interstitial(false)
|
||||
if initialized:
|
||||
_admob.load_interstitial_ad()
|
||||
|
||||
|
||||
func _load_state() -> void:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
func _finish_victory_interstitial(was_shown: bool) -> void:
|
||||
if not _victory_interstitial_pending:
|
||||
return
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
|
||||
if not file:
|
||||
return
|
||||
var data = JSON.parse_string(file.get_as_text())
|
||||
if data is Dictionary:
|
||||
missions_since_interstitial = maxi(0, int(data.get("missions_since_interstitial", 0)))
|
||||
last_interstitial_time = maxi(0, int(data.get("last_interstitial_time", 0)))
|
||||
_victory_interstitial_pending = false
|
||||
victory_interstitial_finished.emit(was_shown)
|
||||
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c6qskqc282rpg"
|
||||
path="res://.godot/imported/cryo_refinery_bg.png-b0439eb485200ef8da0459147d987794.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/cryo_refinery_bg.png"
|
||||
dest_files=["res://.godot/imported/cryo_refinery_bg.png-b0439eb485200ef8da0459147d987794.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
|
||||
|
After Width: | Height: | Size: 3.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bkc4l5ax4o761"
|
||||
path="res://.godot/imported/orbital_biodome_bg.png-9073720249bcf29936435b3b814e9686.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/orbital_biodome_bg.png"
|
||||
dest_files=["res://.godot/imported/orbital_biodome_bg.png-9073720249bcf29936435b3b814e9686.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
|
||||
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dxy2tscacupix"
|
||||
path="res://.godot/imported/solar_forge_bg.png-d2e9c29bfbb0f929e05570a5a5d0a90b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/solar_forge_bg.png"
|
||||
dest_files=["res://.godot/imported/solar_forge_bg.png-d2e9c29bfbb0f929e05570a5a5d0a90b.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
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://da66x5legbfd3"
|
||||
path="res://.godot/imported/tower_emp.png-0c5406d0107cf3a00de9de8b95603513.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/tower_emp.png"
|
||||
dest_files=["res://.godot/imported/tower_emp.png-0c5406d0107cf3a00de9de8b95603513.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
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dcfoc8ioji2jg"
|
||||
path="res://.godot/imported/tower_plasma.png-6c4277ea9427fade2f842c305b0946df.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/tower_plasma.png"
|
||||
dest_files=["res://.godot/imported/tower_plasma.png-6c4277ea9427fade2f842c305b0946df.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
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bbba7b3ds0q1s"
|
||||
path="res://.godot/imported/tower_railgun.png-05e293f5b98edc1a6a0a91aff3e87037.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/tower_railgun.png"
|
||||
dest_files=["res://.godot/imported/tower_railgun.png-05e293f5b98edc1a6a0a91aff3e87037.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
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c18bfpv8ad25r"
|
||||
path="res://.godot/imported/vestige_megastructure_bg.png-cdef57a31190ae339ecfafd3efc0bafe.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/vestige_megastructure_bg.png"
|
||||
dest_files=["res://.godot/imported/vestige_megastructure_bg.png-cdef57a31190ae339ecfafd3efc0bafe.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
|
||||
@@ -0,0 +1,123 @@
|
||||
[preset.0]
|
||||
|
||||
name="Windows x64"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*"
|
||||
export_path="build/windows/Void-Bastion.exe"
|
||||
patches=PackedStringArray()
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=0
|
||||
binary_format/embed_pck=true
|
||||
binary_format/architecture="x86_64"
|
||||
codesign/enable=false
|
||||
application/modify_resources=true
|
||||
application/icon="res://assets/app_icon.png"
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version="0.1.0.4"
|
||||
application/product_version="0.1.0.4"
|
||||
application/company_name="GerGames"
|
||||
application/product_name="Void Bastion"
|
||||
application/file_description="Void Bastion – Alpha-Testversion"
|
||||
application/copyright="Copyright © 2026 Dominic Springer / GerGames"
|
||||
application/trademarks=""
|
||||
ssh_remote_deploy/enabled=false
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Android Test APK"
|
||||
platform="Android"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*"
|
||||
export_path="build/android/Void-Bastion-Alpha.apk"
|
||||
patches=PackedStringArray()
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
gradle_build/use_gradle_build=true
|
||||
gradle_build/export_format=0
|
||||
gradle_build/min_sdk="24"
|
||||
architectures/arm64-v8a=true
|
||||
architectures/armeabi-v7a=true
|
||||
architectures/x86=false
|
||||
architectures/x86_64=false
|
||||
package/name="Void Bastion"
|
||||
package/unique_name="de.gergames.voidbastion"
|
||||
package/signed=true
|
||||
screen/immersive_mode=true
|
||||
permissions/internet=true
|
||||
|
||||
[preset.3]
|
||||
|
||||
name="iOS (Xcode Projekt)"
|
||||
platform="iOS"
|
||||
runnable=false
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*"
|
||||
export_path="build/ios/Void-Bastion"
|
||||
patches=PackedStringArray()
|
||||
|
||||
[preset.3.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
application/app_store_team_id=""
|
||||
application/bundle_identifier="de.gergames.voidbastion"
|
||||
application/export_project_only=true
|
||||
application/min_ios_version="14.3"
|
||||
application/short_version="0.1.0"
|
||||
application/version="1.0"
|
||||
|
||||
[preset.2]
|
||||
|
||||
name="Android Studio Emulator x86_64"
|
||||
platform="Android"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*"
|
||||
export_path="build/android/Void-Bastion-Emulator-x64.apk"
|
||||
patches=PackedStringArray()
|
||||
|
||||
[preset.2.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
gradle_build/use_gradle_build=true
|
||||
gradle_build/export_format=0
|
||||
gradle_build/min_sdk="24"
|
||||
architectures/arm64-v8a=false
|
||||
architectures/armeabi-v7a=false
|
||||
architectures/x86=false
|
||||
architectures/x86_64=true
|
||||
package/name="Void Bastion"
|
||||
package/unique_name="de.gergames.voidbastion"
|
||||
package/signed=true
|
||||
screen/immersive_mode=true
|
||||
permissions/internet=true
|
||||
@@ -1,10 +1,11 @@
|
||||
[gd_scene load_steps=6 format=3]
|
||||
[gd_scene load_steps=7 format=3]
|
||||
|
||||
[ext_resource path="res://main.gd" type="Script" id="1"]
|
||||
[ext_resource path="res://ads_manager.gd" type="Script" id="2"]
|
||||
[ext_resource path="res://account_manager.gd" type="Script" id="3"]
|
||||
[ext_resource path="res://multiplayer_manager.gd" type="Script" id="4"]
|
||||
[ext_resource path="res://online_manager.gd" type="Script" id="5"]
|
||||
[ext_resource path="res://update_manager.gd" type="Script" id="6"]
|
||||
|
||||
[node name="VoidBastion" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
@@ -20,3 +21,12 @@ script = ExtResource("4")
|
||||
|
||||
[node name="OnlineManager" type="Node" parent="."]
|
||||
script = ExtResource("5")
|
||||
|
||||
[node name="UpdateManager" type="Node" parent="."]
|
||||
script = ExtResource("6")
|
||||
|
||||
[node name="MusicPlayer" type="AudioStreamPlayer" parent="."]
|
||||
bus = &"Master"
|
||||
|
||||
[node name="UiSoundPlayer" type="AudioStreamPlayer" parent="."]
|
||||
bus = &"Master"
|
||||
@@ -85,6 +85,7 @@ func _authenticate_on(host: String, port: int, scheme: String) -> bool:
|
||||
|
||||
|
||||
func _after_authentication() -> void:
|
||||
await _record_activity()
|
||||
socket = Nakama.create_socket_from(client)
|
||||
_bind_socket_signals()
|
||||
var result = await socket.connect_async(session, true, 5)
|
||||
@@ -99,6 +100,16 @@ func _after_authentication() -> void:
|
||||
await flush_pending_scores()
|
||||
|
||||
|
||||
func _record_activity() -> void:
|
||||
if not is_authenticated():
|
||||
return
|
||||
var payload := {
|
||||
"platform": OS.get_name().to_lower(),
|
||||
"version": str(ProjectSettings.get_setting("application/config/version", "development"))
|
||||
}
|
||||
await client.rpc_async(session, "record_activity", JSON.stringify(payload))
|
||||
|
||||
|
||||
func _bind_socket_signals() -> void:
|
||||
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
||||
socket.received_match_state.connect(_on_match_state)
|
||||
@@ -129,7 +140,7 @@ func submit_run(record: Dictionary) -> bool:
|
||||
if not is_authenticated():
|
||||
return false
|
||||
var payload := record.duplicate(true)
|
||||
payload["client_version"] = "0.23"
|
||||
payload["client_version"] = str(ProjectSettings.get_setting("application/config/version", "development"))
|
||||
var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload))
|
||||
var parsed := _parse_rpc_result(result)
|
||||
if parsed.is_empty() or not bool(parsed.get("accepted", false)):
|
||||
@@ -204,7 +215,10 @@ func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limi
|
||||
"username": str(current_names.get(record.owner_id, record.username)),
|
||||
"score": int(record.score),
|
||||
"wave": int(record.subscore),
|
||||
"owner_id": record.owner_id
|
||||
"owner_id": record.owner_id,
|
||||
"map": map_index,
|
||||
"difficulty": difficulty_index,
|
||||
"mode": "endless" if endless else "mission"
|
||||
})
|
||||
leaderboard_loaded.emit(leaderboard_id, rows)
|
||||
return rows
|
||||
@@ -408,7 +422,7 @@ func is_online() -> bool:
|
||||
|
||||
func make_leaderboard_id(map_index: int, difficulty_index: int, mode: String) -> String:
|
||||
var safe_mode := mode if mode in ["mission", "endless", "coop_ai", "coop_online"] else "mission"
|
||||
return "%s_map%d_difficulty%d" % [safe_mode, clampi(map_index, 0, 2), clampi(difficulty_index, 0, 2)]
|
||||
return "%s_map%d_difficulty%d" % [safe_mode, clampi(map_index, 0, 6), clampi(difficulty_index, 0, 2)]
|
||||
|
||||
|
||||
func _parse_rpc_result(result) -> Dictionary:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$GodotExe,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$MakeNsisExe
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe"
|
||||
$installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe"
|
||||
$installerScript = Join-Path $PSScriptRoot "installer.nsi"
|
||||
$projectFile = Join-Path $projectRoot "project.godot"
|
||||
|
||||
foreach ($requiredFile in @($GodotExe, $MakeNsisExe, $installerScript)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) {
|
||||
throw "Benötigte Datei fehlt: $requiredFile"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $gameExe), (Split-Path $installer) | Out-Null
|
||||
|
||||
$projectConfiguration = Get-Content -LiteralPath $projectFile -Raw
|
||||
$releaseConfiguration = $projectConfiguration -replace '(?m)^_MCPGameBridge=.*\r?\n?', ''
|
||||
$releaseConfiguration = $releaseConfiguration -replace '"res://addons/agent_tools/plugin\.cfg",\s*', ''
|
||||
|
||||
try {
|
||||
# Die lokale MCP-Laufzeit ist ein Entwicklungswerkzeug und wird vom
|
||||
# Exportfilter ausgeschlossen. Deshalb dürfen weder ihr Autoload-Verweis
|
||||
# noch das Editor-Plugin in der veröffentlichten project.binary verbleiben.
|
||||
Set-Content -LiteralPath $projectFile -Value $releaseConfiguration -NoNewline
|
||||
& $GodotExe --headless --path $projectRoot --export-release "Windows x64" $gameExe
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $gameExe)) {
|
||||
throw "Der Godot-Windows-Export ist fehlgeschlagen."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Set-Content -LiteralPath $projectFile -Value $projectConfiguration -NoNewline
|
||||
}
|
||||
|
||||
Push-Location $PSScriptRoot
|
||||
try {
|
||||
& $MakeNsisExe $installerScript
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $installer)) {
|
||||
throw "Die Installer-Erstellung mit NSIS ist fehlgeschlagen."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$installerFile = Get-Item -LiteralPath $installer
|
||||
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $installer
|
||||
Write-Host "Windows-Installer erfolgreich erstellt:"
|
||||
Write-Host " Datei: $($installerFile.FullName)"
|
||||
Write-Host " Größe: $([math]::Round($installerFile.Length / 1MB, 1)) MiB"
|
||||
Write-Host " SHA256: $($hash.Hash)"
|
||||
@@ -0,0 +1,85 @@
|
||||
Unicode true
|
||||
ManifestDPIAware true
|
||||
RequestExecutionLevel user
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
!define PRODUCT_NAME "Void Bastion"
|
||||
!define PRODUCT_VERSION "0.1.0-alpha.4"
|
||||
!define PRODUCT_PUBLISHER "GerGames"
|
||||
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
|
||||
!define PRODUCT_EXE "Void-Bastion.exe"
|
||||
|
||||
Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"
|
||||
OutFile "..\..\dist\Void-Bastion-Setup-x64.exe"
|
||||
InstallDir "$LOCALAPPDATA\Programs\Void Bastion"
|
||||
InstallDirRegKey HKCU "Software\GerGames\Void Bastion" "InstallDir"
|
||||
SetCompressor /SOLID lzma
|
||||
SetCompressorDictSize 64
|
||||
BrandingText "GerGames"
|
||||
ShowInstDetails show
|
||||
ShowUninstDetails show
|
||||
|
||||
VIProductVersion "0.1.0.4"
|
||||
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
|
||||
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
|
||||
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
|
||||
VIAddVersionKey /LANG=1031 "FileVersion" "${PRODUCT_VERSION}"
|
||||
VIAddVersionKey /LANG=1031 "ProductVersion" "${PRODUCT_VERSION}"
|
||||
VIAddVersionKey /LANG=1031 "LegalCopyright" "Copyright © 2026 Dominic Springer / GerGames"
|
||||
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXE}"
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "Void Bastion starten"
|
||||
!define MUI_FINISHPAGE_LINK "Void-Bastion-Website öffnen"
|
||||
!define MUI_FINISHPAGE_LINK_LOCATION "${PRODUCT_WEBSITE}"
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
!insertmacro MUI_UNPAGE_FINISH
|
||||
|
||||
!insertmacro MUI_LANGUAGE "German"
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Section "Void Bastion" SEC_MAIN
|
||||
SectionIn RO
|
||||
SetOutPath "$INSTDIR"
|
||||
File "..\..\build\windows\${PRODUCT_EXE}"
|
||||
|
||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||
WriteRegStr HKCU "Software\GerGames\Void Bastion" "InstallDir" "$INSTDIR"
|
||||
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "DisplayName" "${PRODUCT_NAME}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "DisplayVersion" "${PRODUCT_VERSION}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "Publisher" "${PRODUCT_PUBLISHER}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "URLInfoAbout" "${PRODUCT_WEBSITE}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "DisplayIcon" "$INSTDIR\${PRODUCT_EXE}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "UninstallString" '"$INSTDIR\Uninstall.exe"'
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "NoModify" 1
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion" "NoRepair" 1
|
||||
|
||||
CreateDirectory "$SMPROGRAMS\Void Bastion"
|
||||
CreateShortcut "$SMPROGRAMS\Void Bastion\Void Bastion.lnk" "$INSTDIR\${PRODUCT_EXE}"
|
||||
CreateShortcut "$SMPROGRAMS\Void Bastion\Void Bastion deinstallieren.lnk" "$INSTDIR\Uninstall.exe"
|
||||
CreateShortcut "$DESKTOP\Void Bastion.lnk" "$INSTDIR\${PRODUCT_EXE}"
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
Delete "$DESKTOP\Void Bastion.lnk"
|
||||
Delete "$SMPROGRAMS\Void Bastion\Void Bastion.lnk"
|
||||
Delete "$SMPROGRAMS\Void Bastion\Void Bastion deinstallieren.lnk"
|
||||
RMDir "$SMPROGRAMS\Void Bastion"
|
||||
|
||||
Delete "$INSTDIR\${PRODUCT_EXE}"
|
||||
Delete "$INSTDIR\Uninstall.exe"
|
||||
RMDir "$INSTDIR"
|
||||
|
||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void Bastion"
|
||||
DeleteRegKey HKCU "Software\GerGames\Void Bastion"
|
||||
SectionEnd
|
||||
@@ -15,6 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
[application]
|
||||
|
||||
config/name="Void Bastion"
|
||||
config/version="0.1.0-alpha.4"
|
||||
run/main_scene="res://main.tscn"
|
||||
config/features=PackedStringArray("4.7")
|
||||
config/icon="res://assets/app_icon.png"
|
||||
@@ -28,10 +29,12 @@ Nakama="*res://addons/com.heroiclabs.nakama/Nakama.gd"
|
||||
|
||||
window/size/viewport_width=1600
|
||||
window/size/viewport_height=900
|
||||
window/size/mode=3
|
||||
window/size/window_width_override=1440
|
||||
window/size/window_height_override=810
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="expand"
|
||||
window/handheld/orientation=4
|
||||
|
||||
[editor_plugins]
|
||||
|
||||
@@ -41,3 +44,4 @@ enabled=PackedStringArray("res://addons/agent_tools/plugin.cfg", "res://addons/A
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
textures/vram_compression/import_etc2_astc=true
|
||||
@@ -27,3 +27,10 @@ Für eine öffentliche Testversion wird Port 7350 später über Nginx Proxy Mana
|
||||
## Betrieb
|
||||
|
||||
Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. Vor einem Versionswechsel werden Datenbank und Nakama-Appdata gesichert und die offiziellen Migrationshinweise geprüft.
|
||||
|
||||
## Private Statistik-RPCs
|
||||
|
||||
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
|
||||
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
|
||||
|
||||
Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
|
||||
@@ -1,9 +1,13 @@
|
||||
var GAME_VERSION = "0.23";
|
||||
var MAP_COUNT = 3;
|
||||
var MAP_COUNT = 7;
|
||||
var DIFFICULTY_COUNT = 3;
|
||||
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
||||
var MATCH_MODULE = "void_bastion_coop";
|
||||
var ROOM_COLLECTION = "private_rooms";
|
||||
var ACTIVITY_COLLECTION = "player_activity";
|
||||
var ACTIVITY_KEY = "summary";
|
||||
var STATS_COLLECTION = "internal_stats";
|
||||
var STATS_KEY = "global";
|
||||
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
||||
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
|
||||
var TICK_RATE = 10, SNAPSHOT_TICKS = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
|
||||
@@ -15,7 +19,10 @@ var PATHS = [LEFT_PATH, RIGHT_PATH];
|
||||
var TOWERS = [
|
||||
{cost:120,damage:22,range:190,cooldown:0.55},
|
||||
{cost:180,damage:12,range:165,cooldown:0.18},
|
||||
{cost:250,damage:65,range:260,cooldown:1.40}
|
||||
{cost:250,damage:65,range:260,cooldown:1.40},
|
||||
{cost:230,damage:48,range:240,cooldown:1.05},
|
||||
{cost:210,damage:34,range:215,cooldown:0.92},
|
||||
{cost:300,damage:110,range:330,cooldown:1.85}
|
||||
];
|
||||
var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}];
|
||||
var DIFFICULTIES = [
|
||||
@@ -33,6 +40,96 @@ function leaderboardId(mapIndex, difficulty, mode) { return mode + "_map" + mapI
|
||||
function rpcServerInfo(ctx, logger, nk, payload) {
|
||||
return JSON.stringify({game:"void_bastion",version:GAME_VERSION,leaderboards:MAP_COUNT*DIFFICULTY_COUNT*VALID_MODES.length,authoritative_multiplayer:true,status:"online"});
|
||||
}
|
||||
|
||||
function normalizeMetricLabel(value, fallback) {
|
||||
var normalized = String(value || "").toLowerCase().replace(/[^a-z0-9._-]/g, "").substring(0, 32);
|
||||
return normalized || fallback;
|
||||
}
|
||||
function utcDay(timestamp) {
|
||||
return new Date(timestamp).toISOString().slice(0, 10);
|
||||
}
|
||||
function readStorageRecord(nk, collection, key, userId) {
|
||||
var records = nk.storageRead([{collection:collection,key:key,userId:userId}]);
|
||||
return records.length ? records[0] : null;
|
||||
}
|
||||
function emptyStats(now) {
|
||||
return {total_players:0,platforms:{},versions:{},daily_active:{},created_at:now,updated_at:now};
|
||||
}
|
||||
function incrementMetric(metrics, key, amount) {
|
||||
metrics[key] = Math.max(0, Math.floor(Number(metrics[key] || 0)) + amount);
|
||||
if (metrics[key] === 0) delete metrics[key];
|
||||
}
|
||||
function trimDailyActivity(daily, currentDay) {
|
||||
var minimum = Date.parse(currentDay + "T00:00:00.000Z") - 35 * 86400000;
|
||||
var days = Object.keys(daily);
|
||||
for (var i = 0; i < days.length; i++) {
|
||||
if (Date.parse(days[i] + "T00:00:00.000Z") < minimum) delete daily[days[i]];
|
||||
}
|
||||
}
|
||||
function writeStats(nk, previousRecord, value) {
|
||||
var write = {collection:STATS_COLLECTION,key:STATS_KEY,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0};
|
||||
if (previousRecord && previousRecord.version) write.version = previousRecord.version;
|
||||
else write.version = "*";
|
||||
nk.storageWrite([write]);
|
||||
}
|
||||
function updatePlayerStats(nk, playerRecord, platform, version, day, now) {
|
||||
for (var attempt = 0; attempt < 3; attempt++) {
|
||||
var statsRecord = readStorageRecord(nk, STATS_COLLECTION, STATS_KEY, SYSTEM_USER_ID);
|
||||
var stats = statsRecord && statsRecord.value ? statsRecord.value : emptyStats(now);
|
||||
stats.platforms = stats.platforms || {};
|
||||
stats.versions = stats.versions || {};
|
||||
stats.daily_active = stats.daily_active || {};
|
||||
if (!playerRecord) {
|
||||
stats.total_players = Math.max(0, Math.floor(Number(stats.total_players || 0))) + 1;
|
||||
incrementMetric(stats.platforms, platform, 1);
|
||||
incrementMetric(stats.versions, version, 1);
|
||||
} else {
|
||||
var oldPlatform = normalizeMetricLabel(playerRecord.value.platform, "unknown");
|
||||
var oldVersion = normalizeMetricLabel(playerRecord.value.version, "unknown");
|
||||
if (oldPlatform !== platform) { incrementMetric(stats.platforms, oldPlatform, -1); incrementMetric(stats.platforms, platform, 1); }
|
||||
if (oldVersion !== version) { incrementMetric(stats.versions, oldVersion, -1); incrementMetric(stats.versions, version, 1); }
|
||||
}
|
||||
if (!playerRecord || String(playerRecord.value.last_seen_day || "") !== day) incrementMetric(stats.daily_active, day, 1);
|
||||
trimDailyActivity(stats.daily_active, day);
|
||||
stats.updated_at = now;
|
||||
try { writeStats(nk, statsRecord, stats); return; }
|
||||
catch (error) { if (attempt === 2) throw error; }
|
||||
}
|
||||
}
|
||||
function removePlayerStats(nk, playerRecord, now) {
|
||||
if (!playerRecord || !playerRecord.value) return;
|
||||
for (var attempt = 0; attempt < 3; attempt++) {
|
||||
var statsRecord = readStorageRecord(nk, STATS_COLLECTION, STATS_KEY, SYSTEM_USER_ID);
|
||||
if (!statsRecord || !statsRecord.value) return;
|
||||
var stats = statsRecord.value;
|
||||
stats.platforms = stats.platforms || {};
|
||||
stats.versions = stats.versions || {};
|
||||
stats.total_players = Math.max(0, Math.floor(Number(stats.total_players || 0)) - 1);
|
||||
incrementMetric(stats.platforms, normalizeMetricLabel(playerRecord.value.platform, "unknown"), -1);
|
||||
incrementMetric(stats.versions, normalizeMetricLabel(playerRecord.value.version, "unknown"), -1);
|
||||
stats.updated_at = now;
|
||||
try { writeStats(nk, statsRecord, stats); return; }
|
||||
catch (error) { if (attempt === 2) throw error; }
|
||||
}
|
||||
}
|
||||
function rpcRecordActivity(ctx, logger, nk, payload) {
|
||||
if (!ctx.userId) throw new Error("authentication required");
|
||||
var request;
|
||||
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
|
||||
var platform = normalizeMetricLabel(request.platform, "unknown");
|
||||
var version = normalizeMetricLabel(request.version, "unknown");
|
||||
var now = Date.now(), day = utcDay(now);
|
||||
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
||||
updatePlayerStats(nk, playerRecord, platform, version, day, now);
|
||||
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:{first_seen:playerRecord&&playerRecord.value?Number(playerRecord.value.first_seen||now):now,last_seen:now,last_seen_day:day,platform:platform,version:version},permissionRead:0,permissionWrite:0}]);
|
||||
return JSON.stringify({accepted:true});
|
||||
}
|
||||
function rpcPrivateStats(ctx, logger, nk, payload) {
|
||||
if (ctx.userId) throw new Error("server-to-server access required");
|
||||
var record = readStorageRecord(nk, STATS_COLLECTION, STATS_KEY, SYSTEM_USER_ID);
|
||||
var stats = record && record.value ? record.value : emptyStats(Date.now());
|
||||
return JSON.stringify(stats);
|
||||
}
|
||||
function rpcSubmitRun(ctx, logger, nk, payload) {
|
||||
if (!ctx.userId) throw new Error("authentication required");
|
||||
var run;
|
||||
@@ -79,6 +176,8 @@ function rpcUpdateDisplayName(ctx, logger, nk, payload) {
|
||||
}
|
||||
function rpcDeleteAccount(ctx, logger, nk, payload) {
|
||||
if (!ctx.userId) throw new Error("authentication required");
|
||||
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
||||
removePlayerStats(nk, playerRecord, Date.now());
|
||||
nk.accountDeleteId(ctx.userId, false);
|
||||
return JSON.stringify({accepted:true});
|
||||
}
|
||||
@@ -239,12 +338,13 @@ function updateTowers(state,delta) {
|
||||
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1;
|
||||
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; } }
|
||||
if (targetIndex<0) continue;
|
||||
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+(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;
|
||||
var damage=towerDamage(t);
|
||||
if (t.type===2) {
|
||||
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:0.38);
|
||||
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)<=76*76) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*0.45; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
|
||||
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); }
|
||||
} else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
|
||||
t.cooldown=towerCooldown(t);
|
||||
}
|
||||
@@ -294,6 +394,8 @@ function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:s
|
||||
function InitModule(ctx,logger,nk,initializer) {
|
||||
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
|
||||
initializer.registerRpc("server_info",rpcServerInfo);
|
||||
initializer.registerRpc("record_activity",rpcRecordActivity);
|
||||
initializer.registerRpc("private_stats",rpcPrivateStats);
|
||||
initializer.registerRpc("submit_run",rpcSubmitRun);
|
||||
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
|
||||
initializer.registerRpc("delete_account",rpcDeleteAccount);
|
||||
|
||||
@@ -27,6 +27,7 @@ const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
|
||||
let updatedAccount = null;
|
||||
let deletedAccount = null;
|
||||
const accountNk = {
|
||||
storageRead() { return []; },
|
||||
accountUpdateId(...args) { updatedAccount = args; },
|
||||
accountDeleteId(...args) { deletedAccount = args; }
|
||||
};
|
||||
@@ -38,6 +39,34 @@ assert.strictEqual(JSON.parse(context.rpcUpdateDisplayName({ userId: "pilot-a" }
|
||||
assert.deepStrictEqual(JSON.parse(context.rpcDeleteAccount({ userId: "pilot-a" }, logger, accountNk, "{}")), { accepted: true });
|
||||
assert.deepStrictEqual(deletedAccount, ["pilot-a", false]);
|
||||
|
||||
const metricStorage = new Map();
|
||||
let metricVersion = 0;
|
||||
const metricNk = {
|
||||
storageRead(ops) {
|
||||
return ops.map(op => metricStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean);
|
||||
},
|
||||
storageWrite(ops) {
|
||||
for (const op of ops) {
|
||||
const storageKey = `${op.collection}:${op.userId}:${op.key}`;
|
||||
const previous = metricStorage.get(storageKey);
|
||||
if (op.version === "*" && previous) throw new Error("version conflict");
|
||||
if (op.version && op.version !== "*" && (!previous || previous.version !== op.version)) throw new Error("version conflict");
|
||||
metricVersion += 1;
|
||||
metricStorage.set(storageKey, { value: JSON.parse(JSON.stringify(op.value)), version: String(metricVersion) });
|
||||
}
|
||||
}
|
||||
};
|
||||
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.2" }))), { accepted: true });
|
||||
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.2" }))), { accepted: true });
|
||||
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-b" }, logger, metricNk, JSON.stringify({ platform: "Android", version: "0.1.0-alpha.2" }))), { accepted: true });
|
||||
const privateStats = JSON.parse(context.rpcPrivateStats({}, logger, metricNk, "{}"));
|
||||
assert.strictEqual(privateStats.total_players, 2);
|
||||
assert.strictEqual(privateStats.platforms.windows, 1);
|
||||
assert.strictEqual(privateStats.platforms.android, 1);
|
||||
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
|
||||
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
|
||||
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
|
||||
|
||||
let leaderboardWrite = null;
|
||||
const leaderboardNk = {
|
||||
accountGetId() { return { user: { displayName: "Kommandant Ägir" } }; },
|
||||
|
||||
@@ -29,6 +29,51 @@ func _run() -> void:
|
||||
var ads_manager = game.get_node("AdsManager")
|
||||
var account_manager = game.get_node("AccountManager")
|
||||
var multiplayer_manager = game.get_node("MultiplayerManager")
|
||||
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_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.")
|
||||
game.set("unlocked_maps", [0, 1, 2])
|
||||
assert(game.get("TOWER_DATA").size() == 6, "Das Arsenal muss sechs unterschiedliche Turmtypen enthalten.")
|
||||
assert(game.get("tower_buttons").size() == 6, "Alle sechs Türme müssen gleichzeitig in der unteren Baumenüleiste sichtbar sein.")
|
||||
game.call("_handle_pointer", Vector2(760, 815), false)
|
||||
assert(game.get("game_mode") == "store", "Der interne Shop muss direkt aus dem Hauptmenü erreichbar sein.")
|
||||
await process_frame
|
||||
game.set("core_crystals", 24)
|
||||
game.call("_handle_pointer", game.get("store_map_tab_button").get_center(), false)
|
||||
assert(game.get("store_tab") == "maps", "Der Shop muss eine eigene Kartenseite haben.")
|
||||
game.call("_handle_pointer", game.get("map_shop_purchase_buttons")[0].get_center(), false)
|
||||
assert(game.get("unlocked_maps").has(3) and game.get("core_crystals") == 0, "Neue Karten müssen dauerhaft mit Kernkristallen freischaltbar sein.")
|
||||
game.set("unlocked_maps", [0, 1, 2])
|
||||
game.set("core_crystals", 0)
|
||||
game.call("_handle_pointer", Vector2(140, 60), false)
|
||||
assert(game.get("game_mode") == "menu", "Der Shop benötigt einen funktionierenden Zurück-Button.")
|
||||
game.call("_handle_pointer", game.get("map_buttons")[3].get_center(), false)
|
||||
assert(game.get("game_mode") == "store" and game.get("store_tab") == "maps", "Eine gesperrte Karte muss direkt den Karten-Shop öffnen.")
|
||||
game.set("core_crystals", 24)
|
||||
game.call("_handle_pointer", game.get("map_shop_purchase_buttons")[0].get_center(), false)
|
||||
assert(game.get("unlocked_maps").has(3), "Die erste zusätzliche Karte muss kaufbar sein.")
|
||||
game.call("_handle_pointer", game.get("shop_back_button").get_center(), false)
|
||||
assert(game.get("game_mode") == "menu", "Nach dem Kartenkauf muss die Kartenauswahl erreichbar bleiben.")
|
||||
game.call("_handle_pointer", game.get("map_buttons")[3].get_center(), false)
|
||||
assert(game.get("menu_step") == "difficulty" and game.get("selected_map") == 3, "Eine freigeschaltete Karte muss danach spielbar sein.")
|
||||
game.call("_handle_pointer", game.get("map_step_back_button").get_center(), false)
|
||||
game.set("unlocked_maps", [0, 1, 2])
|
||||
game.set("core_crystals", 0)
|
||||
game.call("_select_map", 0)
|
||||
game.call("_handle_pointer", game.get("start_button").get_center(), false)
|
||||
assert(game.get("game_mode") == "menu", "Die Mission darf erst nach Auswahl einer Karte gestartet werden.")
|
||||
var menu_map_index: int = game.get("selected_map")
|
||||
game.call("_handle_pointer", game.get("map_buttons")[menu_map_index].get_center(), false)
|
||||
assert(game.get("menu_step") == "difficulty", "Nach Auswahl einer Karte muss die Schwierigkeitsauswahl erscheinen.")
|
||||
game.call("_handle_pointer", game.get("map_step_back_button").get_center(), false)
|
||||
assert(game.get("menu_step") == "map", "Die Missionsvorbereitung benötigt eine Rückkehr zur Kartenauswahl.")
|
||||
game.set("unlocked_towers", [0, 1, 2])
|
||||
game.set("game_mode", "playing")
|
||||
game.call("_handle_pointer", game.get("tower_buttons")[3].get_center(), false)
|
||||
assert(game.get("game_mode") == "store", "Ein gesperrter Turm muss den Shop auch aus einer laufenden Mission öffnen.")
|
||||
game.call("_handle_pointer", game.get("shop_back_button").get_center(), false)
|
||||
assert(game.get("game_mode") == "playing", "Der Shop-Zurück-Button muss in die laufende Mission zurückführen.")
|
||||
game.set("game_mode", "menu")
|
||||
multiplayer_manager.reset_coop_session(500, 30)
|
||||
assert(multiplayer_manager.player_credits == [500, 500], "Beide Koop-Spieler benötigen getrennte, gleich hohe Startressourcen.")
|
||||
assert(multiplayer_manager.spend_player_credits(0, 120), "Spieler 1 muss aus seinem eigenen Guthaben bauen können.")
|
||||
@@ -42,7 +87,7 @@ func _run() -> void:
|
||||
assert(multiplayer_manager.matchmaking_state == "waiting_for_service", "Die Spielersuche benötigt einen sichtbaren vorbereiteten Zustand.")
|
||||
multiplayer_manager.cancel_search()
|
||||
assert(multiplayer_manager.matchmaking_state == "idle", "Eine laufende Spielersuche muss abgebrochen werden können.")
|
||||
game.call("_handle_pointer", Vector2(1100, 730), false)
|
||||
game.call("_handle_pointer", Vector2(500, 815), false)
|
||||
assert(game.get("game_mode") == "multiplayer_menu", "Der Multiplayerbereich muss direkt aus dem Hauptmenü erreichbar sein.")
|
||||
game.call("_handle_pointer", Vector2(400, 695), false)
|
||||
assert(game.get("game_mode") == "matchmaking", "Schnelles Spiel muss in den vorbereiteten Suchzustand wechseln.")
|
||||
@@ -50,7 +95,8 @@ func _run() -> void:
|
||||
assert(game.get("game_mode") == "multiplayer_menu", "Die Spielersuche muss über die sichtbare Schaltfläche abgebrochen werden können.")
|
||||
game.call("_handle_pointer", Vector2(140, 60), false)
|
||||
assert(game.get("game_mode") == "menu", "Der Multiplayerbereich benötigt einen funktionierenden Zurück-Button.")
|
||||
game.call("_handle_pointer", Vector2(1100, 730), false)
|
||||
game.set("selected_difficulty", 0)
|
||||
game.call("_handle_pointer", Vector2(500, 815), false)
|
||||
game.call("_handle_pointer", Vector2(1300, 695), false)
|
||||
assert(game.get("game_mode") == "playing" and game.get("coop_mode") and game.get("coop_ai_mode"), "Der KI-Koop-Modus muss direkt spielbar gestartet werden können.")
|
||||
assert(game.get("coop_paths").size() == 2, "Die Koop-Arena benötigt zwei gespiegelte Gegnerwege.")
|
||||
@@ -59,6 +105,7 @@ func _run() -> void:
|
||||
assert(game.get("towers").size() >= 2, "Der Computergegner muss selbstständig eine Startverteidigung bauen.")
|
||||
for ai_tower in game.get("towers"):
|
||||
assert(int(ai_tower.get("owner", -1)) == 1 and ai_tower.pos.x > 1480.0, "KI-Türme dürfen nur auf der vollständigen gespiegelten KI-Seite stehen.")
|
||||
assert(game.call("_distance_to_route", ai_tower.pos, game.get("coop_paths")[1]) <= 200.0, "KI-Türme müssen innerhalb ihrer effektiven Reichweite am Gegnerweg stehen.")
|
||||
assert(multiplayer_manager.player_credits[0] > multiplayer_manager.player_credits[1], "Die KI muss ihre Baukosten ausschließlich aus dem eigenen Guthaben bezahlen.")
|
||||
assert(game.call("_is_valid_build_position", Vector2(440, 200), 0), "Der Spieler benötigt gültige Bauplätze auf der linken Kartenhälfte.")
|
||||
assert(game.call("_is_valid_build_position", Vector2(1240, 200), 0), "Die eigene Koop-Seite muss nahezu die volle Breite einer Einzelspielerkarte bieten.")
|
||||
@@ -76,12 +123,22 @@ func _run() -> void:
|
||||
game.call("_process", 0.1)
|
||||
assert(game.get("next_enemy_id") > 3, "Beide Koop-Routen müssen auch während der laufenden Simulation Gegner erzeugen.")
|
||||
game.call("_return_to_menu")
|
||||
game.call("_handle_pointer", Vector2(400, 735), false)
|
||||
game.call("_handle_pointer", Vector2(225, 815), false)
|
||||
assert(game.get("game_mode") == "leaderboard", "Die globale Bestenliste muss direkt aus dem Hauptmenü erreichbar sein.")
|
||||
var leaderboard_map_before: int = game.get("leaderboard_map")
|
||||
game.call("_handle_pointer", Vector2(750, 175), false)
|
||||
assert(game.get("leaderboard_map") == wrapi(leaderboard_map_before + 1, 0, game.get("MAP_NAMES").size()), "Die Rangliste muss direkt nach Karte filterbar sein.")
|
||||
var leaderboard_difficulty_before: int = game.get("leaderboard_difficulty")
|
||||
game.call("_handle_pointer", Vector2(1225, 175), false)
|
||||
assert(game.get("leaderboard_difficulty") == wrapi(leaderboard_difficulty_before + 1, 0, 3), "Die Rangliste muss direkt nach Schwierigkeitsgrad filterbar sein.")
|
||||
game.call("_handle_pointer", Vector2(140, 60), false)
|
||||
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.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.")
|
||||
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.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.")
|
||||
account_manager.storage_enabled = false
|
||||
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.")
|
||||
@@ -94,16 +151,19 @@ func _run() -> void:
|
||||
assert(account_manager.record_run(0, 0, false, 3, previous_test_score + 1000), "Ein neuer lokaler Highscore muss gespeichert werden.")
|
||||
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.")
|
||||
ads_manager.missions_since_interstitial = 0
|
||||
ads_manager.last_interstitial_time = 0
|
||||
assert(not ads_manager.is_interstitial_due(1000), "Vor drei Missionen darf keine Vollbildwerbung fällig sein.")
|
||||
ads_manager.missions_since_interstitial = 3
|
||||
assert(ads_manager.is_interstitial_due(1000), "Frühestens nach drei Missionen darf eine Vollbildwerbung fällig sein.")
|
||||
ads_manager.last_interstitial_time = 800
|
||||
assert(not ads_manager.is_interstitial_due(1000), "Die 15-Minuten-Sperre zwischen Vollbildanzeigen muss greifen.")
|
||||
ads_manager.last_interstitial_time = 0
|
||||
assert(game.get("path_points").size() == 8, "Bastion Prime sollte aus sieben klaren Rasterstrecken bestehen.")
|
||||
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", "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.")
|
||||
var announced_update := {}
|
||||
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()
|
||||
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(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_grid_aligned_path(game.get("path_points"), "Bastion Prime")
|
||||
assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 0), "Bastion Prime muss exakt im sichtbaren Reaktor enden.")
|
||||
assert(_path_length(game.get("path_points")) >= 4000.0, "Die Bastion-Route sollte den sichtbaren Bauraum ausnutzen.")
|
||||
assert(game.get("camera_offset") == Vector2.ZERO, "Die ganze Karte muss ohne Verschieben sichtbar bleiben.")
|
||||
assert(game.get("path_cells").size() > 45, "Straße, Gegnerlinie und Bausperre müssen dieselbe lange Rasterzellenkette verwenden.")
|
||||
@@ -116,8 +176,9 @@ func _run() -> void:
|
||||
assert(game.call("_is_valid_build_position", Vector2(200, 680)), "Außerhalb der Wegzellen soll jede geeignete Plattformzelle bebaubar sein.")
|
||||
game.call("_select_map", 1)
|
||||
assert(game.get("selected_map") == 1, "Die zweite Karte sollte auswählbar sein.")
|
||||
assert(game.get("path_points").size() == 8, "Die Helios-Werft sollte aus sieben klaren Rasterstrecken bestehen.")
|
||||
assert(game.get("path_points").size() == 8, "Die Helios-Werft sollte direkt im sichtbaren Reaktor enden.")
|
||||
_assert_grid_aligned_path(game.get("path_points"), "Helios-Werft")
|
||||
assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 1), "Die Helios-Route muss exakt im Reaktormittelpunkt enden.")
|
||||
assert(_path_length(game.get("path_points")) >= 4400.0, "Die Werft-Route sollte mehrere Feuerlinien bilden.")
|
||||
assert(not game.call("_is_valid_build_position", Vector2(600, 280)), "Auf der oberen Helios-Fahrbahn darf nicht gebaut werden.")
|
||||
assert(game.call("_is_valid_build_position", Vector2(600, 360)), "Auf Helios muss zwischen der oberen und mittleren Fahrbahn gebaut werden können.")
|
||||
@@ -126,14 +187,29 @@ func _run() -> void:
|
||||
assert(not game.call("_is_valid_build_position", Vector2(600, 680)), "Auf der unteren Helios-Fahrbahn darf nicht gebaut werden.")
|
||||
assert(game.call("_is_in_reactor_zone", Vector2(1480, 440)), "Der Helios-Reaktor muss als Sperrzone erkannt werden.")
|
||||
assert(not game.call("_is_valid_build_position", Vector2(1480, 440)), "Auf dem Helios-Reaktor darf nicht gebaut werden.")
|
||||
for map_index in game.get("MAP_NAMES").size():
|
||||
game.call("_select_map", map_index)
|
||||
var current_path: PackedVector2Array = game.get("path_points")
|
||||
_assert_grid_aligned_path(current_path, str(game.get("MAP_NAMES")[map_index]))
|
||||
assert(game.call("_validate_route_ends_at_reactor", current_path, map_index), "Jede neue Karte muss exakt am sichtbaren Reaktor enden.")
|
||||
assert(current_path.size() >= 7, "Jede Karte braucht einen ausreichend langen, gestalteten Laufweg.")
|
||||
assert(game.call("_is_in_reactor_zone", current_path[-1]), "Der Zielpunkt muss in der Reaktorsperrzone liegen.")
|
||||
for cell in game.get("path_cells"):
|
||||
var cell_position: Vector2i = cell
|
||||
var neighbors := 0
|
||||
for direction in [Vector2i.LEFT, Vector2i.RIGHT, Vector2i.UP, Vector2i.DOWN]:
|
||||
if game.get("path_cells").has(cell_position + direction):
|
||||
neighbors += 1
|
||||
assert(neighbors <= 2, "%s hat an Rasterzelle %s eine Wegkreuzung." % [game.get("MAP_NAMES")[map_index], cell_position])
|
||||
game.call("_select_map", 0)
|
||||
assert(game.call("_is_in_reactor_zone", Vector2(1480, 440)), "Der Bastion-Reaktor muss als Sperrzone erkannt werden.")
|
||||
assert(not game.call("_is_valid_build_position", Vector2(1480, 440)), "Auf dem Bastion-Reaktor darf nicht gebaut werden.")
|
||||
assert(not game.call("_is_valid_build_position", Vector2(40, 680)), "Im freien Weltraum darf kein Bauplatz liegen.")
|
||||
game.call("_select_map", 2)
|
||||
assert(game.get("path_points").size() == 6, "Pyrax sollte aus fünf klaren Rasterstrecken bestehen.")
|
||||
assert(game.get("path_points").size() == 7, "Pyrax sollte ohne die beiden unnötigen letzten Bögen in den Reaktor führen.")
|
||||
_assert_grid_aligned_path(game.get("path_points"), "Pyrax-Raffinerie")
|
||||
assert(_path_length(game.get("path_points")) >= 2480.0, "Die Pyrax-Route sollte den Bauraum weiträumig erschließen.")
|
||||
assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 2), "Die Pyrax-Route muss exakt im Reaktormittelpunkt enden.")
|
||||
assert(_path_length(game.get("path_points")) >= 2300.0, "Die Pyrax-Route sollte den Bauraum weiträumig erschließen.")
|
||||
assert(game.call("_is_in_reactor_zone", Vector2(1440, 360)), "Der Pyrax-Hauptreaktor muss als Sperrzone erkannt werden.")
|
||||
assert(game.call("_is_in_reactor_zone", Vector2(680, 360)), "Auch die offene Pyrax-Reaktorkammer muss gesperrt sein.")
|
||||
assert(not game.call("_is_valid_build_position", Vector2(1440, 360)), "Auf dem Pyrax-Reaktor darf nicht gebaut werden.")
|
||||
@@ -146,6 +222,15 @@ func _run() -> void:
|
||||
game.set("selected_difficulty", 0)
|
||||
game.call("_start_game")
|
||||
assert(game.get("credits") == 450 and game.get("reactor_lives") == 20, "Leicht muss dem bisherigen Balancing entsprechen.")
|
||||
game.call("_handle_pointer", Vector2(1180, 45), false)
|
||||
assert(game.get("speed_dropdown_open"), "Die Spielgeschwindigkeit muss über eine Auswahl bedienbar sein.")
|
||||
game.call("_handle_pointer", Vector2(1190, 198), false)
|
||||
assert(is_equal_approx(game.get("game_speed"), 5.0), "Die Geschwindigkeitsauswahl muss bis 5× reichen.")
|
||||
game.set("game_speed", 1.0)
|
||||
game.call("_handle_pointer", Vector2(1330, 82), false)
|
||||
assert(game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox aktivierbar sein.")
|
||||
game.call("_handle_pointer", Vector2(1330, 82), false)
|
||||
assert(not game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox abschaltbar sein.")
|
||||
game.call("_handle_pointer", Vector2(1110, 45), false)
|
||||
assert(game.get("game_mode") == "menu", "Der Zurück-Button muss eine laufende Runde abbrechen und zum Kartenmenü führen.")
|
||||
assert(game.get("towers").is_empty() and game.get("enemies").is_empty(), "Beim Abbruch müssen alle Einheiten der laufenden Runde entfernt werden.")
|
||||
@@ -183,6 +268,28 @@ func _run() -> void:
|
||||
game.set("enemies_to_spawn", 2)
|
||||
game.call("_spawn_enemy")
|
||||
assert(game.get("enemies")[-1].max_hp > 100.0, "Schwer muss die Gegnerhülle deutlich verstärken.")
|
||||
assert(game.call("_enemy_health_scale", 4, false) > game.call("_enemy_health_scale", 3, false), "Die Gegnerhülle muss ab Welle 4 stärker skalieren.")
|
||||
game.set("wave", 3)
|
||||
game.call("_start_wave")
|
||||
assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.")
|
||||
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}
|
||||
game.get("enemies").append(shielded_enemy)
|
||||
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.")
|
||||
game.call("_apply_damage", shielded_enemy, 100.0, 0, 1)
|
||||
assert(is_equal_approx(shielded_enemy.hp, 872.0), "Laser müssen Schildgegner effektiv beschädigen.")
|
||||
game.get("enemies").clear()
|
||||
game.set("wave", 5)
|
||||
game.set("reactor_lives", 12)
|
||||
game.set("credits", 300)
|
||||
game.set("towers_built_this_run", 4)
|
||||
var lean_score: int = game.call("_calculate_run_score")
|
||||
game.set("towers_built_this_run", 14)
|
||||
assert(game.call("_calculate_run_score") < lean_score, "Zusätzliche Bauvorgänge müssen den Effizienz-Multiplikator senken, auch wenn Türme später verkauft werden.")
|
||||
game.set("towers_built_this_run", 4)
|
||||
game.set("credits", 500)
|
||||
assert(game.call("_calculate_run_score") > lean_score, "Verbleibende Energie muss die Highscore-Punkte erhöhen.")
|
||||
game.set("selected_difficulty", 0)
|
||||
game.call("_start_game")
|
||||
game.set("wave", 4)
|
||||
@@ -201,6 +308,10 @@ func _run() -> void:
|
||||
game.call("_update_wave", 0.1)
|
||||
assert(game.get("game_mode") == "endless_offer", "Nach Welle 15 muss die Wahl zum Endlosmodus erscheinen.")
|
||||
assert(game.get("core_crystals") == crystals_before + 5, "Eine abgeschlossene Karte muss Kernkristalle vergeben.")
|
||||
assert(not game.get("victory_reward_pending"), "Unter Windows ohne Anzeigen muss die Missionsbelohnung nicht blockiert werden.")
|
||||
game.call("_mission_complete")
|
||||
game.call("_handle_pointer", game.get("start_button").get_center(), false)
|
||||
assert(game.get("game_mode") == "menu", "Der Abschluss einer 15-Wellen-Mission muss aus dem Siegbildschirm zurück ins Hauptmenü führen, nicht neu starten.")
|
||||
game.call("_start_endless_mode")
|
||||
assert(game.get("endless_mode") and game.get("game_mode") == "playing", "Der Endlosmodus muss freiwillig startbar sein.")
|
||||
game.call("_game_over")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
class_name UpdateManager
|
||||
extends Node
|
||||
|
||||
signal update_available(info: Dictionary)
|
||||
|
||||
const RELEASES_API := "https://git.dominicspringer.de/api/v1/repos/Emporex/Void-Bastion-Releases/releases"
|
||||
const PUBLIC_RELEASE_BASE := "https://git.dominicspringer.de/Emporex/Void-Bastion-Releases/releases/tag/"
|
||||
|
||||
var _request: HTTPRequest
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if DisplayServer.get_name() == "headless" or Engine.is_editor_hint():
|
||||
return
|
||||
_request = HTTPRequest.new()
|
||||
add_child(_request)
|
||||
_request.request_completed.connect(_on_request_completed)
|
||||
call_deferred("check_for_update")
|
||||
|
||||
|
||||
func check_for_update() -> void:
|
||||
if _request == null or _request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
_request.request(RELEASES_API, ["Accept: application/json", "User-Agent: Void-Bastion/%s" % _current_version()])
|
||||
|
||||
|
||||
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var releases = JSON.parse_string(body.get_string_from_utf8())
|
||||
if not releases is Array:
|
||||
return
|
||||
for release in releases:
|
||||
if not release is Dictionary or bool(release.get("draft", false)):
|
||||
continue
|
||||
var version := str(release.get("tag_name", "")).trim_prefix("v")
|
||||
if version.is_empty() or not _is_newer_version(version, _current_version()):
|
||||
return
|
||||
var download_url := PUBLIC_RELEASE_BASE + "v" + version
|
||||
var wanted_extension := ".apk" if OS.get_name() == "Android" else ".exe"
|
||||
for asset in release.get("assets", []):
|
||||
if asset is Dictionary and str(asset.get("name", "")).to_lower().ends_with(wanted_extension):
|
||||
download_url = str(asset.get("browser_download_url", download_url))
|
||||
break
|
||||
update_available.emit({
|
||||
"version": version,
|
||||
"name": str(release.get("name", version)),
|
||||
"url": download_url
|
||||
})
|
||||
return
|
||||
|
||||
|
||||
func _current_version() -> String:
|
||||
return str(ProjectSettings.get_setting("application/config/version", "0.0.0"))
|
||||
|
||||
|
||||
func _is_newer_version(remote: String, local: String) -> bool:
|
||||
var remote_parts := _version_parts(remote)
|
||||
var local_parts := _version_parts(local)
|
||||
for index in 3:
|
||||
if remote_parts[index] != local_parts[index]:
|
||||
return remote_parts[index] > local_parts[index]
|
||||
var remote_suffix: String = remote_parts[3]
|
||||
var local_suffix: String = local_parts[3]
|
||||
if remote_suffix.is_empty():
|
||||
return not local_suffix.is_empty()
|
||||
if local_suffix.is_empty():
|
||||
return false
|
||||
return _prerelease_weight(remote_suffix) > _prerelease_weight(local_suffix)
|
||||
|
||||
|
||||
func _version_parts(value: String) -> Array:
|
||||
var normalized := value.trim_prefix("v").strip_edges().to_lower()
|
||||
var sections := normalized.split("-", true, 1)
|
||||
var numbers := sections[0].split(".")
|
||||
return [
|
||||
int(numbers[0]) if numbers.size() > 0 else 0,
|
||||
int(numbers[1]) if numbers.size() > 1 else 0,
|
||||
int(numbers[2]) if numbers.size() > 2 else 0,
|
||||
sections[1] if sections.size() > 1 else ""
|
||||
]
|
||||
|
||||
|
||||
func _prerelease_weight(value: String) -> int:
|
||||
var stage := 0
|
||||
if value.begins_with("beta"):
|
||||
stage = 100000
|
||||
elif value.begins_with("rc"):
|
||||
stage = 200000
|
||||
var sequence := 0
|
||||
for part in value.split("."):
|
||||
if part.is_valid_int():
|
||||
sequence = int(part)
|
||||
return stage + sequence
|
||||
@@ -0,0 +1 @@
|
||||
uid://n3hk842sdso8
|
||||