diff --git a/ALPHA_TEST_CHECKLIST.md b/ALPHA_TEST_CHECKLIST.md index 3c9625c..0c129da 100644 --- a/ALPHA_TEST_CHECKLIST.md +++ b/ALPHA_TEST_CHECKLIST.md @@ -38,12 +38,21 @@ Diese Liste wird fortlaufend erweitert und vor jeder öffentlichen Version abgea - [ ] **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. +## Sprache und Freundesliste + +- [x] **A** Deutsch/Englisch-Umschaltung und Freundes-/Einladungszustände bestehen den Godot-Test. +- [x] **A** Serverprüfung lehnt Einladungen ohne gegenseitige Freundschaft oder Raumhoheit ab und begrenzt Wiederholungen. +- [ ] **M** Nach Client- und Server-Update beide Sprachen vollständig durchspielen; ältere Status- und Fehlermeldungen nachübersetzen. +- [ ] **M** Auf zwei Konten Freundschaftsanfrage senden, annehmen und wieder entfernen; Status „online“ und „im Spiel“ samt Wiederverbindung prüfen. +- [ ] **M** Aus einem privaten Raum eine Einladung senden und auf dem zweiten Gerät direkt beitreten; nach Raumende darf kein alter Code mehr nutzbar sein. +- [x] **A** Datenschutzhinweise für Freundschaftsbeziehungen, Status und Systemeinladungen aktualisiert und auf der öffentlichen Seite geprüft. + ## 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. +- [x] **A** Alpha-10-Setup-Datei und SHA-256-Prüfsumme stimmen mit dem erneut heruntergeladenen Gitea-Release-Asset überein. +- [x] **A** Der Homepage-Release-Endpunkt zeigt auf Alpha 10 und die aktuelle Setup-Datei; Download-Schaltfläche im Browser noch manuell anklicken. - [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar. -- [ ] **M** Änderungen, bekannte Fehler und Testhinweise stehen in den Release-Notizen. +- [x] **A** Änderungen, bekannte Fehler und Testhinweise stehen in den Alpha-10-Release-Notizen. ## Neue offene Punkte (Stand 06.10.2026) diff --git a/DEPLOYMENT-SCHNELLWEG.md b/DEPLOYMENT-SCHNELLWEG.md index d63dca1..ca51579 100644 --- a/DEPLOYMENT-SCHNELLWEG.md +++ b/DEPLOYMENT-SCHNELLWEG.md @@ -1,6 +1,6 @@ # Void Bastion: Git- und Serververöffentlichung -Stand: 9. Oktober 2026. Diese Anleitung enthält bewusst keine Passwörter, privaten Schlüssel oder API-Tokens. +Stand: 10. Oktober 2026. Diese Anleitung enthält bewusst keine Passwörter, privaten Schlüssel, Webhook-Adressen oder API-Tokens. ## Zuständigkeiten @@ -28,7 +28,9 @@ git push origin main Vor jedem Push `git status` und `git diff --cached` prüfen. Keine Zugangsdaten committen. `intern/statistik/stats-config.local.php` bleibt ausschließlich auf dem Webhosting und ist in `.gitignore` ausgeschlossen. -Netcup-Plesk ist bereits mit dem Gitea-Repository `Emporex/Void-Bastion-Website`, Branch `main`, und dem Ziel `gergames.de/void-bastion` verbunden; der Plesk-Modus ist **manuell**. Am 9. Oktober fehlte die neue Code-Listenfunktion auf der eingeloggten Live-Seite, obwohl Gitea-Commit `3e6132e` sie enthielt und Nakama fünf Codes zurückgab. Im Gitea-Repository war kein Webhook eingerichtet. Der Stand wurde daraufhin im Netcup-Git-Bereich für genau diese Domain per „Jetzt Pull ausführen“ und „Jetzt bereitstellen“ aktualisiert. Die bereitgestellte `intern/statistik/index.php` hatte danach den neuen Zeitstempel und enthielt „Code-Übersicht“ sowie das Bemerkungsfeld; die öffentliche Login-Seite antwortete ohne Serverfehler. Die Alpha-9-Anzeige auf der Homepage kommt separat über `latest-release.php` aus der Releases-API und beweist keine PHP-Bereitstellung. Bis ein automatischer Deploy-Endpunkt verifiziert und eingerichtet ist, nach Website-Push beide Plesk-Schritte ausführen und die Live-Seite prüfen. Andere Domains oder das webhostingweite FTP/SSH-Konto nicht für automatische Uploads benutzen. +Netcup-Plesk ist mit dem Gitea-Repository `Emporex/Void-Bastion-Website`, Branch `main`, und dem Ziel `/gergames.de/void-bastion` verbunden. Seit dem 10. Oktober steht der Plesk-Bereitstellungsmodus auf **Automatisch**. Im Gitea-Repository ist der aktive Webhook „Plesk – Void Bastion Website“ eingerichtet: nur Push-Events, Branch-Filter `main`, POST mit `application/json`. Seine Plesk-Zieladresse enthält einen Zugriffswert und darf weder in Git noch in diese Doku kopiert werden. Ein Test-Event und der echte Push von Commit `3502e04` erhielten jeweils HTTP 204. Der harmlose HTML-Kommentar dieses Commits war danach auf der öffentlichen Homepage sichtbar, ohne manuelle Plesk-Aktion. Damit reicht künftig für Website-Änderungen der geprüfte `git push origin main`; anschließend die betroffene Live-Seite prüfen. Die Windows-Versionsanzeige kommt separat über `latest-release.php` aus der Releases-API und beweist keine PHP-Bereitstellung. + +Falls eine Änderung nicht live erscheint: In **diesem** Gitea-Repository unter Einstellungen → Webhooks die jüngste Zustellung und Antwort prüfen. In Plesk nur bei `void-bastion.gergames.de` den Git-Stand und das Bereitstellungsziel prüfen; notfalls dort „Jetzt Pull ausführen“ und „Jetzt bereitstellen“ manuell nutzen. Andere Domains oder das webhostingweite FTP/SSH-Konto nicht für automatische Uploads benutzen. Ein unerwünschtes Website-Update durch einen gezielten Git-Revert und erneuten Push zurücknehmen, nicht durch Änderungen an fremden Domains. Ein künftiger Wechsel des PCs benötigt den lokalen privaten Schlüssel aus einem sicheren Backup **oder** einen neu ausgestellten Deploy-Key auf Gitea. Vor der ersten Verbindung den Gitea-SSH-Host-Key erneut mit dem am Server veröffentlichten Schlüssel vergleichen (derzeit ED25519-Fingerabdruck `SHA256:WFM5G2yjTwTlJqF8OmBqx4N2O4/8L35vJ4Qu4zrC/J0` für `[192.168.178.20]:2202`). Die vollständige Verzeichnisstruktur und lokale `.git/config` lassen sich nicht allein aus einem Quellcode-ZIP wiederherstellen. @@ -43,7 +45,15 @@ Im Godot-Projektordner: Ohne `-Apply` läuft zuerst der Server-Simulationstest und dann ein SHA-256-Vergleich; es wird nichts hochgeladen. Mit `-Apply` lädt das Skript nur bei einer Änderung hoch, prüft den Hash, sichert die bisherige Serverdatei mit Zeitstempel, ersetzt sie, startet **nur** den Nakama-Container neu und wartet auf dessen `healthy`-Status. Bei einem Fehler nach der Aktivierung nicht blind erneut starten; zuerst Logs prüfen und bei Bedarf die gemeldete Sicherungsdatei wiederherstellen. Der Unraid-SSH-Zugang ist serverweit mächtig: Pfade und Containername im Skript nicht ohne Prüfung ändern. -Am 9. Oktober 2026 stimmten lokaler und aktiver Server-Bundle-Hash `2f76ec101b672912d102b607c651b105afa366e1c488e9da7fdc0187378742dc` überein. Die RPCs `create_unlock_code` und `list_unlock_codes` waren im Nakama-Startlog registriert. Die PHP-Codeverwaltung liegt bereits in Gitea-Commit `3e6132e` auf `main`; ihr Netcup-Deployment ist noch separat zu bestätigen. +Am 10. Oktober 2026 wurde für Alpha 10 das Server-Bundle mit SHA-256 `1b0a0922a1a289d7bb1ccf12269732ea57853eab9f60353757656a7c0fdd0917` aktiviert; Nakama meldete `healthy` und der Zwei-Konten-Test für Freundschaft, Status und Einladung bestand. Die RPCs `create_unlock_code`, `list_unlock_codes` und `invite_friend` sind registriert. Die PHP-Codeverwaltung aus Gitea-Commit `3e6132e` wurde am 9. Oktober manuell auf Netcup bereitgestellt; danach wurde die automatische Website-Bereitstellung eingerichtet und getestet. + +## Windows-Release und Homepage + +Den Windows-Export mit `packaging/windows/build.ps1` erstellen und die exportierte EXE vor dem Upload mit `--headless --quit` starten. Das Skript benötigt den Godot-Konsolenpfad und optional `-MakeNsisExe` mit der **echten** NSIS-Binärdatei (auf diesem PC `C:\Users\Domin\AppData\Local\Programs\NSIS-3.13\Bin\makensis.exe`; die gleichnamige Datei eine Ebene höher ist nur ein Starter). Produkt-, Installer- und Projektversion gemeinsam erhöhen. Das Skript gibt Größe und SHA-256 des Setups aus. + +Im öffentlichen Gitea-Repository `Emporex/Void-Bastion-Releases` zuerst einen **Entwurf** für den neuen Tag erstellen, dann `Void-Bastion-Setup-x64.exe` anhängen. Den hochgeladenen Entwurf mit den über Git Credential Manager gespeicherten Zugangsdaten erneut herunterladen und SHA-256 vergleichen. Erst danach den Entwurf veröffentlichen. Keine Zugangsdaten oder Token in Skripte, Dateien oder Chat kopieren. Der vorhandene Gitea-Token hat `write:repository`, aber kein `read:user`; ein `GET /api/v1/user` kann deshalb trotz funktionierendem Release-Zugriff 403 liefern. + +Die Website liest den neuesten **veröffentlichten** Release mit passendem EXE-Asset automatisch über `https://void-bastion.gergames.de/latest-release.php`; der Ingame-Updater liest die Release-Liste. `.../releases/latest` ist zur Alpha-Prüfung ungeeignet, da Gitea Vorabversionen dort nicht berücksichtigt. Nach jeder Veröffentlichung beide tatsächlichen Endpunkte und den öffentlichen Download testen. Alpha 10: `v0.1.0-alpha.10`, Setup SHA-256 `8B86ABC1EBF95252BD5C3C08156B9DD92BE6585A47E9DA323043D6483FED7287`. ## Absicherung und Wiederherstellung diff --git a/ONLINE_SETUP.md b/ONLINE_SETUP.md index 57d3cfe..fcfed40 100644 --- a/ONLINE_SETUP.md +++ b/ONLINE_SETUP.md @@ -28,6 +28,10 @@ Nach jeder erfolgreichen Anmeldung meldet der Client Plattform und App-Version a 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. +Die geschützte Statistikseite kann außerdem Freischaltcodes für alle Türme, alle Karten oder beides erstellen. `create_unlock_code` ist nur Server-zu-Server mit dem Nakama-HTTP-Schlüssel verfügbar. Codes werden über ihren SHA-256-Schlüssel nachgeschlagen; zusätzlich liegt der Klartextcode für die geschützte Admin-Übersicht in einem privaten Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0`. Das Spiel kann diese Objekte nicht lesen. `list_unlock_codes` liefert Code, Freischaltumfang, optionale Bemerkung, Erstellungszeit und Einlösungszähler ausschließlich für Server-zu-Server-Aufrufe zurück. Die Statistikseite zeigt daraus „Offen“, „Teilweise eingelöst“ oder „Eingelöst“; die Bemerkung ist optional und auf 160 Zeichen begrenzt. Hinweise können z. B. Empfänger beschreiben und sollten keine unnötigen privaten Daten enthalten. Bereits vor dieser Erweiterung erzeugte Codes behalten ihren Status, aber ihr Klartext kann nicht rekonstruiert werden. + +Pro Konto gelten höchstens zehn Codeversuche in fünf Minuten. Die Spieler lösen Codes im Menü über `redeem_unlock_code` ein; die Freischaltungen liegen am Onlinekonto und werden nach einer Anmeldung über `get_promo_unlocks` wiederhergestellt. Für die Codeverwaltung müssen sowohl das Nakama-Runtime-Bundle als auch die PHP-Statistikseite aktualisiert werden. Der Nakama-HTTP-Schlüssel bleibt ausschließlich auf Unraid und im privaten Webhosting-Verzeichnis. + 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 @@ -38,6 +42,12 @@ Vor dem Start zeigt eine gemeinsame Lobby beide Pilotennamen und ihre zugewiesen Der autoritative Matchserver verwaltet Spielerplätze, getrennte Energie, Türme, Gegner, Wellen und die gemeinsamen Reaktorleben. Beide Clients erhalten denselben geprüften Spielzustand. Der lokale KI-Koop bleibt unabhängig davon spielbar. +## Sprache und Freunde (in Entwicklung) + +Unter **Einstellungen** kann zwischen Deutsch und Englisch gewechselt werden. Die Wahl liegt in der lokalen Datei `user://void_bastion_settings.cfg` und bleibt nach einem Neustart erhalten. Hauptmenü, Kartenwahl, Koop- und Freundesbereich sowie die wichtigsten Spiel-, Shop- und Ergebnisansichten sind übersetzt. Einzelne ältere Status- und Fehlermeldungen können noch auf Deutsch erscheinen; vor einer internationalen Veröffentlichung ist ein vollständiger Sprachdurchlauf nötig. + +Die Freundesliste verwendet die Nakama-Freundschaftsfunktionen: Eine Anfrage wird über die eindeutige Spielerkennung gesendet und muss von der anderen Seite angenommen werden. Nur bestätigte Freunde zeigen Online- bzw. Im-Spiel-Status. Der Status ist grob (`menu` oder `playing`), ohne Karten-, Wellen- oder Standortdaten. Ein privater Raum kann nur vom Raumersteller an bestätigte Freunde eingeladen werden. Die Serverfunktion `invite_friend` prüft Raum und Freundschaft, begrenzt wiederholte Einladungen und sendet eine flüchtige Systembenachrichtigung mit Raumcode. Es gibt keinen Chat und keinen frei formulierten Nachrichtentext. Für diese Funktion müssen Client und Nakama-Runtime gemeinsam auf den neuen Stand gebracht werden; sie ist mit dem lokalen Test allein noch nicht live. + ## Adressen - Produktion: `https://api.void-bastion.gergames.de` und `wss://api.void-bastion.gergames.de` @@ -50,7 +60,14 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag ` ## Tests - Offline-/Gameplaytest: `godot --headless --path . --script res://tests/smoke_test.gd` +- Online-Zustands-/Interpolationstest ohne Server: `godot --headless --path . res://tests/online_snapshot_test.tscn` - echter Onlinetest im Heimnetz: `godot --headless --path . --script res://tests/online_integration_test.gd` - Produktionstest: `VOID_BASTION_TEST_HOST=api.void-bastion.gergames.de VOID_BASTION_TEST_SCHEME=https VOID_BASTION_TEST_PORT=443 godot --headless --path . --script res://tests/online_integration_test.gd` Der Onlinetest prüft zwei Gastanmeldungen, Änderung und Ablehnung von Spielernamen, die Löschung eines eigens erzeugten Wegwerf-Testkontos, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, schnelles Matchmaking, die Bereit-Lobby, autoritative Spielzustände sowie Erstellen, Verlassen und Wiederbeitreten eines privaten Raums per Code. + +Bei einem stockenden Onlinespiel schreibt der Client alle zehn Sekunden eine Zeile `ONLINE DIAG` ins Godot-Protokoll: Anzahl und maximale Lücke der Zustandspakete, Lücken über 500 ms, größte Paketgröße, maximale JSON-Lese- und Zustandsanwendungszeit. Zusätzliche Zeilen melden Bildaussetzer über 200 ms und WebSocket-Abbruch/Wiederverbindung. Eine große Paketlücke bei normaler Bildrate deutet auf Übertragung oder Server hin; eine lange Bildlücke ohne Paketlücke eher auf den Client. Für eine Fehlermeldung möglichst die Zeilen direkt vor und nach dem Hänger sichern; Spielerkennungen und Zugangsdaten werden darin nicht ausgegeben. + +Der Client fasst bei Rückstau maximal fünf eingetroffene Spielstände zusammen: Nur der neueste Zustand wird angewendet, die Schusseffekte dieser wenigen Pakete bleiben erhalten. Ältere, verworfene Zustände sind nur noch historische Zwischenbilder. `coalesced` in `ONLINE DIAG` zählt die dabei übersprungenen Zustände. Weg- und Baufeldgeometrie wird nur beim Karten- bzw. Arenenwechsel neu berechnet. Der lokale Snapshot-Test prüft außerdem, dass Geschosse nach einem langen Bildaussetzer ihr Ziel erreichen und verschwinden. + +Der Server meldet mit `ONLINE DIAG tick_gap_ms` verspätete Match-Ticks über 250 ms und mit `ONLINE DIAG loop_ms` eine Verarbeitung über 70 ms. Die Meldungen enthalten nur Ticknummer und Objektanzahl. Wenn beim Client Paketlücken auftreten, aber zur gleichen Zeit keine Tick- oder Loop-Warnung auf dem Server, liegt die Suche als Nächstes bei WebSocket-Verbindung und Reverse Proxy. diff --git a/PROJEKT-HANDBUCH-FORTSETZUNG.md b/PROJEKT-HANDBUCH-FORTSETZUNG.md index 9d6d032..799e252 100644 --- a/PROJEKT-HANDBUCH-FORTSETZUNG.md +++ b/PROJEKT-HANDBUCH-FORTSETZUNG.md @@ -1,6 +1,6 @@ # Void Bastion – Projekt- und Fortsetzungs-Handbuch -Stand: 6. Oktober 2026 · Version: 0.1.0-alpha.4 · Engine: Godot 4.7.2 +Stand: 9. 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. @@ -165,6 +165,17 @@ iOS-Export erzeugt ein Xcode-Projekt, erfordert macOS, Xcode, Zertifikate/Provis 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. +### Netcup-Dateiübertragung + +Der aktuelle, schnellere Git-/SSH-Ablauf für Website und Nakama steht in `DEPLOYMENT-SCHNELLWEG.md`. Diese Datei bei Veröffentlichungen zuerst lesen. Der Website-Quellcode wird lokal per repositorygebundenem SSH-Deploy-Key nach Gitea gepusht; Nakama wird unabhängig davon per SSH gezielt auf Unraid aktualisiert. + +- Die Website `void-bastion.gergames.de` liegt im Netcup-Webspace unter `gergames.de/void-bastion`. Änderungen nur in diesem Domain-/Ordnerbereich vornehmen; andere gehostete Seiten sind ausdrücklich außerhalb des Arbeitsumfangs. +- Beim Prüfen des Netcup-Panels am 9. Oktober 2026 zeigte der FTP-Bereich nur das Hauptkonto `hosting143098` mit Basisverzeichnis `/`. Das ist ein webhostingweiter Zugang und darf nicht für automatisierte Void-Bastion-Uploads verwendet werden, weil er auch andere Websites erreichen kann. +- Laut Netcup-Dokumentation lässt sich kein zusätzlicher FTP-Benutzer anlegen; zur Verfügung steht der SSH-Zugang. Ein separater, auf Void Bastion beschränkter FTP-Benutzer existiert daher nicht. +- Für automatisierte Übertragungen zuerst prüfen, ob SFTP über den vorhandenen SSH-Zugang aktiviert ist und ob dessen Startverzeichnis sicher auf den benötigten Webspace begrenzt ist. Bis Host, Port, Authentifizierung und zulässiger Zielpfad praktisch bestätigt sind, nichts automatisiert hochladen. +- Zugangsdaten oder private SSH-Schlüssel niemals in dieses Repository oder diese Anleitung schreiben. Für die Einrichtung nach Möglichkeit einen eigenen SSH-Schlüssel verwenden und nur lokal/geschützt speichern. Das bestehende Git-Deployment bzw. der Netcup-Dateimanager bleiben sichere Alternativen, bis SFTP getestet wurde. +- Passwörter, private Schlüssel und API-Tokens niemals in diese Anleitung, Git oder Projektdateien schreiben. Geheimnisse separat im Passwortmanager bzw. geschützten lokalen Credential Store des Betreibers ablegen. In der Anleitung dürfen nur Host, Protokoll, Port, Benutzername und begrenzter Zielpfad stehen, sofern diese Werte bestätigt wurden. + ## Weiterarbeit nach einem PC-Wechsel 1. Backupordner auf neuen PC kopieren und vorzugsweise in einen stabilen Arbeitsordner legen. diff --git a/README.md b/README.md index 1447873..19ff876 100644 --- a/README.md +++ b/README.md @@ -93,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`. -Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.8.md`. +Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.10.md`. ## Werbung und Datenschutz @@ -157,7 +157,7 @@ Die Schaltfläche **Bestenliste** im Hauptmenü zeigt die zwölf besten Piloten. 4. Freischaltmenü für neue Türme und Karten mit den bereits gespeicherten Kernkristallen 5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden 6. Private Raumcodes und die autoritative Koop-Synchronisierung auf echten Android-, iOS- und Windows-Geräten testen -7. Freundesliste und direkte Einladungen ergänzen +7. Freundesliste und direkte Einladungen auf zwei echten Clients mit der neuen Server-Runtime prüfen 8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion ## Godot Agent Tools MCP diff --git a/RELEASE_NOTES_0.1.0-alpha.10.md b/RELEASE_NOTES_0.1.0-alpha.10.md new file mode 100644 index 0000000..23f3c59 --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.10.md @@ -0,0 +1,16 @@ +# Void Bastion 0.1.0-alpha.10 + +## Neu in dieser Testversion + +- In **Einstellungen** kann zwischen Deutsch und Englisch gewechselt werden. Die Auswahl bleibt nach einem Neustart erhalten. Einzelne ältere Status- und Fehlermeldungen sind noch nicht übersetzt. +- Der Multiplayer-Bereich enthält eine **Freundesliste**. Spieler können ihre eindeutige Kennung kopieren, andere Spieler darüber hinzufügen und Anfragen bestätigen oder zurückziehen. +- Bestätigte Freunde werden als **online**, **im Spiel** oder **offline** angezeigt. Der Status wird nach einer unterbrochenen Echtzeitverbindung wiederhergestellt. +- Ersteller eines privaten Raums können bestätigten Freunden eine direkte Raumeinladung senden. Die Einladung enthält nur einen Raumcode und einen Absendernamen; es gibt keinen Chat und keine frei formulierbaren Nachrichten. +- Einladungen werden vom Server auf Raumhoheit und gegenseitige Freundschaft geprüft und gegen wiederholtes Senden begrenzt. + +## Testhinweise + +- Die Freundes- und Einladungsfunktionen benötigen die zu dieser Version passende Nakama-Serverlogik. Beide Spieler sollten Alpha 10 verwenden. +- Bitte mit zwei Konten Freundschaftsanfrage, Statuswechsel, Einladung und Beitritt testen. Eine Einladung ist flüchtig und wird nicht als Offline-Nachricht gespeichert. +- Die englischen Texte bitte in Hauptmenü, Shop, Mission und Koop prüfen; noch deutsche Meldungen können gemeldet werden. +- Dies ist weiterhin eine unsignierte Alpha-Testversion. Windows SmartScreen kann vor dem ersten Start warnen. diff --git a/RELEASE_NOTES_0.1.0-alpha.9.md b/RELEASE_NOTES_0.1.0-alpha.9.md new file mode 100644 index 0000000..e8e2a06 --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.9.md @@ -0,0 +1,15 @@ +# Void Bastion 0.1.0-alpha.9 + +## Online-Koop-Test + +- Partneranzeige und Partner-Umschalter sind in der oberen Leiste neu angeordnet; die Partnerenergie wird rechts angezeigt. +- ESC öffnet auch online das Pausenmenü. Die lokale Partie pausiert; online läuft das gemeinsame Spiel während des Menüs weiter. +- Der Server sendet Spielzustände jetzt mit 10 statt 5 Aktualisierungen pro Sekunde. +- Schüsse werden im Serverzustand mitgesendet und auf beiden Clients als passende Projektil- oder Strahleffekte dargestellt. +- Der Flakturm ist als Luftabwehr in der Serverlogik ergänzt und greift keine Bodeneinheiten an. +- Serverseitige Freischaltcodes sind für Türme, Karten oder beides enthalten; Einlösen ist angemeldeten Spielern vorbehalten und gegen Rateversuche geschützt. + +## Testhinweise + +- Godot-Headless-Start und die Nakama-Runtime-Simulation müssen vor Veröffentlichung erfolgreich durchlaufen. +- Der Online-Test benötigt zusätzlich das passende Nakama-Runtime-Bundle auf dem Server. diff --git a/export_presets.cfg b/export_presets.cfg index dd0d152..9c80164 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -24,8 +24,8 @@ 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.8" -application/product_version="0.1.0.8" +application/file_version="0.1.0.10" +application/product_version="0.1.0.10" application/company_name="GerGames" application/product_name="Void Bastion" application/file_description="Void Bastion – Alpha-Testversion" diff --git a/main.gd b/main.gd index 63e4647..24ff951 100644 --- a/main.gd +++ b/main.gd @@ -1,12 +1,123 @@ extends Node2D const SCREEN := Vector2(1600.0, 900.0) +const ENGLISH_UI := { + "PROFIL": "PROFILE", "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "SELECT A MAP • UNLOCK SECTORS IN THE SHOP", + "GESPERRT": "LOCKED", "Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen.": "Select a sector to choose difficulty and start the mission.", + "< KARTE": "< MAP", "SCHWIERIGKEIT WÄHLEN": "SELECT DIFFICULTY", "LEICHT": "EASY", "MITTEL": "MEDIUM", "SCHWER": "HARD", + "MISSION STARTEN": "START MISSION", "BESTENLISTE": "LEADERBOARD", "ARSENAL / SHOP": "ARSENAL / SHOP", "EINSTELLUNGEN": "SETTINGS", + "CODE EINLÖSEN": "REDEEM CODE", "SPIEL BEENDEN": "QUIT GAME", "MULTIPLAYER": "MULTIPLAYER", "SCHLIESSEN": "CLOSE", + "MASTER-LAUTSTÄRKE": "MASTER VOLUME", "SPRACHE": "LANGUAGE", "DEUTSCH": "GERMAN", "ENGLISCH": "ENGLISH", + "Die Einstellung wird automatisch gespeichert.": "Settings are saved automatically.", "< ZURÜCK": "< BACK", "ZURÜCK": "BACK", + "KOOPERATIVE VERTEIDIGUNG": "COOPERATIVE DEFENSE", "ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR": "TWO COMMANDERS • ONE SHARED REACTOR", + "GEMEINSAMER REAKTOR": "SHARED REACTOR", "SCHNELLES SPIEL": "QUICK MATCH", "RAUM BEITRETEN": "JOIN ROOM", + "RAUM ERSTELLEN": "CREATE ROOM", "MIT KI-PARTNER": "WITH AI PARTNER", "FREUNDESLISTE": "FRIENDS", + "FREUNDE EINLADEN": "INVITE FRIENDS", "AKTUALISIEREN": "REFRESH", "KENNUNG KOPIEREN": "COPY PLAYER ID", + "ANFRAGE SENDEN": "SEND REQUEST", "EINLADEN": "INVITE", "ENTFERNEN": "REMOVE", "ZURÜCKZIEHEN": "WITHDRAW", + "ANNEHMEN": "ACCEPT", "ONLINE": "ONLINE", "OFFLINE": "OFFLINE", "IM SPIEL": "IN GAME", + "ANFRAGE GESENDET": "REQUEST SENT", "ANFRAGE ERHALTEN": "REQUEST RECEIVED", "BLOCKIERT": "BLOCKED", + "Noch keine Freunde oder offenen Anfragen.": "No friends or pending requests yet.", + "Eindeutige Spielerkennung eingeben": "Enter unique player ID", + "Füge Freunde über ihre eindeutige Spielerkennung hinzu. Beide müssen die Anfrage bestätigen.": "Add friends using their unique player ID. Both players must confirm the request.", + "Kein Chat: Einladungen enthalten nur einen Raumcode, keine freie Nachricht.": "No chat: invites contain only a room code, never a custom message.", + "PRIVATEN RAUM BEITRETEN": "JOIN A PRIVATE ROOM", "GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN": "ENTER YOUR PARTNER'S SIX-CHARACTER CODE", + "BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL": "LETTERS AND NUMBERS • CASE DOES NOT MATTER", + "PRIVATER KOOP-RAUM": "PRIVATE CO-OP ROOM", "MITSPIELERSUCHE": "FINDING A PARTNER", "KOOP-SPIEL": "CO-OP GAME", + "NICHT BEREIT": "NOT READY", "BEREIT": "READY", "SUCHE ABBRECHEN": "CANCEL SEARCH", + "GLOBALE BESTENLISTE": "GLOBAL LEADERBOARD", "MISSION": "MISSION", "ENDLOSMODUS": "ENDLESS MODE", + "RANG": "RANK", "PILOT": "PILOT", "KARTE": "MAP", "STUFE": "LEVEL", "WELLE": "WAVE", "PUNKTE": "SCORE", + "PILOTENPROFIL": "PILOT PROFILE", "SPIELERNAME ÄNDERN": "CHANGE PLAYER NAME", "KONTO & CLOUD": "ACCOUNT & CLOUD", + "BASTION PRIME": "BASTION PRIME", "HELIOS-WERFT": "HELIOS SHIPYARD", "PYRAX-RAFFINERIE": "PYRAX REFINERY", + "NOVA-ARKOLOGIE": "NOVA ARCOLOGY", "CRYO-SCHLEUSE": "CRYO LOCK", "SOLARFORGE": "SOLAR FORGE", "VESTIGE-9": "VESTIGE-9", + "Dreifache Feuerlinie": "Triple firing line", "Langer Werft-Korridor": "Long shipyard corridor", "Gefährlicher Asteroidenring": "Dangerous asteroid ring", + "Biodom über dem Nebel": "Biodome above the nebula", "Eisige Raffinerie": "Frozen refinery", "Schmiede am Gasriesen": "Forge by the gas giant", + "Verlassene Megastruktur": "Abandoned megastructure", "VERKAUFEN": "SELL", "MAX. STUFE": "MAX LEVEL", "FREIGESCHALTET": "UNLOCKED", + "GESPERRT • %d KERNKRISTALLE": "LOCKED • %d CORE CRYSTALS", "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d": "MAP BONUS %d • BEST WAVE %02d • CORE CRYSTALS %04d", + "Bisheriges Balancing • 20 Reaktor • Kartenbonus 5": "Classic balance • 20 reactor health • map bonus 5", + "+32 % Gegnerhülle • +24 % Gegner • Kartenbonus 7": "+32% enemy armor • +24% enemies • map bonus 7", + "+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12": "+72% base armor • stronger late waves • start 340 • map bonus 12", + "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT": "GERGAMES SERVER CONNECTED • ONLINE CO-OP READY", + "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT": "CONNECTING TO THE GERGAMES SERVER AUTOMATICALLY", + "FREISCHALTEN • %d KERNKRISTALLE": "UNLOCK • %d CORE CRYSTALS", "SCH %.0f • %.2f SCHUSS/S": "DMG %.0f • %.2f SHOTS/S", + "RW %.0f • %s": "RANGE %.0f • %s", "LUFT": "AIR", "BODEN": "GROUND", "LUFT + BODEN": "AIR + GROUND", + "EINZELZIEL": "SINGLE TARGET", "FLÄCHE 76": "AREA 76", "PLASMA-FLÄCHE 92": "PLASMA AREA 92", "EMP-FLÄCHE 118 · ALLE": "EMP AREA 118 · ALL", + "PIERCING-STRAHL": "PIERCING BEAM", "LUFT-EINZELZIEL": "AIR SINGLE TARGET", + "DEINE SPIELERKENNUNG: %s": "YOUR PLAYER ID: %s", "EINLADUNG VON %s ANNEHMEN": "ACCEPT INVITE FROM %s", + "Anfrage an %s gesendet.": "Request sent to %s.", "Spielerkennung nicht gefunden oder Anfrage fehlgeschlagen.": "Player ID not found or request failed.", + "Anfrage bestätigt.": "Request accepted.", "Bestätigung fehlgeschlagen.": "Could not accept request.", + "Einladung gesendet.": "Invite sent.", "Einladung fehlgeschlagen oder gerade erst gesendet.": "Invite failed or was sent too recently.", + "Anfrage zurückgezogen.": "Request withdrawn.", "Freund entfernt.": "Friend removed.", "Aktion fehlgeschlagen.": "Action failed.", + "Spielerkennung kopiert.": "Player ID copied.", "Keine Serververbindung. Bitte später erneut versuchen.": "No server connection. Please try again later.", + "Freundesliste konnte nicht geladen werden.": "Could not load friends.", + "Privater Raum %s: Freunde mit EINLADEN direkt benachrichtigen.": "Private room %s: use INVITE to notify friends directly.", + "MITSPIELER": "PARTNER", "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN": "REACTOR HEALTH %02d • SEPARATE RESOURCES", + "ENERGIE": "ENERGY", + "VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN": "VERSION %s • GERGAMES ONLINE ACCOUNT • GLOBAL LEADERBOARDS", + "ONLINE • SUCHE NACH EINEM MITPILOTEN": "ONLINE • SEARCHING FOR A PARTNER", + "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUFGEBAUT …": "CONNECTING TO THE GERGAMES SERVER …", + "SERVER NOCH NICHT ERREICHBAR • BITTE SPÄTER ERNEUT VERSUCHEN": "SERVER UNAVAILABLE • PLEASE TRY AGAIN LATER", + "MITPILOT GEFUNDEN • WÄHLE BEREIT": "PARTNER FOUND • SELECT READY", + "BEIDE SPIELER MÜSSEN BEREIT SEIN": "BOTH PLAYERS MUST BE READY", + "VERBINDUNG WIRD WIEDERHERGESTELLT .": "RECONNECTING …", "VERBINDUNG VERLOREN • BITTE ZURÜCKKEHREN": "CONNECTION LOST • PLEASE GO BACK", + "PRIVATER RAUM WIRD ERSTELLT .": "CREATING PRIVATE ROOM …", "TEILE DEN RAUMCODE MIT DEINEM MITPILOTEN • WARTE AUF BEITRITT": "SHARE THE ROOM CODE WITH YOUR PARTNER • WAITING FOR JOIN", + "PRIVATER RAUM WIRD GESUCHT .": "FINDING PRIVATE ROOM …", "RAUM NICHT GEFUNDEN ODER BEREITS VOLL": "ROOM NOT FOUND OR FULL", + "LINKE VERTEIDIGUNG": "LEFT DEFENSE", "RECHTE VERTEIDIGUNG": "RIGHT DEFENSE", "NICHT VERBUNDEN": "DISCONNECTED", + "WARTE AUF MITPILOTEN": "WAITING FOR PARTNER", "RAUMCODE %s": "ROOM CODE %s", + "REAKTOR %02d": "REACTOR %02d", "ENERGIE %04d": "ENERGY %04d", "PARTNER": "PARTNER", "KI": "AI", + "WELLE %d STARTEN": "START WAVE %d", "%d IM ANFLUG": "%d INCOMING", "JETZT • %02d/%02d": "NOW • %02d/%02d", + "DANN • %02d": "NEXT • %02d", "DANN • --": "NEXT • --", "%d SCHIFFE": "%d UNITS", + "NÄCHSTE WELLE AUTO STARTEN": "AUTO-START NEXT WAVE", "VIDEO: +150 NOTFALLENERGIE": "VIDEO: +150 EMERGENCY ENERGY", + "ANTIPPEN • BAUPLATZ WÄHLEN": "TAP • SELECT BUILD SLOT", "GESPERRT • IM SHOP FREISCHALTEN": "LOCKED • UNLOCK IN SHOP", + "UPGRADE %d": "UPGRADE %d", "SCHILD": "SHIELD", "KOMMANDOSCHIFF": "COMMAND SHIP", + "TÜRME": "TOWERS", "KARTEN": "MAPS", "Türme und Einsatzsektoren mit Kernkristallen freischalten": "Unlock towers and sectors using core crystals", + "KERNKRISTALLE %04d": "CORE CRYSTALS %04d", "Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten.": "Unlock additional defenses permanently with core crystals.", + "GERGAMES-ARSENAL": "GERGAMES ARSENAL", "80-PX-RASTER • DIREKTZIEL REAKTOR": "80-PX GRID • DIRECT REACTOR TARGET", + "Jeder Sektor hat einen eigenen, rastergenauen Laufweg bis in den Reaktor.": "Each sector has its own grid-aligned route into the reactor.", + "SCH %.0f • %.2f/S • RW %.0f": "DMG %.0f • %.2f/S • RANGE %.0f", + "%s • STUFE %d": "%s • LEVEL %d", "%s • BAUVORSCHAU": "%s • BUILD PREVIEW", + "+%.0f SCH +%.2f/S +%.0f RW BEIM UPGRADE": "+%.0f DMG +%.2f/S +%.0f RANGE ON UPGRADE", + "MAXIMALE AUSBAUSTUFE": "MAXIMUM LEVEL", "SPIELER-ID %s": "PLAYER ID %s", + "LOKALE BESTWERTE": "LOCAL BESTS", "EINSATZSEKTOR": "SECTOR", "ENDLOS": "ENDLESS", "BESTE PUNKTE": "BEST SCORE", + "GERGAMES ONLINE VERBUNDEN": "GERGAMES ONLINE CONNECTED", "GAME CENTER / APPLE NÄCHSTE STUFE": "GAME CENTER / APPLE COMING LATER", + "GOOGLE PLAY GAMES NÄCHSTE STUFE": "GOOGLE PLAY GAMES COMING LATER", + "DATENSCHUTZ": "PRIVACY", "LÖSCHEN BESTÄTIGEN": "CONFIRM DELETION", "KONTO LÖSCHEN": "DELETE ACCOUNT", + "SPIELERNAME": "PLAYER NAME", "Dieser Name ist für Mitspieler und in Ranglisten sichtbar.": "This name is visible to partners and on leaderboards.", + "3–20 Zeichen · Buchstaben, Zahlen, Leerzeichen, _ und -": "3–20 characters · letters, numbers, spaces, _ and -", + "SPIELERNAME SPEICHERN": "SAVE PLAYER NAME", "Die Online-Partie läuft währenddessen weiter.": "The online match continues in the background.", + "Die Verteidigung ist angehalten.": "Defense is paused.", "FORTSETZEN": "RESUME", "PARTIE VERLASSEN": "LEAVE MATCH", "MISSION VERLASSEN": "LEAVE MISSION", + "REAKTOR VERLOREN": "REACTOR LOST", "Erreichte Welle: %d": "Wave reached: %d", "Highscore: %d • Effizienzbonus: ×%.2f": "Score: %d • Efficiency bonus: ×%.2f", + "NEUER VERSUCH": "TRY AGAIN", "SEKTOR GESICHERT": "SECTOR SECURED", "Alle %d Angriffswellen wurden abgewehrt.": "All %d attack waves were repelled.", + "Der Orbitalkern bleibt unter unserer Kontrolle.": "The orbital core remains under our control.", + "+%d KERNKRISTALLE • GUTHABEN %d": "+%d CORE CRYSTALS • BALANCE %d", "ZUM HAUPTMENÜ": "MAIN MENU", + "Alle %d regulären Wellen wurden abgewehrt.": "All %d regular waves were repelled.", + "Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?": "Continue in an increasingly difficult endless mode?", + "WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d": "LOADING AD • CORE CRYSTALS AFTERWARDS: %d", + "+%d Kernkristalle gutgeschrieben. Ein späteres Ende gilt nicht als gescheitert.": "+%d core crystals awarded. A later defeat does not undo this victory.", + "ENDLOSMODUS STARTEN": "START ENDLESS MODE", "MISSION BEENDEN": "FINISH MISSION", + "ENDLOSAUFTRAG BEENDET": "ENDLESS MISSION ENDED", "Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen.": "The regular sector mission remains completed.", + "KERNKRISTALLE %d": "CORE CRYSTALS %d", "NEUER EINSATZ": "NEW MISSION", + "SONDER-CODE": "SPECIAL CODE", "Gib einen persönlichen Freischaltcode ein.": "Enter a personal unlock code.", + "FREISCHALTCODE": "UNLOCK CODE", "BITTE WARTEN …": "PLEASE WAIT …", + "Spielernamen werden aus dem aktuellen öffentlichen Profil geladen.": "Player names are loaded from their current public profiles.", + "WERTUNG: WELLEN + REAKTORREST + RESTENERGIE • WENIGER GEBAUTE TÜRME ERHÖHEN DEN EFFIZIENZ-MULTIPLIKATOR": "SCORE: WAVES + REACTOR HEALTH + REMAINING ENERGY • FEWER TOWERS BUILT INCREASE EFFICIENCY", + "Zuverlässig gegen Bodenziele.": "Reliable against ground targets.", + "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen.": "Versatile laser; penetrates shields on air and ground targets.", + "Bodenraketen treffen Gegnergruppen.": "Ground rockets hit groups of enemies.", + "Mehrzweckwaffe mit reduziertem Schaden.": "Versatile weapon with reduced damage.", + "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden.": "Pulse deals 20 damage to all air and ground targets in range.", + "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie.": "Purple piercing beam hits all enemies along its path.", + "Schnelle Abfangkanone gegen gegnerische Luftschiffe.": "Rapid interceptor cannon against enemy aircraft.", + "1/3 VERTEIDIGUNG WÄHLEN": "1/3 SELECT DEFENSE", "Wähle unten das KINETIK-Geschütz.": "Select the KINETIC cannon below.", + "2/3 TURM PLATZIEREN": "2/3 PLACE TOWER", "Setze den Turm auf eine freie Metallfläche neben der Flugroute.": "Place the tower on a free metal tile beside the enemy route.", + "3/3 ANGRIFF STARTEN": "3/3 START ATTACK", "Starte oben rechts die erste Welle.": "Start the first wave in the top-right corner.", + "ÜBERSPRINGEN": "SKIP", "KINETIK": "KINETIC", "RAKETE": "ROCKET", "EMP-PULS": "EMP PULSE" +} const WORLD_SCALE := 1.0 const WORLD_SIZE := SCREEN const SAVE_PATH := "user://void_bastion_save.json" const MAX_WAVES := 15 const BOSS_WAVE_INTERVAL := 5 -const PLAYFIELD_TOP := 92.0 +const PLAYFIELD_TOP := 84.0 const PLAYFIELD_BOTTOM := 758.0 const GRID_SIZE := 80.0 const TOWER_TEXTURE := preload("res://assets/defense_towers.png") @@ -89,7 +200,8 @@ const TOWER_DATA := [ {"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": 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": 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": 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."} + {"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."}, + {"name": "FLAK", "cost": 210, "damage": 20.0, "range": 230.0, "rate": 0.42, "shot_speed": 920.0, "damage_type": "LUFT-EINZELZIEL", "target_class": "air", "color": Color("ff737c"), "unlock_cost": 0, "description": "Schnelle Abfangkanone gegen gegnerische Luftschiffe."} ] const ENEMY_DATA := [ @@ -107,6 +219,9 @@ const ENEMY_DATA := [ var path_points := PackedVector2Array() var path_cells: Dictionary = {} var build_zones: Array[Rect2] = [] +var road_tile_cache: Array[Dictionary] = [] +var build_cell_cache: Array[Dictionary] = [] +var draw_geometry_dirty := true var selected_map := 0 var selected_difficulty := 1 var camera_offset := Vector2.ZERO @@ -120,7 +235,7 @@ var wave := 0 var best_wave := 0 var best_endless_wave := 0 var core_crystals := 0 -var unlocked_towers := [0, 1, 2] +var unlocked_towers := [0, 1, 2, 6] var unlocked_maps := [0, 1, 2] var store_tab := "towers" var tutorial_completed := false @@ -170,33 +285,35 @@ var map_buttons := [ Rect2(255, 540, 340, 130), Rect2(630, 540, 340, 130), Rect2(1005, 540, 340, 130) ] var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)] -var wave_button := Rect2(1320, 18, 255, 54) -var pause_button := Rect2(1242, 18, 62, 54) -var speed_button := Rect2(1170, 18, 62, 54) -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 wave_button := Rect2(1216, 10, 359, 46) +var pause_button := Rect2(1142, 14, 62, 54) +var speed_button := Rect2(1070, 14, 62, 54) +var auto_wave_button := Rect2(1216, 58, 359, 19) +var wave_current_preview_rect := Rect2(220, 8, 258, 66) +var wave_next_preview_rect := Rect2(486, 8, 258, 66) 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 tower_buttons := [ - Rect2(15, 768, 255, 122), Rect2(278, 768, 255, 122), Rect2(541, 768, 255, 122), - Rect2(804, 768, 255, 122), Rect2(1067, 768, 255, 122), Rect2(1330, 768, 255, 122) + Rect2(13, 778, 218, 112), Rect2(238, 778, 218, 112), Rect2(463, 778, 218, 112), + Rect2(688, 778, 218, 112), Rect2(913, 778, 218, 112), Rect2(1138, 778, 218, 112), + Rect2(1363, 778, 218, 112) ] -var upgrade_button := Rect2(1275, 187, 145, 44) -var sell_button := Rect2(1430, 187, 145, 44) +var upgrade_button := Rect2(350, 698, 160, 44) +var sell_button := Rect2(518, 698, 140, 44) +var pause_resume_button := Rect2(570, 500, 220, 58) +var pause_exit_button := Rect2(810, 500, 220, 58) var tutorial_skip_button := Rect2(365, 205, 145, 30) var reward_ad_button := Rect2(1070, 690, 300, 54) -var tower_info_rect := Rect2(1275, 103, 300, 76) -var target_priority_button := Rect2(1275, 236, 300, 34) +var tower_info_rect := Rect2(16, 690, 320, 60) +var target_priority_button := Rect2(666, 698, 300, 44) var endless_button := Rect2(545, 515, 510, 70) var finish_mission_button := Rect2(625, 610, 350, 62) var profile_button := Rect2(1280, 25, 270, 52) -var leaderboard_button := Rect2(100, 790, 250, 54) -var shop_button := Rect2(640, 790, 250, 54) +var leaderboard_button := Rect2(40, 790, 200, 54) +var shop_button := Rect2(568, 790, 200, 54) var map_step_back_button := Rect2(55, 320, 170, 50) var shop_back_button := Rect2(55, 35, 185, 54) var shop_purchase_buttons := [Rect2(190, 545, 360, 62), Rect2(620, 545, 360, 62), Rect2(1050, 545, 360, 62)] @@ -220,17 +337,35 @@ var leaderboard_difficulty := 1 var leaderboard_loading := false var leaderboard_message := "" var profile_back_button := Rect2(55, 35, 185, 54) -var multiplayer_button := Rect2(370, 790, 250, 54) +var multiplayer_button := Rect2(304, 790, 200, 54) var multiplayer_back_button := Rect2(55, 35, 185, 54) var quick_match_button := Rect2(125, 665, 300, 64) var friend_match_button := Rect2(475, 665, 300, 64) var private_match_button := Rect2(825, 665, 300, 64) var ai_match_button := Rect2(1175, 665, 300, 64) +var friends_button := Rect2(600, 753, 400, 54) +var friends_back_button := Rect2(55, 35, 185, 54) +var friends_refresh_button := Rect2(1190, 35, 345, 54) +var friend_name_rect := Rect2(260, 200, 740, 58) +var friend_add_button := Rect2(1030, 200, 300, 58) +var friend_copy_button := Rect2(1030, 135, 300, 46) +var friend_action_buttons: Array[Rect2] = [] +var friend_return_mode := "multiplayer_menu" +var friends_message := "" +var friend_name_line_edit: LineEdit +var pending_match_invite: Dictionary = {} var cancel_search_button := Rect2(625, 650, 350, 64) var lobby_ready_button := Rect2(625, 580, 350, 58) var room_code_input_rect := Rect2(510, 340, 580, 90) var join_room_button := Rect2(625, 525, 350, 64) -var coop_side_button := Rect2(920, 18, 140, 54) +var coop_side_button := Rect2(916, 14, 140, 54) +var online_projectiles: Array[Dictionary] = [] +var online_snapshot_elapsed := 0.0 +var online_snapshot_interval := 0.1 +var online_frame_stall_count := 0 +var online_frame_stall_report_ms := 0 +var online_apply_max_us := 0 +var online_apply_report_ms := 0 var room_code_input := "" var profile_name_input := "" var profile_message := "" @@ -247,11 +382,21 @@ var available_update: Dictionary = {} var update_notice_visible := false var audio_settings_open := false var master_volume := 0.65 -var audio_settings_button := Rect2(910, 790, 250, 54) -var exit_button := Rect2(1180, 790, 250, 54) -var audio_panel_rect := Rect2(450, 325, 700, 270) -var audio_close_button := Rect2(1010, 345, 110, 44) +var audio_settings_button := Rect2(832, 790, 200, 54) +var promo_code_button := Rect2(1096, 790, 200, 54) +var exit_button := Rect2(1360, 790, 200, 54) +var promo_code_input_rect := Rect2(500, 335, 600, 64) +var promo_code_redeem_button := Rect2(650, 460, 300, 58) +var promo_code_cancel_button := Rect2(55, 35, 185, 54) +var promo_code_line_edit: LineEdit +var promo_code_message := "" +var promo_code_pending := false +var audio_panel_rect := Rect2(450, 280, 700, 355) +var audio_close_button := Rect2(1010, 300, 110, 44) var audio_slider_rect := Rect2(565, 460, 520, 32) +var language_button_de := Rect2(565, 540, 240, 42) +var language_button_en := Rect2(825, 540, 240, 42) +var ui_language := "de" @onready var ads_manager = $AdsManager @onready var account_manager = $AccountManager @@ -280,20 +425,194 @@ func _ready() -> void: multiplayer_manager.online_snapshot_updated.connect(_apply_online_snapshot) multiplayer_manager.online_event_received.connect(_handle_online_event) update_manager.update_available.connect(_on_update_available) + if not online_manager.authenticated.is_connected(_on_online_authenticated): + online_manager.authenticated.connect(_on_online_authenticated) + online_manager.friends_changed.connect(queue_redraw) + online_manager.match_invite_received.connect(_on_match_invite_received) + _create_promo_code_input() + _create_friend_name_input() queue_redraw() +func _create_friend_name_input() -> void: + friend_name_line_edit = LineEdit.new() + friend_name_line_edit.position = friend_name_rect.position + Vector2(10, 3) + friend_name_line_edit.size = friend_name_rect.size - Vector2(20, 6) + friend_name_line_edit.max_length = 40 + friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben") + friend_name_line_edit.text_submitted.connect(func(_value: String) -> void: _add_friend_from_input()) + add_child(friend_name_line_edit) + friend_name_line_edit.hide() + + +func _on_match_invite_received(invite: Dictionary) -> void: + pending_match_invite = invite + _show_status("EINLADUNG VON %s" % String(invite.get("from", "MITSPIELER"))) + queue_redraw() + + +func _open_friends(return_mode: String) -> void: + friend_return_mode = return_mode + game_mode = "friends" + friend_name_line_edit.show() + friends_message = "" + _refresh_friends() + queue_redraw() + + +func _close_friends() -> void: + friend_name_line_edit.hide() + game_mode = friend_return_mode + queue_redraw() + + +func _refresh_friends() -> void: + if not online_manager.is_authenticated(): + friends_message = _ui("Keine Serververbindung. Bitte später erneut versuchen.") + return + var ok: bool = await online_manager.refresh_friends() + if not ok: + friends_message = _ui("Freundesliste konnte nicht geladen werden.") + queue_redraw() + + +func _add_friend_from_input() -> void: + var username := friend_name_line_edit.text.strip_edges() + if username.is_empty(): + return + var ok: bool = await online_manager.add_friend_by_username(username) + friends_message = _ui("Anfrage an %s gesendet.") % username if ok else _ui("Spielerkennung nicht gefunden oder Anfrage fehlgeschlagen.") + if ok: + friend_name_line_edit.clear() + queue_redraw() + + +func _friend_row_action(friend: Dictionary) -> void: + var state := int(friend.get("state", -1)) + var user_id := String(friend.get("id", "")) + if state == 2: + friends_message = _ui("Anfrage bestätigt.") if await online_manager.accept_friend(user_id) else _ui("Bestätigung fehlgeschlagen.") + elif state == 0 and _can_invite_friends(): + friends_message = _ui("Einladung gesendet.") if await online_manager.invite_friend(user_id, multiplayer_manager.private_room_code) else _ui("Einladung fehlgeschlagen oder gerade erst gesendet.") + elif state == 1: + friends_message = _ui("Anfrage zurückgezogen.") if await online_manager.remove_friend(user_id) else _ui("Aktion fehlgeschlagen.") + elif state == 0: + friends_message = _ui("Freund entfernt.") if await online_manager.remove_friend(user_id) else _ui("Aktion fehlgeschlagen.") + queue_redraw() + + +func _can_invite_friends() -> bool: + return friend_return_mode == "matchmaking" and multiplayer_manager.matchmaking_mode == "private_host" and multiplayer_manager.matchmaking_state == "hosting" and not multiplayer_manager.private_room_code.is_empty() + + +func _create_promo_code_input() -> void: + promo_code_line_edit = LineEdit.new() + promo_code_line_edit.position = promo_code_input_rect.position + Vector2(14, 5) + promo_code_line_edit.size = promo_code_input_rect.size - Vector2(28, 10) + promo_code_line_edit.max_length = 24 + promo_code_line_edit.placeholder_text = "CODE EINGEBEN" + promo_code_line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER + promo_code_line_edit.add_theme_color_override("font_color", Color("e8fbff")) + promo_code_line_edit.add_theme_color_override("font_placeholder_color", Color("7699a8")) + var input_style := StyleBoxFlat.new() + input_style.bg_color = Color("081a27") + input_style.border_color = Color("4ab8d1") + input_style.set_border_width_all(1) + input_style.set_content_margin_all(10) + promo_code_line_edit.add_theme_stylebox_override("normal", input_style) + promo_code_line_edit.add_theme_stylebox_override("focus", input_style) + promo_code_line_edit.text_changed.connect(func(value: String) -> void: + var normalized := value.to_upper().replace(" ", "") + if value != normalized: + promo_code_line_edit.text = normalized + promo_code_line_edit.caret_column = normalized.length() + ) + promo_code_line_edit.text_submitted.connect(func(_value: String) -> void: + _submit_promo_code() + ) + add_child(promo_code_line_edit) + promo_code_line_edit.hide() + if online_manager.is_authenticated(): + _on_online_authenticated("", "") + + +func _on_online_authenticated(_user_id: String, _username: String) -> void: + var entitlements: Dictionary = await online_manager.get_promo_unlocks() + _apply_promo_unlocks(entitlements) + + +func _apply_promo_unlocks(entitlements: Dictionary) -> void: + if bool(entitlements.get("all_towers", false)): + unlocked_towers = [] + for tower_type in TOWER_DATA.size(): + unlocked_towers.append(tower_type) + if bool(entitlements.get("all_maps", false)): + unlocked_maps = [] + for map_index in MAP_NAMES.size(): + unlocked_maps.append(map_index) + _save_progress() + queue_redraw() + + +func _submit_promo_code() -> void: + if promo_code_pending: + return + if not online_manager.is_authenticated(): + promo_code_message = "Für das Einlösen ist eine Internetverbindung erforderlich." + queue_redraw() + return + var code := promo_code_line_edit.text.strip_edges().to_upper().replace("-", "") + if code.is_empty(): + promo_code_message = "Bitte gib einen Code ein." + queue_redraw() + return + promo_code_pending = true + promo_code_message = "CODE WIRD GEPRÜFT …" + queue_redraw() + var result: Dictionary = await online_manager.redeem_unlock_code(code) + promo_code_pending = false + if bool(result.get("accepted", false)): + _apply_promo_unlocks(result) + var reward := str(result.get("reward", "")) + promo_code_message = "Freigeschaltet: %s" % _promo_reward_label(reward) + promo_code_line_edit.clear() + else: + match str(result.get("error", "invalid")): + "offline": promo_code_message = "Server nicht erreichbar. Bitte später erneut versuchen." + "already_redeemed": promo_code_message = "Dieser Code wurde bereits eingelöst." + "exhausted": promo_code_message = "Dieser Code wurde bereits vollständig verwendet." + "rate_limited": promo_code_message = "Zu viele Versuche. Bitte warte kurz." + _: promo_code_message = "Code ungültig oder nicht mehr verfügbar." + queue_redraw() + + +func _promo_reward_label(reward: String) -> String: + match reward: + "all_towers": return "alle Türme" + "all_maps": return "alle Karten" + "all": return "alle Türme und Karten" + _: return "Bonus" + + func _process(delta: float) -> void: elapsed += delta + if coop_online_mode and delta > 0.2: + online_frame_stall_count += 1 + var now_ms := Time.get_ticks_msec() + if now_ms - online_frame_stall_report_ms > 10000: + print("ONLINE DIAG frame_stall_ms=%d count=%d" % [roundi(delta * 1000.0), online_frame_stall_count]) + online_frame_stall_report_ms = now_ms + online_frame_stall_count = 0 if status_timer > 0.0: status_timer -= delta - if game_mode == "playing": + if game_mode == "playing" or (game_mode == "paused" and coop_online_mode): if auto_wave_countdown > 0.0: auto_wave_countdown -= delta if auto_wave_countdown <= 0.0 and auto_start_waves and not wave_active and (endless_mode or wave < MAX_WAVES): _start_wave() if coop_online_mode: _update_online_interpolation(delta) + _update_online_projectiles(delta) _update_effects(delta) else: var simulation_delta := delta * game_speed @@ -443,7 +762,7 @@ func _tower_target_label(tower_type: int) -> String: match _tower_target_class(tower_type): "air": return "LUFT" "ground": return "BODEN" - _: return "BEIDE" + _: return "LUFT + BODEN" func _set_enemy_strength_multiplier(value: float) -> void: @@ -681,6 +1000,11 @@ func _unhandled_input(event: InputEvent) -> void: 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 game_mode == "promo_code": + game_mode = "menu" + promo_code_line_edit.hide() + queue_redraw() + return if audio_settings_open: audio_settings_open = false _save_audio_settings() @@ -727,6 +1051,9 @@ func _unhandled_input(event: InputEvent) -> void: room_code_input += character queue_redraw() return + if game_mode == "friends" and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + _close_friends() + return if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_P, KEY_ESCAPE] and game_mode in ["playing", "paused"]: _toggle_pause() return @@ -764,12 +1091,21 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position): audio_settings_open = false _save_audio_settings() + elif language_button_de.has_point(position): + _set_ui_language("de") + elif language_button_en.has_point(position): + _set_ui_language("en") elif audio_slider_rect.grow(12.0).has_point(position): _set_master_volume((position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x) _save_audio_settings() queue_redraw() return if game_mode == "menu": + if not pending_match_invite.is_empty() and Rect2(470, 226, 660, 54).has_point(position): + room_code_input = String(pending_match_invite.get("room_code", "")) + pending_match_invite.clear() + _join_entered_room() + return if update_notice_visible and update_button.has_point(position): OS.shell_open(str(available_update.get("url", "https://void-bastion.gergames.de/"))) return @@ -788,6 +1124,13 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if exit_button.has_point(position): get_tree().quit() return + if promo_code_button.has_point(position): + game_mode = "promo_code" + promo_code_message = "" + promo_code_line_edit.show() + promo_code_line_edit.grab_focus() + queue_redraw() + return if shop_button.has_point(position): store_return_mode = "menu" store_tab = "towers" @@ -827,6 +1170,16 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: game_mode = "multiplayer_menu" queue_redraw() return + if game_mode == "promo_code": + if promo_code_cancel_button.has_point(position): + game_mode = "menu" + promo_code_line_edit.hide() + elif promo_code_redeem_button.has_point(position): + _submit_promo_code() + elif promo_code_input_rect.has_point(position): + promo_code_line_edit.grab_focus() + queue_redraw() + return if game_mode == "store": if shop_back_button.has_point(position): game_mode = store_return_mode @@ -879,6 +1232,11 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: queue_redraw() return if game_mode == "multiplayer_menu": + if not pending_match_invite.is_empty() and Rect2(470, 585, 660, 55).has_point(position): + room_code_input = String(pending_match_invite.get("room_code", "")) + pending_match_invite.clear() + _join_entered_room() + return if multiplayer_back_button.has_point(position): game_mode = "menu" elif quick_match_button.has_point(position): @@ -889,6 +1247,25 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: _open_private_room() elif ai_match_button.has_point(position): _start_coop_ai_game() + elif friends_button.has_point(position): + _open_friends("multiplayer_menu") + queue_redraw() + return + if game_mode == "friends": + if friends_back_button.has_point(position): + _close_friends() + elif friends_refresh_button.has_point(position): + _refresh_friends() + elif friend_add_button.has_point(position): + _add_friend_from_input() + elif friend_copy_button.has_point(position) and online_manager.session != null: + DisplayServer.clipboard_set(String(online_manager.session.username)) + friends_message = _ui("Spielerkennung kopiert.") + else: + for index in mini(friend_action_buttons.size(), online_manager.friends.size()): + if friend_action_buttons[index].has_point(position): + _friend_row_action(online_manager.friends[index]) + break queue_redraw() return if game_mode == "room_code_entry": @@ -904,6 +1281,8 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if game_mode == "matchmaking": if lobby_ready_button.has_point(position) and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"]: multiplayer_manager.toggle_lobby_ready() + elif friends_button.has_point(position) and multiplayer_manager.matchmaking_mode == "private_host": + _open_friends("matchmaking") elif cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): multiplayer_manager.cancel_search() game_mode = "multiplayer_menu" @@ -939,8 +1318,12 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: _save_profile_name() queue_redraw() return - if back_button.has_point(position): - _return_to_menu() + if game_mode == "paused": + if pause_button.has_point(position) or pause_resume_button.has_point(position): + _toggle_pause() + elif pause_exit_button.has_point(position): + _return_to_menu() + queue_redraw() return if coop_mode and coop_side_button.has_point(position): _toggle_coop_side() @@ -965,7 +1348,7 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if not tutorial_completed and tutorial_skip_button.has_point(position): _complete_tutorial() return - if ads_manager.can_offer_rewarded() and reward_ad_button.has_point(position): + if selected_tower_type < 0 and selected_tower_index < 0 and ads_manager.can_offer_rewarded() and reward_ad_button.has_point(position): ads_manager.request_rewarded_energy() return if pause_button.has_point(position): @@ -1027,6 +1410,8 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if sell_button.has_point(position): _sell_selected() return + if tower_info_rect.has_point(position): + return var world_position := position + camera_offset var existing := _tower_at(world_position) if existing >= 0: @@ -1059,6 +1444,7 @@ func _select_map(index: int) -> void: build_zones.clear() for zone in MAP_BUILD_ZONES[selected_map]: build_zones.append(Rect2(zone.position * WORLD_SCALE, zone.size * WORLD_SCALE)) + draw_geometry_dirty = true camera_offset = Vector2.ZERO queue_redraw() @@ -1074,6 +1460,7 @@ func _configure_coop_arena() -> void: for route in coop_paths: _add_path_cells(route) build_zones = [Rect2(120, PLAYFIELD_TOP, 1280, PLAYFIELD_BOTTOM - PLAYFIELD_TOP), Rect2(1560, PLAYFIELD_TOP, 1280, PLAYFIELD_BOTTOM - PLAYFIELD_TOP)] + draw_geometry_dirty = true camera_offset = Vector2.ZERO if left[-1].distance_to(COOP_REACTOR_CENTER) > 1.0 or right[-1].distance_to(COOP_REACTOR_CENTER) > 1.0: push_error("Koop-Routen müssen sichtbar im gemeinsamen Reaktor enden.") @@ -1101,6 +1488,7 @@ func _toggle_coop_side() -> void: func _start_coop_ai_game() -> void: + online_manager.set_presence_status("playing") coop_mode = true coop_ai_mode = true coop_online_mode = false @@ -1115,6 +1503,7 @@ func _start_coop_ai_game() -> void: towers.clear() enemies.clear() projectiles.clear() + online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 @@ -1135,6 +1524,7 @@ func _start_coop_ai_game() -> void: func _start_coop_online_game(player_slot: int) -> void: + online_manager.set_presence_status("playing") local_player_slot = clampi(player_slot, 0, 1) coop_mode = true coop_ai_mode = false @@ -1144,6 +1534,7 @@ func _start_coop_online_game(player_slot: int) -> void: towers.clear() enemies.clear() projectiles.clear() + online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 @@ -1264,7 +1655,7 @@ func _delete_profile_account() -> void: best_wave = 0 best_endless_wave = 0 core_crystals = 0 - unlocked_towers = [0, 1, 2] + unlocked_towers = [0, 1, 2, 6] unlocked_maps = [0, 1, 2] _save_progress() profile_message = "Online-Konto und lokale Profildaten gelöscht. Ein neues Gastprofil wurde erstellt." @@ -1298,6 +1689,7 @@ func _refresh_leaderboard() -> void: func _start_game() -> void: + online_manager.set_presence_status("playing") coop_online_mode = false credits = int(_difficulty().start_credits) reactor_lives = int(_difficulty().lives) @@ -1307,6 +1699,7 @@ func _start_game() -> void: towers.clear() enemies.clear() projectiles.clear() + online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 @@ -1326,6 +1719,7 @@ func _start_game() -> void: func _return_to_menu() -> void: + online_manager.set_presence_status("menu") if coop_online_mode: online_manager.leave_current_match() game_mode = "menu" @@ -1336,6 +1730,7 @@ func _return_to_menu() -> void: towers.clear() enemies.clear() projectiles.clear() + online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 @@ -1372,7 +1767,11 @@ func _start_wave() -> void: func _toggle_pause() -> void: if coop_online_mode: - _show_status("ONLINE-PARTIEN KÖNNEN NICHT PAUSIERT WERDEN") + if game_mode == "playing": + game_mode = "paused" + _show_status("ONLINE-PARTIE LÄUFT WÄHREND DER PAUSE WEITER") + elif game_mode == "paused": + game_mode = "playing" return if game_mode == "playing": game_mode = "paused" @@ -1601,7 +2000,7 @@ func _tower_shots_per_second(type: int, level: int) -> float: func _tower_aim_angle(type: int, origin: Vector2, target: Vector2) -> float: - return origin.angle_to_point(target) + (PI if type in [0, 3, 5] else 0.0) + return origin.angle_to_point(target) + (PI if type in [0, 3, 5, 6] else 0.0) func _tower_at(position: Vector2, radius := 52.0) -> int: @@ -1700,11 +2099,12 @@ func _run_ai_builder(initial_setup: bool) -> void: if int(tower.get("owner", 0)) == 1: ai_tower_count += 1 while ai_tower_count < desired_towers: - var tower_type := ai_tower_count % TOWER_DATA.size() + var preferred_category := _ai_priority_category() + var tower_type := _ai_choose_tower_type(preferred_category) var cost: int = int(TOWER_DATA[tower_type].cost) if _player_credits(1) < cost: break - var build_position := _find_ai_build_position(ai_tower_count) + var build_position := _find_ai_build_position(ai_tower_count, tower_type) if build_position == Vector2.ZERO: break _spend_credits(1, cost) @@ -1715,13 +2115,69 @@ func _run_ai_builder(initial_setup: bool) -> void: _upgrade_ai_tower() -func _find_ai_build_position(offset: int) -> Vector2: +func _ai_priority_category() -> String: + var incoming := {"air": 0.0, "ground": 0.0} + var first_wave := maxi(1, wave) + var weights := [1.0, 0.65, 0.35] + for offset in weights.size(): + var forecast_wave := first_wave + offset + if forecast_wave > MAX_WAVES and not endless_mode: + continue + for enemy_type in _wave_preview_types(forecast_wave): + var category := _enemy_category(int(enemy_type)) + incoming[category] = float(incoming[category]) + float(weights[offset]) + var air_defense := 0.0 + var ground_defense := 0.0 + for tower in towers: + if int(tower.get("owner", 0)) != 1: + continue + var type := int(tower.type) + var dps := _tower_damage(type, int(tower.get("level", 1))) * _tower_shots_per_second(type, int(tower.get("level", 1))) + match _tower_target_class(type): + "air": air_defense += dps + "ground": ground_defense += dps + _: + var both_scale := float(TOWER_DATA[type].get("both_damage_scale", 1.0)) + air_defense += dps * both_scale * 0.62 + ground_defense += dps * both_scale * 0.62 + var air_need := float(incoming.air) / maxf(1.0, air_defense) + var ground_need := float(incoming.ground) / maxf(1.0, ground_defense) + if incoming.air <= 0.0: + return "ground" + if incoming.ground <= 0.0: + return "air" + return "air" if air_need >= ground_need else "ground" + + +func _ai_choose_tower_type(category: String) -> int: + var best_type := 1 + var best_score := -INF + for tower_type in TOWER_DATA.size(): + var target_class := _tower_target_class(tower_type) + if target_class != category and target_class != "both": + continue + var data: Dictionary = TOWER_DATA[tower_type] + var level := 1 + var dps := _tower_damage(tower_type, level) * _tower_shots_per_second(tower_type, level) + if target_class == "both": + dps *= float(data.get("both_damage_scale", 1.0)) * 0.72 + if str(data.damage_type).contains("FLÄCHE") or str(data.damage_type).contains("EMP-FLÄCHE"): + dps *= 1.18 + var specialization_bonus := 1.18 if target_class == category else 1.0 + var score := dps * specialization_bonus / maxf(1.0, float(data.cost)) + if score > best_score: + best_score = score + best_type = tower_type + return best_type + + +func _find_ai_build_position(offset: int, tower_type: int) -> Vector2: if coop_paths.size() < 2: return Vector2.ZERO var route: PackedVector2Array = coop_paths[1] var desired_towers := mini(9, 2 + int(wave / 2)) var target_progress := _route_length(route) * float(offset + 1) / float(desired_towers + 1) - var tower_range: float = TOWER_DATA[offset % TOWER_DATA.size()].range + var tower_range: float = TOWER_DATA[tower_type].range var best_position := Vector2.ZERO var best_score := INF for x in range(1560, 2841, 80): @@ -1795,6 +2251,9 @@ func _local_credit_slot() -> int: func _apply_online_snapshot(snapshot: Dictionary) -> void: if not coop_online_mode or snapshot.is_empty(): return + var apply_start_us := Time.get_ticks_usec() + online_snapshot_interval = clampf(online_snapshot_elapsed, 0.08, 0.2) if online_snapshot_elapsed > 0.0 else 0.1 + online_snapshot_elapsed = 0.0 selected_difficulty = clampi(int(snapshot.get("difficulty", selected_difficulty)), 0, DIFFICULTY_DATA.size() - 1) if bool(snapshot.get("connection_interrupted", false)): _show_status("MITPILOT GETRENNT • WIEDERBEITRITT %d S" % int(snapshot.get("reconnect_seconds", 0))) @@ -1807,56 +2266,92 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void: online_wave_types_remaining.append(int(enemy_type)) credits = _player_credits(local_player_slot) - var previous_enemy_positions: Dictionary = {} + var previous_enemies: Dictionary = {} for enemy in enemies: - previous_enemy_positions[int(enemy.get("id", -1))] = enemy.get("pos", Vector2.ZERO) + previous_enemies[int(enemy.get("id", -1))] = enemy var synced_enemies: Array[Dictionary] = [] for entry in snapshot.get("enemies", []): if not entry is Dictionary: continue var enemy_id := int(entry.get("id", -1)) var target_position := Vector2(float(entry.get("x", 0.0)), float(entry.get("y", 0.0))) - synced_enemies.append({ - "id": enemy_id, - "type": int(entry.get("type", 0)), - "pos": previous_enemy_positions.get(enemy_id, target_position), - "target_pos": target_position, - "path_index": int(entry.get("path_index", 1)), - "route_id": int(entry.get("route_id", 0)), - "hp": float(entry.get("hp", 1.0)), - "max_hp": float(entry.get("max_hp", 1.0)), - "speed": float(entry.get("speed", 0.0)), - "reward": int(entry.get("reward", 0)), - "angle": float(entry.get("angle", 0.0)), - "flash": 0.0, - "boss": bool(entry.get("boss", false)), - "size_scale": 1.34 if bool(entry.get("boss", false)) else 1.0 - }) + var enemy: Dictionary = previous_enemies.get(enemy_id, {}) + if enemy.is_empty(): + enemy = {"id": enemy_id, "pos": target_position, "flash": 0.0} + enemy["from_pos"] = enemy.get("pos", target_position) + enemy["target_pos"] = target_position + enemy["type"] = int(entry.get("type", 0)) + enemy["path_index"] = int(entry.get("path_index", 1)) + enemy["route_id"] = int(entry.get("route_id", 0)) + enemy["hp"] = float(entry.get("hp", 1.0)) + enemy["max_hp"] = float(entry.get("max_hp", 1.0)) + enemy["shield"] = bool(entry.get("shield", false)) + enemy["shield_hp"] = float(entry.get("shield_hp", 0.0)) + enemy["max_shield_hp"] = float(entry.get("max_shield_hp", 0.0)) + enemy["angle"] = float(entry.get("angle", 0.0)) + enemy["boss"] = bool(entry.get("boss", false)) + enemy["size_scale"] = 1.34 if enemy["boss"] else 1.0 + synced_enemies.append(enemy) enemies = synced_enemies + var previous_towers: Dictionary = {} + for tower in towers: + previous_towers[int(tower.get("id", -1))] = tower var synced_towers: Array[Dictionary] = [] for entry in snapshot.get("towers", []): if entry is Dictionary: - synced_towers.append({ - "id": int(entry.get("id", -1)), - "pos": Vector2(float(entry.get("x", 0.0)), float(entry.get("y", 0.0))), - "type": int(entry.get("type", 0)), - "level": int(entry.get("level", 1)), - "owner": int(entry.get("owner", 0)), - "cooldown": float(entry.get("cooldown", 0.0)), - "angle": float(entry.get("angle", 0.0)) - }) + var tower_id := int(entry.get("id", -1)) + var tower: Dictionary = previous_towers.get(tower_id, {}) + tower["id"] = tower_id + tower["pos"] = Vector2(float(entry.get("x", 0.0)), float(entry.get("y", 0.0))) + tower["type"] = int(entry.get("type", 0)) + tower["level"] = int(entry.get("level", 1)) + tower["owner"] = int(entry.get("owner", 0)) + tower["angle"] = float(entry.get("angle", 0.0)) + synced_towers.append(tower) towers = synced_towers + for fired_shot in snapshot.get("shots", []): + if not fired_shot is Dictionary: + continue + var tower_type := clampi(int(fired_shot.get("type", 0)), 0, TOWER_DATA.size() - 1) + var shot_from := Vector2(float(fired_shot.get("from_x", 0.0)), float(fired_shot.get("from_y", 0.0))) + var shot_to := Vector2(float(fired_shot.get("to_x", 0.0)), float(fired_shot.get("to_y", 0.0))) + var shot_color: Color = TOWER_DATA[tower_type].color + if tower_type in [1, 5]: + beams.append({"from": shot_from, "to": shot_to, "ttl": 0.12 if tower_type == 1 else 0.16, "color": shot_color, "piercing": tower_type == 5}) + else: + online_projectiles.append({"pos": shot_from, "target": shot_to, "speed": float(TOWER_DATA[tower_type].shot_speed), "type": tower_type, "color": shot_color}) if selected_tower_index >= towers.size(): selected_tower_index = -1 if bool(snapshot.get("game_over", false)) and game_mode == "playing": _game_over() + online_apply_max_us = maxi(online_apply_max_us, Time.get_ticks_usec() - apply_start_us) + var now_ms := Time.get_ticks_msec() + if online_apply_report_ms == 0: + online_apply_report_ms = now_ms + elif now_ms - online_apply_report_ms >= 10000: + print("ONLINE DIAG max_apply_us=%d enemies=%d towers=%d" % [online_apply_max_us, enemies.size(), towers.size()]) + online_apply_max_us = 0 + online_apply_report_ms = now_ms func _update_online_interpolation(delta: float) -> void: - var weight := clampf(delta * 12.0, 0.0, 1.0) + online_snapshot_elapsed += delta + var weight := clampf(online_snapshot_elapsed / online_snapshot_interval, 0.0, 1.0) for enemy in enemies: - enemy.pos = enemy.pos.lerp(enemy.get("target_pos", enemy.pos), weight) + enemy.pos = (enemy.get("from_pos", enemy.pos) as Vector2).lerp(enemy.get("target_pos", enemy.pos), weight) + + +func _update_online_projectiles(delta: float) -> void: + for index in range(online_projectiles.size() - 1, -1, -1): + var shot: Dictionary = online_projectiles[index] + var target: Vector2 = shot.target + var step := maxf(0.0, float(shot.speed) * delta) + if shot.pos.distance_to(target) <= maxf(14.0, step): + _spawn_burst(target, shot.color, 6) + online_projectiles.remove_at(index) + else: + shot.pos = shot.pos.move_toward(target, step) func _handle_online_event(event: Dictionary) -> void: @@ -1874,7 +2369,7 @@ func _handle_online_event(event: Dictionary) -> void: func _show_status(text: String) -> void: - status_text = text + status_text = _ui(text) status_timer = 2.1 @@ -1882,7 +2377,26 @@ func _load_audio_settings() -> void: var config := ConfigFile.new() if config.load("user://void_bastion_settings.cfg") == OK: master_volume = clampf(float(config.get_value("audio", "master_volume", master_volume)), 0.0, 1.0) + ui_language = String(config.get_value("interface", "language", "de")) + if ui_language not in ["de", "en"]: + ui_language = "de" _apply_master_volume() + if friend_name_line_edit: + friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben") + + +func _ui(text: String) -> String: + return String(ENGLISH_UI.get(text, text)) if ui_language == "en" else text + + +func _set_ui_language(value: String) -> void: + if value not in ["de", "en"]: + return + ui_language = value + if friend_name_line_edit: + friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben") + _save_audio_settings() + queue_redraw() func _set_master_volume(value: float) -> void: @@ -1900,6 +2414,7 @@ func _apply_master_volume() -> void: func _save_audio_settings() -> void: var config := ConfigFile.new() config.set_value("audio", "master_volume", master_volume) + config.set_value("interface", "language", ui_language) config.save("user://void_bastion_settings.cfg") @@ -1980,8 +2495,8 @@ func _load_progress() -> void: best_wave = int(data.get("best_wave", 0)) best_endless_wave = int(data.get("best_endless_wave", 0)) core_crystals = maxi(0, int(data.get("core_crystals", 0))) - unlocked_towers = [0, 1, 2] - var saved_unlocks = data.get("unlocked_towers", [0, 1, 2]) + unlocked_towers = [0, 1, 2, 6] + var saved_unlocks = data.get("unlocked_towers", [0, 1, 2, 6]) if saved_unlocks is Array: for tower_type in saved_unlocks: if int(tower_type) >= 0 and int(tower_type) < 3 and not unlocked_towers.has(int(tower_type)): @@ -1999,13 +2514,16 @@ func _load_progress() -> void: func _draw() -> void: - if game_mode in ["menu", "store", "leaderboard", "profile", "profile_name_entry", "multiplayer_menu", "room_code_entry", "matchmaking"]: + if game_mode in ["menu", "promo_code", "store", "leaderboard", "profile", "profile_name_entry", "multiplayer_menu", "friends", "room_code_entry", "matchmaking"]: draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, SCREEN), false, Color("f3f6fb")) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06)) if game_mode == "menu": _draw_menu() if audio_settings_open: _draw_audio_settings() + elif game_mode == "promo_code": + _draw_menu() + _draw_promo_code_dialog() elif game_mode == "store": _draw_store() elif game_mode == "leaderboard": @@ -2016,6 +2534,8 @@ func _draw() -> void: _draw_profile_name_entry() elif game_mode == "multiplayer_menu": _draw_multiplayer_menu() + elif game_mode == "friends": + _draw_friends() elif game_mode == "room_code_entry": _draw_room_code_entry() else: @@ -2033,7 +2553,7 @@ func _draw() -> void: if game_mode == "playing" and not tutorial_completed and not coop_mode: _draw_tutorial() if game_mode == "paused": - _draw_overlay("PAUSE", "Zum Fortsetzen tippen oder P drücken") + _draw_pause_menu() elif game_mode == "game_over": _draw_game_over() elif game_mode == "victory": @@ -2059,6 +2579,7 @@ func _draw_coop_background() -> void: func _draw_world() -> void: + _prepare_draw_geometry() _draw_route() if coop_mode: draw_circle(COOP_REACTOR_CENTER, COOP_REACTOR_RADIUS, Color(0.02, 0.20, 0.30, 0.72)) @@ -2089,6 +2610,9 @@ func _draw_world() -> void: for shot in projectiles: 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)) + for shot in online_projectiles: + 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)) for beam in beams: if bool(beam.get("piercing", false)): draw_line(beam.from, beam.to, Color("c86bff", 0.18), 18.0) @@ -2101,7 +2625,11 @@ func _draw_world() -> void: draw_circle(particle.pos, maxf(1.0, particle.ttl * 8.0), Color(particle.color, minf(1.0, particle.ttl * 3.0))) -func _draw_route() -> void: +func _prepare_draw_geometry() -> void: + if not draw_geometry_dirty: + return + road_tile_cache.clear() + build_cell_cache.clear() for cell in path_cells: var center := _cell_center(cell) if center.x < -GRID_SIZE or center.x > _active_world_width() + GRID_SIZE or center.y < PLAYFIELD_TOP - GRID_SIZE or center.y > PLAYFIELD_BOTTOM + GRID_SIZE: @@ -2125,10 +2653,27 @@ func _draw_route() -> void: angle = PI * 1.5 elif up or down: angle = PI * 0.5 - draw_set_transform(center - camera_offset, angle, Vector2.ONE) var variant := absi(cell.x * 3 + cell.y * 5) % 4 + road_tile_cache.append({"center": center, "angle": angle, "texture": texture, + "corner": is_corner, "variant": variant, "opacity": _path_cell_opacity(cell)}) + for x_index in int(_active_world_width() / GRID_SIZE): + for y_index in int(WORLD_SIZE.y / GRID_SIZE): + var center := Vector2(x_index * GRID_SIZE + GRID_SIZE * 0.5, y_index * GRID_SIZE + GRID_SIZE * 0.5) + if not _is_in_build_zone(center) or _is_in_reactor_zone(center) or _is_path_cell(center): + continue + build_cell_cache.append({"rect": Rect2(center - Vector2(34, 34), Vector2(68, 68)), + "partner": coop_mode and center.x > COOP_MIRROR_X}) + draw_geometry_dirty = false + + +func _draw_route() -> void: + for tile in road_tile_cache: + draw_set_transform(tile.center - camera_offset, tile.angle, Vector2.ONE) + var variant: int = tile.variant var ray_x := -18.0 if variant % 2 == 0 else 18.0 - var tile_opacity := _path_cell_opacity(cell) + var tile_opacity: float = tile.opacity + var texture: Texture2D = tile.texture + var is_corner: bool = tile.corner if not is_corner: draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, -36), Vector2(ray_x + 5, -36), Vector2(ray_x + 14, -62), Vector2(ray_x - 14, -62)]), Color(1.0, 0.48, 0.12, 0.055 * tile_opacity)) draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, 36), Vector2(ray_x + 5, 36), Vector2(ray_x + 14, 62), Vector2(ray_x - 14, 62)]), Color(1.0, 0.48, 0.12, 0.055 * tile_opacity)) @@ -2207,21 +2752,11 @@ func _draw_reactor_approach() -> void: func _draw_build_grid() -> void: var line_color := Color(0.24, 0.82, 1.0, 0.14 if selected_tower_type >= 0 else 0.10) var fill_color := Color(0.08, 0.55, 0.72, 0.035 if selected_tower_type >= 0 else 0.025) - for x_index in int(_active_world_width() / GRID_SIZE): - for y_index in int(WORLD_SIZE.y / GRID_SIZE): - var center := Vector2(x_index * GRID_SIZE + GRID_SIZE * 0.5, y_index * GRID_SIZE + GRID_SIZE * 0.5) - if not _is_in_build_zone(center) or _is_in_reactor_zone(center): - continue - if _is_path_cell(center): - continue - var cell_line_color := line_color - var cell_fill_color := fill_color - if coop_mode and center.x > COOP_MIRROR_X: - cell_line_color = Color(1.0, 0.67, 0.32, 0.16) - cell_fill_color = Color(0.75, 0.35, 0.08, 0.025) - var cell := Rect2(center - Vector2(34, 34), Vector2(68, 68)) - draw_rect(cell, cell_fill_color, true) - draw_rect(cell, cell_line_color, false, 1.0) + for cell in build_cell_cache: + var cell_line_color := Color(1.0, 0.67, 0.32, 0.16) if cell.partner else line_color + var cell_fill_color := Color(0.75, 0.35, 0.08, 0.025) if cell.partner else fill_color + draw_rect(cell.rect, cell_fill_color, true) + draw_rect(cell.rect, cell_line_color, false, 1.0) func _draw_tower(tower: Dictionary) -> void: @@ -2234,6 +2769,9 @@ func _draw_tower(tower: Dictionary) -> void: if tower.type < 3: var source_width: float = TOWER_TEXTURE.get_width() / 3.0 draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(source_width * tower.type, 0, source_width, TOWER_TEXTURE.get_height())) + elif tower.type == 6: + var source_width: float = TOWER_TEXTURE.get_width() / 3.0 + draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(0, 0, source_width, TOWER_TEXTURE.get_height()), Color("ff8b92")) else: var tower_texture: Texture2D = PLASMA_TEXTURE if tower.type == 3 else (EMP_TEXTURE if tower.type == 4 else RAILGUN_TEXTURE) draw_texture_rect(tower_texture, destination, false) @@ -2263,9 +2801,9 @@ func _draw_enemy(enemy: Dictionary) -> void: draw_rect(Rect2(enemy.pos + Vector2(-bar_width * 0.5, -size * 0.48), Vector2(bar_width, 5)), Color("261923")) draw_rect(Rect2(enemy.pos + Vector2(-bar_width * 0.5, -size * 0.48), Vector2(bar_width * ratio, 5)), Color("ff6049")) if bool(enemy.get("shield", false)): - draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-30, -size * 0.62), "SCHILD", HORIZONTAL_ALIGNMENT_CENTER, 60, 9, Color("75eaff")) + draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-30, -size * 0.62), _ui("SCHILD"), HORIZONTAL_ALIGNMENT_CENTER, 60, 9, Color("75eaff")) if enemy.boss: - 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), _ui("KOMMANDOSCHIFF"), HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78")) func _enemy_texture(enemy_type: int) -> Texture2D: @@ -2286,19 +2824,15 @@ func _enemy_source_rect(enemy_type: int) -> Rect2: func _draw_hud() -> void: var font := ThemeDB.fallback_font - draw_rect(Rect2(0, 0, 1600, 90), Color(0.015, 0.035, 0.07, 0.92)) - draw_line(Vector2(0, 90), Vector2(1600, 90), Color("2e7593"), 2.0) - draw_string(font, Vector2(25, 35), "VOID BASTION", HORIZONTAL_ALIGNMENT_LEFT, -1, 25, Color("bcefff")) - draw_string(font, Vector2(25, 65), "ENERGIE %04d" % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_LEFT, -1, 20, Color("65eaff")) - draw_string(font, Vector2(310, 52), "REAKTOR %02d" % max(reactor_lives, 0), HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color("8feeff")) - draw_string(font, Vector2(535, 52), "WELLE %02d/%02d" % [wave, MAX_WAVES], HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color.WHITE) - var partner_slot := 1 - _local_credit_slot() - var partner_name := "PARTNER" if coop_online_mode else "KI" - var map_label := "KOOP-ARENA • %s %04d EN" % [partner_name, _player_credits(partner_slot)] if coop_mode else "KARTE %s" % MAP_NAMES[selected_map] - draw_string(font, Vector2(715, 52), map_label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("ffcf7a") if coop_mode else Color("91a8bc")) - draw_string(font, Vector2(920, 34), _difficulty().name, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color("ffcf7a")) - draw_string(font, Vector2(920, 62), "KERNKRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color("89f4ff")) - _draw_button(back_button, "ZURÜCK", false, Color("26394a")) + draw_rect(Rect2(0, 0, 1600, 82), Color(0.015, 0.035, 0.07, 0.92)) + draw_line(Vector2(0, 82), Vector2(1600, 82), Color("2e7593"), 2.0) + draw_string(font, Vector2(25, 31), _ui("ENERGIE %04d") % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_LEFT, 190, 16, Color("65eaff")) + var reactor_label := _ui("REAKTOR %02d") % max(reactor_lives, 0) + draw_string(font, Vector2(25, 59), reactor_label, HORIZONTAL_ALIGNMENT_LEFT, 165, 16, Color("8feeff")) + if coop_mode: + var partner_slot := 1 - _local_credit_slot() + var partner_name := _ui("PARTNER" if coop_online_mode else "KI") + draw_string(font, Vector2(752, 58), "%s %04d EN" % [partner_name, _player_credits(partner_slot)], HORIZONTAL_ALIGNMENT_CENTER, 154, 12, Color("ffcf7a")) if coop_mode: var viewing_right := camera_offset.x > 0.0 var viewing_own_side := (viewing_right and _local_credit_slot() == 1) or (not viewing_right and _local_credit_slot() == 0) @@ -2306,8 +2840,8 @@ func _draw_hud() -> void: _draw_button(coop_side_button, switch_label, false, Color("604827")) _draw_button(speed_button, "%.0f×" % game_speed, game_speed > 1.0) _draw_button(pause_button, "II" if game_mode == "playing" else ">", false) - _draw_button(wave_button, "WELLE %d STARTEN" % (wave + 1) if not wave_active else "%d IM ANFLUG" % (enemies.size() + enemies_to_spawn), wave_active) - if tutorial_completed and ads_manager.can_offer_rewarded(): + _draw_button(wave_button, _ui("WELLE %d STARTEN") % (wave + 1) if not wave_active else _ui("%d IM ANFLUG") % (enemies.size() + enemies_to_spawn), wave_active) + if tutorial_completed and selected_tower_type < 0 and selected_tower_index < 0 and ads_manager.can_offer_rewarded(): _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_line(Vector2(0, 760), Vector2(1600, 760), Color("2e7593"), 2.0) @@ -2317,7 +2851,7 @@ func _draw_hud() -> void: _draw_tower_card(index, tower_type, selected_tower_type == tower_type) if selected_tower_index >= 0 and selected_tower_index < towers.size(): 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 _ui("UPGRADE %d") % _upgrade_cost(tower) _draw_button(upgrade_button, upgrade_label, false) _draw_button(sell_button, "VERKAUFEN", false, Color("6b3542")) if not coop_online_mode: @@ -2327,17 +2861,21 @@ func _draw_hud() -> void: _draw_button(target_priority_button, priority_label, int(tower.type) == 1, Color("26394a")) if selected_tower_type >= 0 or selected_tower_index >= 0: _draw_tower_info_panel() - _draw_wave_preview_panel(wave_current_preview_rect, "JETZT", wave) + draw_rect(Rect2(wave_current_preview_rect.position, Vector2(wave_next_preview_rect.end.x - wave_current_preview_rect.position.x, wave_current_preview_rect.size.y)), Color(0.008, 0.028, 0.052, 0.90), true) + draw_rect(Rect2(wave_current_preview_rect.position, Vector2(wave_next_preview_rect.end.x - wave_current_preview_rect.position.x, wave_current_preview_rect.size.y)), Color(0.22, 0.54, 0.66, 0.68), false, 1.0) + draw_line(Vector2(wave_next_preview_rect.position.x - 4.0, wave_current_preview_rect.position.y + 6.0), Vector2(wave_next_preview_rect.position.x - 4.0, wave_current_preview_rect.end.y - 6.0), Color("2b596a", 0.8), 1.0) + _draw_wave_preview_panel(wave_current_preview_rect, _ui("JETZT • %02d/%02d") % [wave, MAX_WAVES], 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) + var next_heading := _ui("DANN • %02d") % next_wave if next_wave > 0 else _ui("DANN • --") + _draw_wave_preview_panel(wave_next_preview_rect, next_heading, next_wave) draw_rect(auto_wave_button, Color("102d40"), true) 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) if auto_start_waves: draw_rect(Rect2(auto_wave_button.position + Vector2(10, 7), Vector2(8, 8)), Color("65eaff"), true) - draw_string(font, auto_wave_button.position + Vector2(29, 16), "NÄCHSTE WELLE AUTO STARTEN", HORIZONTAL_ALIGNMENT_LEFT, 220, 11, Color("dceaf0")) + draw_string(font, auto_wave_button.position + Vector2(29, 14), _ui("NÄCHSTE WELLE AUTO STARTEN"), HORIZONTAL_ALIGNMENT_LEFT, 220, 10, Color("dceaf0")) if speed_dropdown_open: for speed_index in SPEED_OPTIONS.size(): var option_rect := _speed_option_rect(speed_index) @@ -2361,26 +2899,24 @@ func _draw_hud() -> void: 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")) + draw_string(font, rect.position + Vector2(8, 14), heading, HORIZONTAL_ALIGNMENT_LEFT, 160, 9, Color("96d8e8")) + draw_string(font, rect.position + Vector2(164, 14), _ui("%d SCHIFFE") % int(summary.total), HORIZONTAL_ALIGNMENT_RIGHT, 84, 8, 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}]: + for category_info in [{"name": "air", "x": 7.0, "color": Color("ff777d")}, {"name": "ground", "x": 133.0, "color": Color("78f29a")}]: 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 category_color: Color = category_info.color + _draw_wave_category_icon(Vector2(rect.position.x + category_x + 7.0, rect.position.y + 44.0), category, category_color) 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) + var icon_rect := Rect2(rect.position.x + category_x + 17.0 + drawn * 35.0, rect.position.y + 32.0, 15.0, 13.0) + _draw_enemy_miniature(type, icon_rect, category_color) draw_string(font, icon_rect.position + Vector2(16, 12), "×%d" % int(type_counts[type]), HORIZONTAL_ALIGNMENT_LEFT, 19, 8, Color("dceaf0")) drawn += 1 @@ -2431,10 +2967,10 @@ func _draw_wave_category_icon(center: Vector2, category: String, color: Color) - 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) +func _draw_enemy_miniature(enemy_type: int, rect: Rect2, category_color := Color("4e8194")) -> void: + draw_rect(rect.grow(1.0), Color(category_color, 0.18), 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) + draw_rect(rect.grow(1.0), Color(category_color, 0.82), false, 1.0) func _speed_option_rect(index: int) -> Rect2: @@ -2451,12 +2987,12 @@ func _draw_tower_card(slot: int, type: int, active: bool) -> void: 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(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, 72), "RW %.0f • %s" % [data.range, _tower_target_label(type)], HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("9fdce9")) + draw_string(font, rect.position + Vector2(9, 20), _ui(data.name), HORIZONTAL_ALIGNMENT_LEFT, 122, 13, Color.WHITE if unlocked else Color("9ab0ba")) + draw_string(font, rect.position + Vector2(132, 20), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 77, 10, Color("7ce7ff")) + draw_string(font, rect.position + Vector2(9, 42), _ui("SCH %.0f • %.2f SCHUSS/S") % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 200, 9, Color("dceaf0")) + draw_string(font, rect.position + Vector2(9, 63), _ui("RW %.0f • %s") % [data.range, _ui(_tower_target_label(type))], HORIZONTAL_ALIGNMENT_LEFT, 200, 9, class_color.lightened(0.45)) 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, 85), _ui(hint), HORIZONTAL_ALIGNMENT_LEFT, 200, 8, Color("719aaa") if unlocked else Color("ffcf7a")) func _tower_class_color(tower_type: int) -> Color: @@ -2473,9 +3009,9 @@ func _draw_store() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true) _draw_button(shop_back_button, "< ZURÜCK", false, Color("26394a")) - draw_string(font, Vector2(0, 115), "GERGAMES-ARSENAL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) - draw_string(font, Vector2(0, 160), "Türme und Einsatzsektoren mit Kernkristallen freischalten", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8feeff")) - draw_string(font, Vector2(1270, 55), "KERNKRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_RIGHT, 270, 20, Color("8ff4ff")) + draw_string(font, Vector2(0, 115), _ui("GERGAMES-ARSENAL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) + draw_string(font, Vector2(0, 160), _ui("Türme und Einsatzsektoren mit Kernkristallen freischalten"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8feeff")) + draw_string(font, Vector2(1270, 55), _ui("KERNKRISTALLE %04d") % core_crystals, HORIZONTAL_ALIGNMENT_RIGHT, 270, 20, Color("8ff4ff")) _draw_button(store_tower_tab_button, "TÜRME", store_tab == "towers", Color("24475b")) _draw_button(store_map_tab_button, "KARTEN", store_tab == "maps", Color("24475b")) if store_tab == "maps": @@ -2487,12 +3023,12 @@ func _draw_store() -> void: draw_rect(card, Color(0.012, 0.04, 0.07, 0.94), true) draw_rect(card, Color("65dfff", 0.72), false, 2.0) draw_texture_rect(MAP_TEXTURES[map_index], Rect2(card.position + Vector2(12, 12), Vector2(326, 176)), false) - draw_string(font, card.position + Vector2(10, 222), MAP_NAMES[map_index], HORIZONTAL_ALIGNMENT_CENTER, 330, 17, Color("9feeff")) - draw_string(font, card.position + Vector2(10, 250), MAP_DESCRIPTIONS[map_index], HORIZONTAL_ALIGNMENT_CENTER, 330, 13, Color("dceaf0")) - draw_string(font, card.position + Vector2(10, 282), "80-PX-RASTER • DIREKTZIEL REAKTOR", HORIZONTAL_ALIGNMENT_CENTER, 330, 10, Color("8fddef")) - var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(MAP_UNLOCK_COSTS[map_index]) + draw_string(font, card.position + Vector2(10, 222), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, 330, 17, Color("9feeff")) + draw_string(font, card.position + Vector2(10, 250), _ui(MAP_DESCRIPTIONS[map_index]), HORIZONTAL_ALIGNMENT_CENTER, 330, 13, Color("dceaf0")) + draw_string(font, card.position + Vector2(10, 282), _ui("80-PX-RASTER • DIREKTZIEL REAKTOR"), HORIZONTAL_ALIGNMENT_CENTER, 330, 10, Color("8fddef")) + var button_text := "FREIGESCHALTET" if is_unlocked else _ui("FREISCHALTEN • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[map_index]) _draw_button(button, button_text, is_unlocked, Color("24516a") if not is_unlocked else Color("245843")) - draw_string(font, Vector2(0, 685), "Jeder Sektor hat einen eigenen, rastergenauen Laufweg bis in den Reaktor.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("ffcf7a")) + draw_string(font, Vector2(0, 685), _ui("Jeder Sektor hat einen eigenen, rastergenauen Laufweg bis in den Reaktor."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("ffcf7a")) return var textures: Array[Texture2D] = [PLASMA_TEXTURE, EMP_TEXTURE, RAILGUN_TEXTURE] for slot in shop_purchase_buttons.size(): @@ -2502,14 +3038,14 @@ func _draw_store() -> void: draw_rect(card, Color(0.012, 0.04, 0.07, 0.94), true) draw_rect(card, Color(data.color, 0.72), false, 2.0) 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(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, _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(20, 190), _ui(data.name), HORIZONTAL_ALIGNMENT_CENTER, 320, 23, Color(data.color)) + draw_string(font, card.position + Vector2(22, 224), _ui("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), _ui("RW %.0f • %s") % [data.range, _ui(_tower_target_label(tower_type))], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9")) + draw_string(font, card.position + Vector2(22, 295), _ui(data.description), HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("c4d7df")) 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 _ui("FREISCHALTEN • %d KERNKRISTALLE") % int(data.unlock_cost) _draw_button(shop_purchase_buttons[slot], button_text, is_unlocked, Color("24516a") if not is_unlocked else Color("245843")) - draw_string(font, Vector2(0, 685), "Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("ffcf7a")) + draw_string(font, Vector2(0, 685), _ui("Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("ffcf7a")) func _draw_tower_info_panel() -> void: @@ -2526,16 +3062,16 @@ func _draw_tower_info_panel() -> void: var font := ThemeDB.fallback_font draw_rect(tower_info_rect, Color(0.008, 0.028, 0.055, 0.58), true) draw_rect(tower_info_rect, Color(0.29, 0.60, 0.70, 0.72), false, 1.5) - var heading := "%s • STUFE %d" % [data.name, level] if is_built else "%s • BAUVORSCHAU" % data.name - draw_string(font, tower_info_rect.position + Vector2(10, 20), heading, HORIZONTAL_ALIGNMENT_LEFT, 280, 13, Color("a9efff")) - draw_string(font, tower_info_rect.position + Vector2(10, 41), "SCH %.0f • %.2f/S • RW %.0f" % [_tower_damage(type, level), _tower_shots_per_second(type, level), _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 280, 10, Color.WHITE) + var heading := _ui("%s • STUFE %d") % [_ui(data.name), level] if is_built else _ui("%s • BAUVORSCHAU") % _ui(data.name) + draw_string(font, tower_info_rect.position + Vector2(10, 19), heading, HORIZONTAL_ALIGNMENT_LEFT, 300, 11, Color("a9efff")) + draw_string(font, tower_info_rect.position + Vector2(10, 37), _ui("SCH %.0f • %.2f/S • RW %.0f") % [_tower_damage(type, level), _tower_shots_per_second(type, level), _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 300, 9, Color.WHITE) if is_built and level < 3: var next_level := level + 1 - draw_string(font, tower_info_rect.position + Vector2(10, 62), "UPGRADE +%.0f SCH +%.2f/S +%.0f RW" % [_tower_damage(type, next_level) - _tower_damage(type, level), _tower_shots_per_second(type, next_level) - _tower_shots_per_second(type, level), _tower_range(type, next_level) - _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca")) + draw_string(font, tower_info_rect.position + Vector2(10, 55), _ui("+%.0f SCH +%.2f/S +%.0f RW BEIM UPGRADE") % [_tower_damage(type, next_level) - _tower_damage(type, level), _tower_shots_per_second(type, next_level) - _tower_shots_per_second(type, level), _tower_range(type, next_level) - _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 300, 8, Color("84ffca")) 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, 55), _ui("MAXIMALE AUSBAUSTUFE"), HORIZONTAL_ALIGNMENT_LEFT, 300, 8, Color("84ffca")) else: - 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")) + draw_string(font, tower_info_rect.position + Vector2(10, 55), "%s • %s" % [_ui(_tower_target_label(type)), _ui(str(data.damage_type))], HORIZONTAL_ALIGNMENT_LEFT, 300, 8, Color("84ffca")) func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55")) -> void: @@ -2543,7 +3079,7 @@ func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55 var color: Color = Color("226c88") if active else base draw_rect(rect, color, true) draw_rect(rect, Color("65dfff") if active else Color("37677b"), false, 2.0) - var lines := text.split("\n") + var lines := _ui(text).split("\n") if lines.size() == 1: draw_string(font, rect.position + Vector2(0, rect.size.y * 0.62), lines[0], HORIZONTAL_ALIGNMENT_CENTER, rect.size.x, 17, Color.WHITE) else: @@ -2556,9 +3092,11 @@ func _draw_menu() -> void: draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52)) draw_string(font, Vector2(0, 145), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("c7f5ff")) draw_string(font, Vector2(0, 202), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color("56cce9")) - _draw_button(profile_button, "%s • PROFIL" % account_manager.display_name, false, Color("233f57")) + if not pending_match_invite.is_empty(): + _draw_button(Rect2(470, 226, 660, 54), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) + _draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57")) if menu_step == "map": - draw_string(font, Vector2(0, 300), "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff")) + draw_string(font, Vector2(0, 300), _ui("EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff")) for index in map_buttons.size(): var card: Rect2 = map_buttons[index] var map_index := index @@ -2568,10 +3106,10 @@ func _draw_menu() -> void: draw_rect(card, Color("70eaff") if selected_map == map_index and is_unlocked else Color("436574"), false, 4.0 if selected_map == map_index and is_unlocked else 2.0) var footer_y := card.size.y - 52.0 draw_rect(Rect2(card.position + Vector2(0, footer_y), Vector2(card.size.x, 52)), Color(0.01, 0.025, 0.05, 0.88), true) - draw_string(font, card.position + Vector2(0, footer_y + 21), MAP_NAMES[map_index], HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 14, Color.WHITE) - var description: String = MAP_DESCRIPTIONS[map_index] if is_unlocked else "GESPERRT • %d KERNKRISTALLE" % int(MAP_UNLOCK_COSTS[map_index]) + draw_string(font, card.position + Vector2(0, footer_y + 21), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 14, Color.WHITE) + var description: String = _ui(MAP_DESCRIPTIONS[map_index]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[map_index]) draw_string(font, card.position + Vector2(0, footer_y + 42), description, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 11, Color("8fddef") if is_unlocked else Color("ffcf7a")) - draw_string(font, Vector2(0, 720), "Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("d7e2ea")) + draw_string(font, Vector2(0, 720), _ui("Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("d7e2ea")) else: _draw_button(map_step_back_button, "< KARTE", false, Color("26394a")) var selected_card := Rect2(500, 315, 600, 145) @@ -2579,33 +3117,49 @@ func _draw_menu() -> void: draw_rect(selected_card, Color(0.005, 0.015, 0.035, 0.34), true) draw_rect(selected_card, Color("70eaff"), false, 3.0) draw_rect(Rect2(selected_card.position + Vector2(0, 92), Vector2(selected_card.size.x, 53)), Color(0.01, 0.025, 0.05, 0.88), true) - draw_string(font, selected_card.position + Vector2(0, 116), MAP_NAMES[selected_map], HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 18, Color.WHITE) - draw_string(font, selected_card.position + Vector2(0, 138), MAP_DESCRIPTIONS[selected_map], HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 13, Color("8fddef")) - draw_string(font, Vector2(0, 490), "SCHWIERIGKEIT WÄHLEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("9fdce9")) + draw_string(font, selected_card.position + Vector2(0, 116), _ui(MAP_NAMES[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 18, Color.WHITE) + draw_string(font, selected_card.position + Vector2(0, 138), _ui(MAP_DESCRIPTIONS[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 13, Color("8fddef")) + draw_string(font, Vector2(0, 490), _ui("SCHWIERIGKEIT WÄHLEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("9fdce9")) for index in difficulty_buttons.size(): _draw_button(difficulty_buttons[index], DIFFICULTY_DATA[index].name, selected_difficulty == index, Color("27394d")) - draw_string(font, Vector2(0, 603), DIFFICULTY_DESCRIPTIONS[selected_difficulty], HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("ffcf7a")) + draw_string(font, Vector2(0, 603), _ui(DIFFICULTY_DESCRIPTIONS[selected_difficulty]), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("ffcf7a")) _draw_button(start_button, "MISSION STARTEN", false, Color("18556f")) - draw_string(font, Vector2(0, 755), "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d" % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("86c8d8")) + draw_string(font, Vector2(0, 755), _ui("KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d") % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("86c8d8")) _draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) _draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a")) _draw_button(audio_settings_button, "EINSTELLUNGEN", false, Color("24516a")) + _draw_button(promo_code_button, "CODE EINLÖSEN", false, Color("34576b")) _draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342")) - draw_string(font, Vector2(0, 860), "VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN" % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) + draw_string(font, Vector2(0, 860), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) if update_notice_visible: _draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d")) _draw_button(update_dismiss_button, "×", false, Color("3d4650")) +func _draw_promo_code_dialog() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.72), true) + var panel := Rect2(420, 265, 760, 390) + draw_rect(panel, Color(0.008, 0.028, 0.055, 0.98), true) + draw_rect(panel, Color("4ab8d1"), false, 2.0) + _draw_button(promo_code_cancel_button, "< ZURÜCK", false, Color("26394a")) + draw_string(font, Vector2(panel.position.x, 330), _ui("SONDER-CODE"), HORIZONTAL_ALIGNMENT_CENTER, panel.size.x, 30, Color("c7f5ff")) + draw_string(font, Vector2(panel.position.x + 60, 370), _ui("Gib einen persönlichen Freischaltcode ein."), HORIZONTAL_ALIGNMENT_CENTER, panel.size.x - 120, 16, Color("9dbac8")) + draw_string(font, Vector2(promo_code_input_rect.position.x, promo_code_input_rect.position.y - 12), _ui("FREISCHALTCODE"), HORIZONTAL_ALIGNMENT_LEFT, promo_code_input_rect.size.x, 12, Color("8feeff")) + _draw_button(promo_code_redeem_button, "CODE EINLÖSEN" if not promo_code_pending else "BITTE WARTEN …", promo_code_pending, Color("185f68")) + if not promo_code_message.is_empty(): + draw_string(font, Vector2(panel.position.x + 35, 555), promo_code_message, HORIZONTAL_ALIGNMENT_CENTER, panel.size.x - 70, 15, Color("8fffea") if promo_code_message.begins_with("Freigeschaltet") else Color("ffcf7a")) + + func _draw_audio_settings() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.002, 0.008, 0.02, 0.62), true) draw_rect(audio_panel_rect, Color(0.01, 0.035, 0.065, 0.98), true) draw_rect(audio_panel_rect, Color("4ab8d1"), false, 2.0) - draw_string(font, audio_panel_rect.position + Vector2(30, 54), "AUDIOEINSTELLUNGEN", HORIZONTAL_ALIGNMENT_LEFT, 450, 25, Color("bcefff")) + draw_string(font, audio_panel_rect.position + Vector2(30, 54), _ui("EINSTELLUNGEN"), HORIZONTAL_ALIGNMENT_LEFT, 450, 25, Color("bcefff")) _draw_button(audio_close_button, "SCHLIESSEN", false, Color("26394a")) - draw_string(font, Vector2(audio_slider_rect.position.x, audio_slider_rect.position.y - 18), "MASTER-LAUTSTÄRKE", HORIZONTAL_ALIGNMENT_LEFT, 260, 16, Color("dceaf0")) + draw_string(font, Vector2(audio_slider_rect.position.x, audio_slider_rect.position.y - 18), _ui("MASTER-LAUTSTÄRKE"), HORIZONTAL_ALIGNMENT_LEFT, 260, 16, Color("dceaf0")) draw_string(font, Vector2(audio_slider_rect.end.x - 80, audio_slider_rect.position.y - 18), "%d%%" % roundi(master_volume * 100.0), HORIZONTAL_ALIGNMENT_RIGHT, 80, 16, Color("8ff4ff")) var track := Rect2(audio_slider_rect.position.x, audio_slider_rect.position.y + 13, audio_slider_rect.size.x, 6) draw_rect(track, Color("24475b"), true) @@ -2613,7 +3167,10 @@ func _draw_audio_settings() -> void: var knob_position := Vector2(track.position.x + track.size.x * master_volume, track.position.y + track.size.y * 0.5) draw_circle(knob_position, 13, Color("0b1d2c")) draw_circle(knob_position, 9, Color("8ff4ff")) - draw_string(font, Vector2(audio_panel_rect.position.x + 30, audio_panel_rect.end.y - 28), "Die Einstellung wird automatisch gespeichert.", HORIZONTAL_ALIGNMENT_LEFT, audio_panel_rect.size.x - 60, 13, Color("91a8bc")) + draw_string(font, Vector2(audio_panel_rect.position.x + 115, 530), _ui("SPRACHE"), HORIZONTAL_ALIGNMENT_LEFT, 460, 17, Color("dceaf0")) + _draw_button(language_button_de, "DEUTSCH", ui_language == "de", Color("24516a")) + _draw_button(language_button_en, "ENGLISCH", ui_language == "en", Color("24516a")) + draw_string(font, Vector2(audio_panel_rect.position.x + 30, audio_panel_rect.end.y - 28), _ui("Die Einstellung wird automatisch gespeichert."), HORIZONTAL_ALIGNMENT_LEFT, audio_panel_rect.size.x - 60, 13, Color("91a8bc")) func _draw_leaderboard() -> void: @@ -2621,24 +3178,24 @@ func _draw_leaderboard() -> void: draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.84)) _draw_button(leaderboard_back_button, "< ZURÜCK", false, Color("26394a")) _draw_button(leaderboard_refresh_button, "AKTUALISIEREN", leaderboard_loading, Color("24516a")) - draw_string(font, Vector2(0, 108), "GLOBALE BESTENLISTE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) + draw_string(font, Vector2(0, 108), _ui("GLOBALE BESTENLISTE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) _draw_button(leaderboard_map_previous_button, "<", false, Color("26394a")) - _draw_button(leaderboard_map_button, "KARTE • %s" % MAP_NAMES[leaderboard_map], true, Color("24475b")) + _draw_button(leaderboard_map_button, "%s • %s" % [_ui("KARTE"), _ui(MAP_NAMES[leaderboard_map])], true, Color("24475b")) _draw_button(leaderboard_map_next_button, ">", false, Color("26394a")) _draw_button(leaderboard_difficulty_previous_button, "<", false, Color("26394a")) - _draw_button(leaderboard_difficulty_button, "STUFE • %s" % DIFFICULTY_DATA[leaderboard_difficulty].name, true, Color("5b452b")) + _draw_button(leaderboard_difficulty_button, "%s • %s" % [_ui("STUFE"), _ui(DIFFICULTY_DATA[leaderboard_difficulty].name)], true, Color("5b452b")) _draw_button(leaderboard_difficulty_next_button, ">", false, Color("26394a")) _draw_button(leaderboard_mission_button, "MISSION", not leaderboard_endless, Color("24475b")) _draw_button(leaderboard_endless_button, "ENDLOSMODUS", leaderboard_endless, Color("5b452b")) var panel := Rect2(205, 280, 1190, 505) draw_rect(panel, Color(0.012, 0.035, 0.065, 0.96), true) draw_rect(panel, Color("39788e"), false, 2.0) - draw_string(font, Vector2(235, 319), "RANG", HORIZONTAL_ALIGNMENT_LEFT, 75, 13, Color("91a8bc")) - draw_string(font, Vector2(325, 319), "PILOT", HORIZONTAL_ALIGNMENT_LEFT, 270, 13, Color("91a8bc")) - draw_string(font, Vector2(610, 319), "KARTE", HORIZONTAL_ALIGNMENT_LEFT, 240, 13, Color("91a8bc")) - draw_string(font, Vector2(850, 319), "STUFE", HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("91a8bc")) - draw_string(font, Vector2(995, 319), "WELLE", HORIZONTAL_ALIGNMENT_CENTER, 110, 13, Color("91a8bc")) - draw_string(font, Vector2(1160, 319), "PUNKTE", HORIZONTAL_ALIGNMENT_RIGHT, 190, 13, Color("91a8bc")) + draw_string(font, Vector2(235, 319), _ui("RANG"), HORIZONTAL_ALIGNMENT_LEFT, 75, 13, Color("91a8bc")) + draw_string(font, Vector2(325, 319), _ui("PILOT"), HORIZONTAL_ALIGNMENT_LEFT, 270, 13, Color("91a8bc")) + draw_string(font, Vector2(610, 319), _ui("KARTE"), HORIZONTAL_ALIGNMENT_LEFT, 240, 13, Color("91a8bc")) + draw_string(font, Vector2(850, 319), _ui("STUFE"), HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("91a8bc")) + draw_string(font, Vector2(995, 319), _ui("WELLE"), HORIZONTAL_ALIGNMENT_CENTER, 110, 13, Color("91a8bc")) + draw_string(font, Vector2(1160, 319), _ui("PUNKTE"), HORIZONTAL_ALIGNMENT_RIGHT, 190, 13, Color("91a8bc")) draw_line(Vector2(230, 334), Vector2(1370, 334), Color("315c70"), 1.0) var own_user_id: String = str(online_manager.session.user_id) if online_manager.session != null else "" for index in leaderboard_rows.size(): @@ -2649,22 +3206,22 @@ func _draw_leaderboard() -> void: var owner_color := Color("83ffc8") if not own_user_id.is_empty() and str(row.get("owner_id", "")) == own_user_id else Color.WHITE draw_string(font, Vector2(240, y), "%02d" % int(row.get("rank", index + 1)), HORIZONTAL_ALIGNMENT_LEFT, 65, 14, Color("8ff4ff")) draw_string(font, Vector2(325, y), str(row.get("username", "PILOT")).to_upper(), HORIZONTAL_ALIGNMENT_LEFT, 270, 14, owner_color) - draw_string(font, Vector2(610, y), MAP_NAMES[int(row.get("map", leaderboard_map))], HORIZONTAL_ALIGNMENT_LEFT, 225, 13, Color("9fdce9")) - draw_string(font, Vector2(850, y), DIFFICULTY_DATA[int(row.get("difficulty", leaderboard_difficulty))].name, HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("ffcf7a")) + draw_string(font, Vector2(610, y), _ui(MAP_NAMES[int(row.get("map", leaderboard_map))]), HORIZONTAL_ALIGNMENT_LEFT, 225, 13, Color("9fdce9")) + draw_string(font, Vector2(850, y), _ui(DIFFICULTY_DATA[int(row.get("difficulty", leaderboard_difficulty))].name), HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("ffcf7a")) draw_string(font, Vector2(995, y), "%02d" % int(row.get("wave", 0)), HORIZONTAL_ALIGNMENT_CENTER, 110, 14, Color("ffcf7a")) draw_string(font, Vector2(1160, y), "%09d" % int(row.get("score", 0)), HORIZONTAL_ALIGNMENT_RIGHT, 190, 14, Color("8fffea")) if not leaderboard_message.is_empty(): draw_string(font, Vector2(300, 520), leaderboard_message, HORIZONTAL_ALIGNMENT_CENTER, 1000, 17, Color("ffcf7a")) - draw_string(font, Vector2(0, 810), "Spielernamen werden aus dem aktuellen öffentlichen Profil geladen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("91a8bc")) - draw_string(font, Vector2(0, 838), "WERTUNG: WELLEN + REAKTORREST + RESTENERGIE • WENIGER GEBAUTE TÜRME ERHÖHEN DEN EFFIZIENZ-MULTIPLIKATOR", HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, Color("75aaba")) + draw_string(font, Vector2(0, 810), _ui("Spielernamen werden aus dem aktuellen öffentlichen Profil geladen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("91a8bc")) + draw_string(font, Vector2(0, 838), _ui("WERTUNG: WELLEN + REAKTORREST + RESTENERGIE • WENIGER GEBAUTE TÜRME ERHÖHEN DEN EFFIZIENZ-MULTIPLIKATOR"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, Color("75aaba")) func _draw_multiplayer_menu() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.78)) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) - draw_string(font, Vector2(0, 105), "KOOPERATIVE VERTEIDIGUNG", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) - draw_string(font, Vector2(0, 142), "ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3")) + draw_string(font, Vector2(0, 105), _ui("KOOPERATIVE VERTEIDIGUNG"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) + draw_string(font, Vector2(0, 142), _ui("ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3")) var arena := Rect2(170, 180, 1260, 410) draw_rect(arena, Color(0.01, 0.04, 0.075, 0.96), true) draw_rect(arena, Color("39788e"), false, 2.0) @@ -2674,32 +3231,85 @@ func _draw_multiplayer_menu() -> void: for radius in [66.0, 48.0, 30.0]: draw_arc(Vector2(800, 385), radius, 0.0, TAU, 48, Color(0.25, 0.92, 1.0, 0.78), 3.0) draw_circle(Vector2(800, 385), 18.0, Color("8ffaff")) - draw_string(font, Vector2(665, 490), "GEMEINSAMER REAKTOR", HORIZONTAL_ALIGNMENT_CENTER, 270, 15, Color("8ff4ff")) - draw_string(font, Vector2(215, 225), "%s • ENERGIE %d" % [account_manager.display_name, multiplayer_manager.player_credits[0]], HORIZONTAL_ALIGNMENT_LEFT, 440, 17, Color("8ff4ff")) - draw_string(font, Vector2(945, 225), "MITSPIELER • ENERGIE %d" % multiplayer_manager.player_credits[1], HORIZONTAL_ALIGNMENT_RIGHT, 440, 17, Color("ffcf7a")) - draw_string(font, Vector2(570, 555), "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN" % multiplayer_manager.shared_reactor_lives, HORIZONTAL_ALIGNMENT_CENTER, 460, 16, Color.WHITE) + draw_string(font, Vector2(665, 490), _ui("GEMEINSAMER REAKTOR"), HORIZONTAL_ALIGNMENT_CENTER, 270, 15, Color("8ff4ff")) + draw_string(font, Vector2(215, 225), "%s • %s %d" % [account_manager.display_name, _ui("ENERGIE"), multiplayer_manager.player_credits[0]], HORIZONTAL_ALIGNMENT_LEFT, 440, 17, Color("8ff4ff")) + draw_string(font, Vector2(945, 225), "%s • %s %d" % [_ui("MITSPIELER"), _ui("ENERGIE"), multiplayer_manager.player_credits[1]], HORIZONTAL_ALIGNMENT_RIGHT, 440, 17, Color("ffcf7a")) + draw_string(font, Vector2(570, 555), _ui("REAKTORLEBEN %02d • GETRENNTE RESSOURCEN") % multiplayer_manager.shared_reactor_lives, HORIZONTAL_ALIGNMENT_CENTER, 460, 16, Color.WHITE) _draw_button(quick_match_button, "SCHNELLES SPIEL", false, Color("185f68")) _draw_button(friend_match_button, "RAUM BEITRETEN", false, Color("254f75")) _draw_button(private_match_button, "RAUM ERSTELLEN", false, Color("493f67")) _draw_button(ai_match_button, "MIT KI-PARTNER", false, Color("6b4b28")) + _draw_button(friends_button, "FREUNDESLISTE", false, Color("285568")) var service_text := "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT" if online_manager.is_online() else "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT" var service_color := Color("83ffc8") if online_manager.is_online() else Color("ffcf7a") - draw_string(font, Vector2(0, 770), service_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color) + draw_string(font, Vector2(0, 842), _ui(service_text), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color) + if not pending_match_invite.is_empty(): + _draw_button(Rect2(470, 585, 660, 55), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) + + +func _draw_friends() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.84)) + _draw_button(friends_back_button, "< ZURÜCK", false, Color("26394a")) + _draw_button(friends_refresh_button, "AKTUALISIEREN", false, Color("24516a")) + draw_string(font, Vector2(0, 105), _ui("FREUNDESLISTE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 40, Color("c7f5ff")) + var own_username := String(online_manager.session.username) if online_manager.session != null else "OFFLINE" + draw_string(font, Vector2(260, 169), _ui("DEINE SPIELERKENNUNG: %s") % own_username, HORIZONTAL_ALIGNMENT_LEFT, 740, 18, Color("8ff4ff")) + _draw_button(friend_copy_button, "KENNUNG KOPIEREN", false, Color("285568")) + draw_rect(friend_name_rect, Color(0.01, 0.035, 0.065, 0.98), true) + draw_rect(friend_name_rect, Color("4ab8d1"), false, 2.0) + _draw_button(friend_add_button, "ANFRAGE SENDEN", false, Color("185f68")) + draw_string(font, Vector2(260, 292), _ui("Füge Freunde über ihre eindeutige Spielerkennung hinzu. Beide müssen die Anfrage bestätigen."), HORIZONTAL_ALIGNMENT_LEFT, 1090, 15, Color("9fdce9")) + draw_rect(Rect2(220, 315, 1160, 455), Color(0.01, 0.035, 0.065, 0.96), true) + draw_rect(Rect2(220, 315, 1160, 455), Color("39788e"), false, 2.0) + friend_action_buttons.clear() + for index in mini(online_manager.friends.size(), 8): + var friend: Dictionary = online_manager.friends[index] + var row := Rect2(240, 336 + index * 52, 1120, 46) + var action := Rect2(1100, row.position.y + 3, 235, 40) + friend_action_buttons.append(action) + draw_rect(row, Color(0.035, 0.09, 0.14, 0.82), true) + var state := int(friend.get("state", -1)) + var status := "" + var action_text := "" + if state == 0: + var presence := String(online_manager.friend_statuses.get(String(friend.get("id", "")), "menu" if bool(friend.get("online", false)) else "")) + status = "IM SPIEL" if presence == "playing" else ("ONLINE" if not presence.is_empty() else "OFFLINE") + action_text = "EINLADEN" if _can_invite_friends() else "ENTFERNEN" + elif state == 1: + status = "ANFRAGE GESENDET" + action_text = "ZURÜCKZIEHEN" + elif state == 2: + status = "ANFRAGE ERHALTEN" + action_text = "ANNEHMEN" + else: + status = "BLOCKIERT" + draw_string(font, row.position + Vector2(20, 30), String(friend.get("name", "PILOT")), HORIZONTAL_ALIGNMENT_LEFT, 450, 19, Color.WHITE) + draw_string(font, row.position + Vector2(520, 30), _ui(status), HORIZONTAL_ALIGNMENT_LEFT, 270, 15, Color("83ffc8") if status == "ONLINE" or status == "IM SPIEL" else Color("ffcf7a")) + if not action_text.is_empty(): + _draw_button(action, action_text, false, Color("24516a")) + if online_manager.friends.is_empty(): + draw_string(font, Vector2(280, 410), _ui("Noch keine Freunde oder offenen Anfragen."), HORIZONTAL_ALIGNMENT_LEFT, 850, 19, Color("9dbac8")) + if not friends_message.is_empty(): + draw_string(font, Vector2(240, 805), friends_message, HORIZONTAL_ALIGNMENT_LEFT, 1120, 16, Color("ffcf7a")) + if _can_invite_friends(): + draw_string(font, Vector2(240, 835), _ui("Privater Raum %s: Freunde mit EINLADEN direkt benachrichtigen.") % multiplayer_manager.private_room_code, HORIZONTAL_ALIGNMENT_LEFT, 1120, 14, Color("8feeff")) + draw_string(font, Vector2(240, 866), _ui("Kein Chat: Einladungen enthalten nur einen Raumcode, keine freie Nachricht."), HORIZONTAL_ALIGNMENT_LEFT, 1120, 13, Color("91a8bc")) func _draw_room_code_entry() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88)) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) - draw_string(font, Vector2(0, 205), "PRIVATEN RAUM BEITRETEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) - draw_string(font, Vector2(0, 260), "GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3")) + draw_string(font, Vector2(0, 205), _ui("PRIVATEN RAUM BEITRETEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) + draw_string(font, Vector2(0, 260), _ui("GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3")) draw_rect(room_code_input_rect, Color(0.01, 0.035, 0.065, 0.98), true) draw_rect(room_code_input_rect, Color("65eaff") if room_code_input.length() == 6 else Color("39788e"), false, 3.0) var shown_code := room_code_input if shown_code.is_empty(): shown_code = "------" draw_string(font, Vector2(room_code_input_rect.position.x, room_code_input_rect.position.y + 61), " ".join(shown_code.split("")), HORIZONTAL_ALIGNMENT_CENTER, room_code_input_rect.size.x, 38, Color("8ffaff") if not room_code_input.is_empty() else Color("688293")) - draw_string(font, Vector2(0, 470), "BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("9fdce9")) + draw_string(font, Vector2(0, 470), _ui("BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("9fdce9")) _draw_button(join_room_button, "RAUM BEITRETEN", room_code_input.length() == 6, Color("185f68") if room_code_input.length() == 6 else Color("26394a")) @@ -2723,7 +3333,7 @@ func _draw_matchmaking() -> void: draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88)) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"] - draw_string(font, Vector2(0, 210), "PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) + draw_string(font, Vector2(0, 210), _ui("PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) var in_lobby: bool = multiplayer_manager.player_slot >= 0 and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"] if in_lobby: _draw_online_lobby_players(font) @@ -2735,10 +3345,12 @@ func _draw_matchmaking() -> void: var mode_text: String = mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL") if not multiplayer_manager.private_room_code.is_empty(): mode_text = "RAUMCODE %s" % " ".join(multiplayer_manager.private_room_code.split("")) - draw_string(font, Vector2(0, 500), mode_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) + draw_string(font, Vector2(0, 500), _ui("RAUMCODE %s") % " ".join(multiplayer_manager.private_room_code.split("")) if not multiplayer_manager.private_room_code.is_empty() else _ui(mode_text), HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) if in_lobby: _draw_button(lobby_ready_button, "NICHT BEREIT" if multiplayer_manager.local_ready else "BEREIT", multiplayer_manager.local_ready, Color("8b6330") if multiplayer_manager.local_ready else Color("185f68")) _draw_button(cancel_search_button, "ZURÜCK" if multiplayer_manager.matchmaking_state == "room_not_found" else "SUCHE ABBRECHEN", false, Color("5c3342")) + if multiplayer_manager.matchmaking_mode == "private_host": + _draw_button(friends_button, "FREUNDE EINLADEN", false, Color("285568")) var online_text := "ONLINE • SUCHE NACH EINEM MITPILOTEN" var online_color := Color("83ffc8") if multiplayer_manager.matchmaking_state == "waiting_for_service": @@ -2772,7 +3384,7 @@ func _draw_matchmaking() -> void: online_text = "RAUM NICHT GEFUNDEN ODER BEREITS VOLL" online_color = Color("ff8e8e") draw_rect(Rect2(180, 520, 1240, 44), Color(0.005, 0.015, 0.035, 0.98), true) - draw_string(font, Vector2(0, 548), online_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color) + draw_string(font, Vector2(0, 548), _ui(online_text), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color) func _draw_online_lobby_players(font: Font) -> void: @@ -2786,8 +3398,8 @@ func _draw_online_lobby_players(font: Font) -> void: draw_rect(rect, Color(0.012, 0.035, 0.065, 0.96), true) draw_rect(rect, border, false, 3.0) var name := String(player.get("name", "PILOT")) if occupied else "WARTE AUF MITPILOTEN" - var side := "LINKE VERTEIDIGUNG" if slot == 0 else "RECHTE VERTEIDIGUNG" - var state := "BEREIT" if ready else ("NICHT BEREIT" if connected else "NICHT VERBUNDEN") + var side := _ui("LINKE VERTEIDIGUNG" if slot == 0 else "RECHTE VERTEIDIGUNG") + var state := _ui("BEREIT" if ready else ("NICHT BEREIT" if connected else "NICHT VERBUNDEN")) draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 43), name.to_upper(), HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 24, Color("e8fbff")) draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 78), side, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 14, Color("8fcddd")) draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 108), state, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 16, border) @@ -2797,32 +3409,32 @@ func _draw_profile() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.76)) _draw_button(profile_back_button, "< ZURÜCK", false, Color("26394a")) - draw_string(font, Vector2(0, 112), "PILOTENPROFIL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) + draw_string(font, Vector2(0, 112), _ui("PILOTENPROFIL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) draw_string(font, Vector2(1110, 112), "VERSION %s" % _app_version(), HORIZONTAL_ALIGNMENT_RIGHT, 290, 14, Color("91a8bc")) draw_rect(Rect2(170, 155, 1260, 600), Color(0.012, 0.035, 0.065, 0.94), true) draw_rect(Rect2(170, 155, 1260, 600), Color("4ab8d1"), false, 2.0) draw_string(font, Vector2(220, 220), account_manager.display_name, HORIZONTAL_ALIGNMENT_LEFT, 520, 30, Color("8ff4ff")) draw_string(font, Vector2(220, 258), account_manager.provider_label(), HORIZONTAL_ALIGNMENT_LEFT, 650, 16, Color("ffcf7a")) - draw_string(font, Vector2(220, 292), "SPIELER-ID %s" % account_manager.short_id(), HORIZONTAL_ALIGNMENT_LEFT, 650, 14, Color("91a8bc")) + draw_string(font, Vector2(220, 292), _ui("SPIELER-ID %s") % account_manager.short_id(), HORIZONTAL_ALIGNMENT_LEFT, 650, 14, Color("91a8bc")) _draw_button(profile_rename_button, "SPIELERNAME ÄNDERN", false, Color("24516a")) - draw_string(font, Vector2(840, 215), "KONTO & CLOUD", HORIZONTAL_ALIGNMENT_LEFT, 470, 18, Color("8feeff")) + draw_string(font, Vector2(840, 215), _ui("KONTO & CLOUD"), HORIZONTAL_ALIGNMENT_LEFT, 470, 18, Color("8feeff")) var cloud_label := "GERGAMES ONLINE VERBUNDEN" if online_manager.is_authenticated() else "GERGAMES ONLINE OFFLINE" var cloud_color := Color("83ffc8") if online_manager.is_authenticated() else Color("ffcf7a") - draw_string(font, Vector2(840, 252), cloud_label, HORIZONTAL_ALIGNMENT_LEFT, 470, 15, cloud_color) - draw_string(font, Vector2(840, 282), "GAME CENTER / APPLE NÄCHSTE STUFE", HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea")) - draw_string(font, Vector2(840, 312), "GOOGLE PLAY GAMES NÄCHSTE STUFE", HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea")) + draw_string(font, Vector2(840, 252), _ui(cloud_label), HORIZONTAL_ALIGNMENT_LEFT, 470, 15, cloud_color) + draw_string(font, Vector2(840, 282), _ui("GAME CENTER / APPLE NÄCHSTE STUFE"), HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea")) + draw_string(font, Vector2(840, 312), _ui("GOOGLE PLAY GAMES NÄCHSTE STUFE"), HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea")) draw_line(Vector2(220, 345), Vector2(1380, 345), Color("315c70"), 1.0) - draw_string(font, Vector2(220, 392), "LOKALE BESTWERTE", HORIZONTAL_ALIGNMENT_LEFT, 1160, 20, Color("8feeff")) - draw_string(font, Vector2(220, 430), "EINSATZSEKTOR", HORIZONTAL_ALIGNMENT_LEFT, 420, 14, Color("91a8bc")) - draw_string(font, Vector2(700, 430), "MISSION", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) - draw_string(font, Vector2(930, 430), "ENDLOS", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) - draw_string(font, Vector2(1160, 430), "BESTE PUNKTE", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) + draw_string(font, Vector2(220, 392), _ui("LOKALE BESTWERTE"), HORIZONTAL_ALIGNMENT_LEFT, 1160, 20, Color("8feeff")) + draw_string(font, Vector2(220, 430), _ui("EINSATZSEKTOR"), HORIZONTAL_ALIGNMENT_LEFT, 420, 14, Color("91a8bc")) + draw_string(font, Vector2(700, 430), _ui("MISSION"), HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) + draw_string(font, Vector2(930, 430), _ui("ENDLOS"), HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) + draw_string(font, Vector2(1160, 430), _ui("BESTE PUNKTE"), HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) for index in MAP_NAMES.size(): var y := 466.0 + index * 28.0 draw_rect(Rect2(210, y - 19, 1180, 25), Color(0.03, 0.09, 0.14, 0.70), true) - draw_string(font, Vector2(235, y), MAP_NAMES[index], HORIZONTAL_ALIGNMENT_LEFT, 420, 12, Color.WHITE) - draw_string(font, Vector2(700, y), "WELLE %02d" % account_manager.get_map_best_wave(index, false), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8ff4ff")) - draw_string(font, Vector2(930, y), "WELLE %02d" % account_manager.get_map_best_wave(index, true), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("ffcf7a")) + draw_string(font, Vector2(235, y), _ui(MAP_NAMES[index]), HORIZONTAL_ALIGNMENT_LEFT, 420, 12, Color.WHITE) + draw_string(font, Vector2(700, y), _ui("WELLE") + " %02d" % account_manager.get_map_best_wave(index, false), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8ff4ff")) + draw_string(font, Vector2(930, y), _ui("WELLE") + " %02d" % account_manager.get_map_best_wave(index, true), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("ffcf7a")) var score := maxi(account_manager.get_map_best_score(index, false), account_manager.get_map_best_score(index, true)) draw_string(font, Vector2(1160, y), "%07d" % score, HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8fffea")) if not profile_message.is_empty(): @@ -2836,17 +3448,30 @@ func _draw_profile_name_entry() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.82)) _draw_button(profile_back_button, "< ZURÜCK", false, Color("26394a")) - draw_string(font, Vector2(0, 190), "SPIELERNAME", HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) - draw_string(font, Vector2(0, 250), "Dieser Name ist für Mitspieler und in Ranglisten sichtbar.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("a9d8e5")) + draw_string(font, Vector2(0, 190), _ui("SPIELERNAME"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) + draw_string(font, Vector2(0, 250), _ui("Dieser Name ist für Mitspieler und in Ranglisten sichtbar."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("a9d8e5")) draw_rect(profile_name_input_rect, Color(0.012, 0.035, 0.065, 0.96), true) draw_rect(profile_name_input_rect, Color("65dfff"), false, 3.0) draw_string(font, profile_name_input_rect.position + Vector2(24, 53), profile_name_input, HORIZONTAL_ALIGNMENT_CENTER, profile_name_input_rect.size.x - 48, 26, Color.WHITE) - draw_string(font, Vector2(0, 448), "3–20 Zeichen · Buchstaben, Zahlen, Leerzeichen, _ und -", HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("91a8bc")) + draw_string(font, Vector2(0, 448), _ui("3–20 Zeichen · Buchstaben, Zahlen, Leerzeichen, _ und -"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("91a8bc")) _draw_button(profile_name_save_button, "SPIELERNAME SPEICHERN", false, Color("185f68")) if not profile_message.is_empty(): draw_string(font, Vector2(300, 585), profile_message, HORIZONTAL_ALIGNMENT_CENTER, 1000, 16, Color("ffcf7a")) +func _draw_pause_menu() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.72)) + var panel := Rect2(500, 330, 600, 290) + draw_rect(panel, Color(0.008, 0.028, 0.055, 0.96), true) + draw_rect(panel, Color("3d8ba5"), false, 2.0) + draw_string(font, Vector2(panel.position.x, 405), "PAUSE", HORIZONTAL_ALIGNMENT_CENTER, panel.size.x, 38, Color("c7f5ff")) + var pause_description := "Die Online-Partie läuft währenddessen weiter." if coop_online_mode else "Die Verteidigung ist angehalten." + draw_string(font, Vector2(panel.position.x, 447), _ui(pause_description), HORIZONTAL_ALIGNMENT_CENTER, panel.size.x, 16, Color("9dbac8")) + _draw_button(pause_resume_button, "FORTSETZEN", false, Color("185f68")) + _draw_button(pause_exit_button, "PARTIE VERLASSEN" if coop_online_mode else "MISSION VERLASSEN", false, Color("60404a")) + + func _draw_overlay(title: String, subtitle: String) -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.72)) @@ -2857,33 +3482,33 @@ func _draw_overlay(title: String, subtitle: String) -> void: func _draw_game_over() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.05, 0.005, 0.015, 0.76)) - draw_string(font, Vector2(0, 330), "REAKTOR VERLOREN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("ff8b78")) - draw_string(font, Vector2(0, 395), "Erreichte Welle: %d" % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) - draw_string(font, Vector2(0, 430), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) + draw_string(font, Vector2(0, 330), _ui("REAKTOR VERLOREN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("ff8b78")) + draw_string(font, Vector2(0, 395), _ui("Erreichte Welle: %d") % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) + draw_string(font, Vector2(0, 430), _ui("Highscore: %d • Effizienzbonus: ×%.2f") % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) _draw_button(start_button, "NEUER VERSUCH", false, Color("6a2835")) func _draw_victory() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.035, 0.05, 0.82)) - draw_string(font, Vector2(0, 310), "SEKTOR GESICHERT", HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) - draw_string(font, Vector2(0, 375), "Alle %d Angriffswellen wurden abgewehrt." % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) - draw_string(font, Vector2(0, 415), "Der Orbitalkern bleibt unter unserer Kontrolle.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8adff2")) - draw_string(font, Vector2(0, 458), "+%d KERNKRISTALLE • GUTHABEN %d" % [last_mission_reward, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8fffea")) - draw_string(font, Vector2(0, 492), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) + draw_string(font, Vector2(0, 310), _ui("SEKTOR GESICHERT"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) + draw_string(font, Vector2(0, 375), _ui("Alle %d Angriffswellen wurden abgewehrt.") % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) + draw_string(font, Vector2(0, 415), _ui("Der Orbitalkern bleibt unter unserer Kontrolle."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8adff2")) + draw_string(font, Vector2(0, 458), _ui("+%d KERNKRISTALLE • GUTHABEN %d") % [last_mission_reward, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8fffea")) + draw_string(font, Vector2(0, 492), _ui("Highscore: %d • Effizienzbonus: ×%.2f") % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) _draw_button(start_button, "ZUM HAUPTMENÜ", false, Color("185f68")) func _draw_endless_offer() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.025, 0.055, 0.88)) - draw_string(font, Vector2(0, 280), "SEKTOR GESICHERT", HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) - draw_string(font, Vector2(0, 345), "Alle %d regulären Wellen wurden abgewehrt." % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) - draw_string(font, Vector2(0, 405), "Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?", HORIZONTAL_ALIGNMENT_CENTER, 1600, 19, Color("8adff2")) + draw_string(font, Vector2(0, 280), _ui("SEKTOR GESICHERT"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) + draw_string(font, Vector2(0, 345), _ui("Alle %d regulären Wellen wurden abgewehrt.") % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) + draw_string(font, Vector2(0, 405), _ui("Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 19, Color("8adff2")) if victory_reward_pending: - draw_string(font, Vector2(0, 450), "WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d" % int(_difficulty().crystals), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("ffcf7a")) + draw_string(font, Vector2(0, 450), _ui("WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d") % int(_difficulty().crystals), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("ffcf7a")) else: - draw_string(font, Vector2(0, 450), "+%d Kernkristalle gutgeschrieben. Ein späteres Ende gilt nicht als gescheitert." % last_mission_reward, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("8fffea")) + draw_string(font, Vector2(0, 450), _ui("+%d Kernkristalle gutgeschrieben. Ein späteres Ende gilt nicht als gescheitert.") % last_mission_reward, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("8fffea")) _draw_button(endless_button, "ENDLOSMODUS STARTEN", false, Color("215b68")) _draw_button(finish_mission_button, "MISSION BEENDEN", false, Color("24394c")) @@ -2891,11 +3516,11 @@ func _draw_endless_offer() -> void: func _draw_endless_result() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.02, 0.045, 0.84)) - draw_string(font, Vector2(0, 315), "ENDLOSAUFTRAG BEENDET", HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("8feeff")) - draw_string(font, Vector2(0, 382), "Erreichte Welle: %d" % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) - draw_string(font, Vector2(0, 425), "Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8adff2")) - draw_string(font, Vector2(0, 462), "KERNKRISTALLE %d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8fffea")) - draw_string(font, Vector2(0, 492), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) + draw_string(font, Vector2(0, 315), _ui("ENDLOSAUFTRAG BEENDET"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("8feeff")) + draw_string(font, Vector2(0, 382), _ui("Erreichte Welle: %d") % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) + draw_string(font, Vector2(0, 425), _ui("Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8adff2")) + draw_string(font, Vector2(0, 462), _ui("KERNKRISTALLE %d") % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8fffea")) + draw_string(font, Vector2(0, 492), _ui("Highscore: %d • Effizienzbonus: ×%.2f") % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) _draw_button(start_button, "NEUER EINSATZ", false, Color("18556f")) @@ -2911,8 +3536,8 @@ func _draw_tutorial() -> void: body = "Starte oben rechts die erste Welle." draw_rect(Rect2(30, 110, 500, 132), Color(0.01, 0.035, 0.07, 0.95)) draw_rect(Rect2(30, 110, 500, 132), Color("55d8ee"), false, 2.0) - draw_string(font, Vector2(50, 147), title, HORIZONTAL_ALIGNMENT_LEFT, 450, 19, Color("8ff4ff")) - draw_string(font, Vector2(50, 184), body, HORIZONTAL_ALIGNMENT_LEFT, 445, 15, Color.WHITE) + draw_string(font, Vector2(50, 147), _ui(title), HORIZONTAL_ALIGNMENT_LEFT, 450, 19, Color("8ff4ff")) + draw_string(font, Vector2(50, 184), _ui(body), HORIZONTAL_ALIGNMENT_LEFT, 445, 15, Color.WHITE) _draw_button(tutorial_skip_button, "ÜBERSPRINGEN", false, Color("24394c")) diff --git a/online_manager.gd b/online_manager.gd index 508da9b..ebf1ff7 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -10,6 +10,8 @@ signal match_found(match_data) signal match_snapshot_received(snapshot: Dictionary) signal match_event_received(event: Dictionary) signal private_room_created(room_code: String) +signal friends_changed() +signal match_invite_received(invite: Dictionary) const SERVER_KEY := "voidbastion-public-v1" const PRODUCTION_HOST := "api.void-bastion.gergames.de" @@ -22,6 +24,7 @@ const OP_UPGRADE := 11 const OP_SELL := 12 const OP_START_WAVE := 13 const OP_READY := 14 +const MAX_PENDING_SNAPSHOTS := 5 var client: NakamaClient var session: NakamaSession @@ -37,6 +40,18 @@ var intentional_disconnect := false var reconnect_in_progress := false var account_manager: AccountManager var auto_connect_enabled := true +var snapshot_previous_ms := 0 +var snapshot_report_ms := 0 +var snapshot_count := 0 +var snapshot_gap_max_ms := 0 +var snapshot_bytes_max := 0 +var snapshot_parse_max_us := 0 +var snapshot_large_gap_count := 0 +var snapshot_coalesced_count := 0 +var pending_snapshot_payloads: Array[String] = [] +var friends: Array[Dictionary] = [] +var friend_statuses: Dictionary = {} +var presence_status := "" func _ready() -> void: @@ -46,6 +61,35 @@ func _ready() -> void: call_deferred("connect_and_authenticate") +func _process(_delta: float) -> void: + _flush_pending_snapshots() + + +func _flush_pending_snapshots() -> void: + if pending_snapshot_payloads.is_empty(): + return + var payloads := pending_snapshot_payloads.duplicate() + pending_snapshot_payloads.clear() + var latest: Dictionary = {} + var recent_shots: Array = [] + for payload in payloads: + var parse_start_us := Time.get_ticks_usec() + var parsed = JSON.parse_string(payload) + snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us) + if not parsed is Dictionary: + continue + latest = parsed + var shots = parsed.get("shots", []) + if shots is Array: + recent_shots.append_array(shots) + if latest.is_empty(): + return + if payloads.size() > 1: + latest["shots"] = recent_shots + snapshot_coalesced_count += payloads.size() - 1 + match_snapshot_received.emit(latest) + + func _is_headless_or_editor() -> bool: return DisplayServer.get_name() == "headless" or Engine.is_editor_hint() @@ -96,6 +140,8 @@ func _after_authentication() -> void: else: intentional_disconnect = false _set_connection_state("online") + var parent_game = get_parent() + await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu") authenticated.emit(session.user_id, session.username) await flush_pending_scores() @@ -113,16 +159,22 @@ func _record_activity() -> void: func _bind_socket_signals() -> void: socket.received_matchmaker_matched.connect(_on_matchmaker_matched) socket.received_match_state.connect(_on_match_state) + socket.received_status_presence.connect(_on_status_presence) + socket.received_notification.connect(_on_notification) socket.closed.connect(_on_socket_closed) func disconnect_from_server() -> void: intentional_disconnect = true + pending_snapshot_payloads.clear() if socket: socket.close() socket = null session = null current_match = null + friends.clear() + friend_statuses.clear() + presence_status = "" matchmaker_ticket = "" private_room_code = "" lobby_ready = false @@ -190,6 +242,124 @@ func delete_online_account() -> Dictionary: return {"accepted": true} +func redeem_unlock_code(code: String) -> Dictionary: + if not is_authenticated(): + return {"accepted": false, "error": "offline"} + var result = await client.rpc_async(session, "redeem_unlock_code", JSON.stringify({"code": code})) + var parsed := _parse_rpc_result(result) + if parsed.is_empty(): + return {"accepted": false, "error": "invalid"} + return parsed + + +func get_promo_unlocks() -> Dictionary: + if not is_authenticated(): + return {} + var result = await client.rpc_async(session, "get_promo_unlocks", "{}") + var parsed := _parse_rpc_result(result) + return parsed if bool(parsed.get("accepted", false)) else {} + + +func set_presence_status(value: String) -> void: + if not is_online() or value == presence_status: + return + var safe_value := "playing" if value == "playing" else "menu" + var result = await socket.update_status_async(safe_value) + if result != null and not result.is_exception(): + presence_status = safe_value + + +func refresh_friends() -> bool: + if not is_authenticated(): + return false + var result = await client.list_friends_async(session, null, 100) + if result == null or result.is_exception(): + return false + var rows: Array[Dictionary] = [] + var follow_ids := PackedStringArray() + for item in result.friends: + if item.user == null: + continue + var user_id: String = item.user.id + var state: int = item.state + rows.append({"id": user_id, "username": item.user.username, + "name": item.user.display_name if not item.user.display_name.is_empty() else item.user.username, + "state": state, "online": item.user.online}) + if state == 0: + follow_ids.append(user_id) + friends = rows + friend_statuses.clear() + if is_online() and not follow_ids.is_empty(): + var statuses = await socket.follow_users_async(follow_ids, PackedStringArray()) + if statuses != null and not statuses.is_exception(): + for presence in statuses.presences: + friend_statuses[presence.user_id] = presence.status + friends_changed.emit() + return true + + +func add_friend_by_username(username: String) -> bool: + var normalized := username.strip_edges() + if not is_authenticated() or normalized.is_empty() or normalized == session.username: + return false + var result = await client.add_friends_async(session, null, PackedStringArray([normalized])) + if result == null or result.is_exception(): + return false + await refresh_friends() + return true + + +func accept_friend(user_id: String) -> bool: + if not is_authenticated() or user_id.is_empty(): + return false + var result = await client.add_friends_async(session, PackedStringArray([user_id])) + if result == null or result.is_exception(): + return false + await refresh_friends() + return true + + +func remove_friend(user_id: String) -> bool: + if not is_authenticated() or user_id.is_empty(): + return false + var result = await client.delete_friends_async(session, PackedStringArray([user_id])) + if result == null or result.is_exception(): + return false + friend_statuses.erase(user_id) + await refresh_friends() + return true + + +func invite_friend(user_id: String, room_code: String) -> bool: + if not is_authenticated() or user_id.is_empty() or room_code.length() != 6: + return false + var result = await client.rpc_async(session, "invite_friend", JSON.stringify({"friend_id": user_id, "room_code": room_code})) + return bool(_parse_rpc_result(result).get("accepted", false)) + + +func _on_status_presence(event) -> void: + for presence in event.joins: + friend_statuses[presence.user_id] = presence.status + for presence in event.leaves: + friend_statuses.erase(presence.user_id) + friends_changed.emit() + + +func _on_notification(notification) -> void: + if notification.code in [-2, -3]: + refresh_friends() + return + if notification.code != 101: + return + var content = JSON.parse_string(notification.content) + if not content is Dictionary: + return + var code := String(content.get("room_code", "")) + if code.length() != 6: + return + match_invite_received.emit({"room_code": code, "from": String(content.get("from", "Mitspieler"))}) + + func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limit := 20) -> Array: if not is_authenticated(): return [] @@ -245,6 +415,7 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool: func cancel_matchmaking() -> void: intentional_disconnect = true + pending_snapshot_payloads.clear() if socket and not matchmaker_ticket.is_empty(): await socket.remove_matchmaker_async(matchmaker_ticket) matchmaker_ticket = "" @@ -257,6 +428,7 @@ func cancel_matchmaking() -> void: func create_private_room(difficulty := 1) -> bool: intentional_disconnect = false + pending_snapshot_payloads.clear() if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false @@ -284,6 +456,7 @@ func create_private_room(difficulty := 1) -> bool: func join_private_room(room_code: String) -> bool: intentional_disconnect = false + pending_snapshot_payloads.clear() if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false @@ -310,6 +483,7 @@ func join_private_room(room_code: String) -> bool: func _on_matchmaker_matched(matched) -> void: intentional_disconnect = false + pending_snapshot_payloads.clear() matchmaker_ticket = "" current_match = await socket.join_matched_async(matched) if current_match == null or current_match.is_exception(): @@ -351,6 +525,7 @@ func request_wave_start() -> bool: func leave_current_match() -> void: + pending_snapshot_payloads.clear() if socket and current_match != null: await socket.leave_match_async(current_match.match_id) current_match = null @@ -361,16 +536,44 @@ func leave_current_match() -> void: func _on_match_state(data) -> void: if current_match == null or data.match_id != current_match.match_id: return - var parsed = JSON.parse_string(data.data) - if not parsed is Dictionary: - return if data.op_code == OP_SNAPSHOT: - match_snapshot_received.emit(parsed) + var now_ms := Time.get_ticks_msec() + if snapshot_previous_ms > 0: + var gap_ms := now_ms - snapshot_previous_ms + snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms) + if gap_ms > 500: + snapshot_large_gap_count += 1 + snapshot_previous_ms = now_ms + snapshot_count += 1 + snapshot_bytes_max = maxi(snapshot_bytes_max, String(data.data).to_utf8_buffer().size()) + pending_snapshot_payloads.append(String(data.data)) + if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS: + pending_snapshot_payloads.pop_front() + snapshot_coalesced_count += 1 + if snapshot_report_ms == 0: + snapshot_report_ms = now_ms + elif now_ms - snapshot_report_ms >= 10000: + print("ONLINE DIAG snapshots=%d coalesced=%d max_gap_ms=%d gaps_over_500ms=%d max_bytes=%d max_parse_us=%d" % [snapshot_count, snapshot_coalesced_count, snapshot_gap_max_ms, snapshot_large_gap_count, snapshot_bytes_max, snapshot_parse_max_us]) + snapshot_report_ms = now_ms + snapshot_count = 0 + snapshot_coalesced_count = 0 + snapshot_gap_max_ms = 0 + snapshot_large_gap_count = 0 + snapshot_bytes_max = 0 + snapshot_parse_max_us = 0 elif data.op_code == OP_EVENT: - match_event_received.emit(parsed) + var parsed = JSON.parse_string(data.data) + if parsed is Dictionary: + match_event_received.emit(parsed) func _on_socket_closed() -> void: + print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null)) + pending_snapshot_payloads.clear() + presence_status = "" + friend_statuses.clear() + friends_changed.emit() + snapshot_previous_ms = 0 var reconnect_match_id := "" if not intentional_disconnect and current_match != null: reconnect_match_id = String(current_match.match_id) @@ -381,6 +584,28 @@ func _on_socket_closed() -> void: reconnect_in_progress = true matchmaking_state_changed.emit("reconnecting") _recover_match.call_deferred(reconnect_match_id) + elif not intentional_disconnect and reconnect_match_id.is_empty() and not reconnect_in_progress and is_authenticated(): + reconnect_in_progress = true + _recover_presence.call_deferred() + + +func _recover_presence() -> void: + for attempt in 5: + await get_tree().create_timer(1.5 + attempt).timeout + if intentional_disconnect or not is_authenticated(): + break + socket = Nakama.create_socket_from(client) + _bind_socket_signals() + var connected = await socket.connect_async(session, true, 5) + if connected == null or connected.is_exception(): + continue + reconnect_in_progress = false + _set_connection_state("online") + var parent_game = get_parent() + await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu") + await refresh_friends() + return + reconnect_in_progress = false func _recover_match(match_id: String) -> void: @@ -395,13 +620,17 @@ func _recover_match(match_id: String) -> void: continue var rejoined = await socket.join_match_async(match_id) if rejoined != null and not rejoined.is_exception(): + print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1)) current_match = rejoined intentional_disconnect = false reconnect_in_progress = false _set_connection_state("online") + var parent_game = get_parent() + await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over"] else "menu") matchmaking_state_changed.emit("reconnected") return reconnect_in_progress = false + print("ONLINE DIAG websocket_reconnect_failed") matchmaking_state_changed.emit("connection_lost") diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 3229707..1c88b57 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -15,6 +15,7 @@ if (-not [string]::IsNullOrWhiteSpace($MakeNsisExe)) { $nsisCandidates += $pathNsis.Source } $nsisCandidates += @( + (Join-Path $env:LOCALAPPDATA "Programs\NSIS-3.13\Bin\makensis.exe"), (Join-Path $env:LOCALAPPDATA "Programs\NSIS-3.13\makensis.exe"), "C:\Program Files (x86)\NSIS\makensis.exe", "C:\Program Files\NSIS\makensis.exe" diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index f3a4368..a6d2f89 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -6,7 +6,7 @@ RequestExecutionLevel user !include "LogicLib.nsh" !define PRODUCT_NAME "Void Bastion" -!define PRODUCT_VERSION "0.1.0-alpha.8" +!define PRODUCT_VERSION "0.1.0-alpha.10" !define PRODUCT_PUBLISHER "GerGames" !define PRODUCT_WEBSITE "https://void-bastion.gergames.de/" !define PRODUCT_EXE "Void-Bastion.exe" @@ -21,7 +21,7 @@ BrandingText "GerGames" ShowInstDetails show ShowUninstDetails show -VIProductVersion "0.1.0.8" +VIProductVersion "0.1.0.10" VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}" VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}" VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup" diff --git a/project.godot b/project.godot index 89eb375..0114bb9 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="Void Bastion" -config/version="0.1.0-alpha.8" +config/version="0.1.0-alpha.10" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7") config/icon="res://assets/app_icon.png" diff --git a/server/nakama/README.md b/server/nakama/README.md index c7df357..7d8872d 100644 --- a/server/nakama/README.md +++ b/server/nakama/README.md @@ -28,9 +28,14 @@ Für eine öffentliche Testversion wird Port 7350 später über Nginx Proxy Mana 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. +## Freunde und Einladungen + +Freundschaftsanfragen, bestätigte Freundschaften und Online-Präsenz verwenden die eingebauten Nakama-Funktionen. Die app-eigene RPC `invite_friend` nimmt ausschließlich die Freundes-ID und einen sechsstelligen privaten Raumcode entgegen. Sie prüft, dass der Absender den Raum erstellt hat und eine gegenseitige Freundschaft besteht. Je Empfänger gilt ein Mindestabstand von 15 Sekunden. Die Benachrichtigung ist nicht persistent und enthält nur Absendername und Raumcode; ein Chat oder frei eingebbarer Einladungstext existiert nicht. Die Funktion muss zusammen mit dem aktualisierten Godot-Client ausgerollt werden. + ## 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. +- `create_unlock_code`, `list_unlock_codes` und `delete_unlock_code`: nur Server-zu-Server mit `NAKAMA_HTTP_KEY`; verwalten private Freischaltcodes inklusive Einlösungsstatus und optionaler Admin-Bemerkung. Löschen ist nur bei null Einlösungen möglich; eine versionsgebundene Löschung schützt vor einer gleichzeitigen Einlösung. -Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden. +Der Code-Lookup verwendet einen SHA-256-Schlüssel. Für die private Admin-Übersicht wird der Klartextcode zusätzlich in einem Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0` gehalten; Spielclients können dieses Objekt nicht lesen. Bemerkungen sind auf 160 Zeichen beschränkt. Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden. diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index 6fa00c0..3373e11 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -1,4 +1,4 @@ -var GAME_VERSION = "0.23"; +var GAME_VERSION = "0.25"; var MAP_COUNT = 7; var DIFFICULTY_COUNT = 3; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; @@ -8,10 +8,14 @@ var ACTIVITY_COLLECTION = "player_activity"; var ACTIVITY_KEY = "summary"; var STATS_COLLECTION = "internal_stats"; var STATS_KEY = "global"; +var PROMO_CODE_COLLECTION = "unlock_codes"; +var PROMO_UNLOCK_COLLECTION = "player_unlocks"; +var PROMO_UNLOCK_KEY = "website_codes"; +var PROMO_ATTEMPT_COLLECTION = "unlock_attempts"; 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; -var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758; +var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480; +var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 84, PLAYFIELD_BOTTOM = 758; var LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]]; 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]]); @@ -22,7 +26,8 @@ var TOWERS = [ {cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"}, {cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72}, {cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1}, - {cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70} + {cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}, + {cost:210,damage:20,range:230,cooldown:0.42,targetClass:"air"} ]; var ENEMIES = [ {category:"air",hp:70,speed:105,reward:18,breachDamage:1}, @@ -140,6 +145,137 @@ function rpcPrivateStats(ctx, logger, nk, payload) { var stats = record && record.value ? record.value : emptyStats(Date.now()); return JSON.stringify(stats); } +function rpcCreateUnlockCode(ctx, logger, nk, payload) { + if (ctx.userId) throw new Error("server-to-server access required"); + var request; + try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); } + var reward = String(request.reward || ""); + if (["all_towers", "all_maps", "all"].indexOf(reward) < 0) throw new Error("invalid unlock reward"); + var maxUses = clampInteger(request.max_uses === undefined ? 1 : request.max_uses, 1, 50000); + var note = String(request.note || "").trim(); + if (note.length > 160) throw new Error("note exceeds 160 characters"); + for (var attempt = 0; attempt < 5; attempt++) { + var code = nk.uuidv4().replace(/-/g, "").substring(0, 24).toUpperCase(); + var codeKey = nk.sha256Hash(code); + if (readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID)) continue; + nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:{code:code,reward:reward,note:note,max_uses:maxUses,uses:0,redeemed_users:[],created_at:Date.now()},permissionRead:0,permissionWrite:0,version:"*"}]); + return JSON.stringify({accepted:true,code:code,reward:reward,note:note,max_uses:maxUses,uses:0,status:"open"}); + } + throw new Error("could not allocate unique unlock code"); +} +function rpcListUnlockCodes(ctx, logger, nk, payload) { + if (ctx.userId) throw new Error("server-to-server access required"); + var codes = [], cursor = "", pages = 0; + do { + var result = nk.storageList(SYSTEM_USER_ID, PROMO_CODE_COLLECTION, 100, cursor); + var objects = Array.isArray(result) ? result : (result && Array.isArray(result.objects) ? result.objects : []); + for (var i = 0; i < objects.length; i++) { + var object = objects[i], value = object && object.value ? object.value : {}; + var uses = Math.max(0, Math.floor(Number(value.uses || 0))); + var maxUses = Math.max(1, Math.floor(Number(value.max_uses || 1))); + codes.push({ + code:String(value.code || ""), + code_key:String(object && object.key ? object.key : ""), + code_id:String(object && object.key ? object.key : "").slice(-8), + reward:String(value.reward || ""), + note:String(value.note || ""), + uses:uses, + max_uses:maxUses, + created_at:Number(value.created_at || 0), + status:uses >= maxUses ? "redeemed" : (uses > 0 ? "partial" : "open") + }); + } + cursor = result && !Array.isArray(result) ? String(result.cursor || "") : ""; + pages++; + } while (cursor && pages < 10); + codes.sort(function(a, b) { return b.created_at - a.created_at; }); + return JSON.stringify({codes:codes,truncated:!!cursor}); +} +function rpcDeleteUnlockCode(ctx, logger, nk, payload) { + if (ctx.userId) throw new Error("server-to-server access required"); + var request; + try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); } + var codeKey = String(request.code_key || "").toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(codeKey)) return JSON.stringify({accepted:false,error:"invalid"}); + for (var attempt = 0; attempt < 3; attempt++) { + var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID); + if (!record || !record.value) return JSON.stringify({accepted:false,error:"not_found"}); + var value = record.value; + if (Number(value.uses || 0) !== 0 || (Array.isArray(value.redeemed_users) && value.redeemed_users.length > 0)) { + return JSON.stringify({accepted:false,error:"already_used"}); + } + if (!record.version) throw new Error("unlock code has no storage version"); + try { + nk.storageDelete([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,version:record.version}]); + return JSON.stringify({accepted:true}); + } catch (error) { + if (attempt === 2) throw error; + } + } + return JSON.stringify({accepted:false,error:"temporary"}); +} +function allowPromoAttempt(ctx, nk) { + var now = Date.now(), key = "user_" + ctx.userId; + for (var attempt = 0; attempt < 3; attempt++) { + var record = readStorageRecord(nk, PROMO_ATTEMPT_COLLECTION, key, SYSTEM_USER_ID); + var value = record && record.value ? record.value : {window_started:now,count:0}; + if (now - Number(value.window_started || 0) >= 5 * 60 * 1000) value = {window_started:now,count:0}; + if (Number(value.count || 0) >= 10) return false; + value.count = Number(value.count || 0) + 1; + var write = {collection:PROMO_ATTEMPT_COLLECTION,key:key,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"}; + try { nk.storageWrite([write]); return true; } + catch (error) { if (attempt === 2) return false; } + } + return false; +} +function writePromoUnlocks(nk, userId, reward) { + for (var attempt = 0; attempt < 3; attempt++) { + var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, userId); + var value = record && record.value ? record.value : {all_towers:false,all_maps:false}; + if (reward === "all_towers" || reward === "all") value.all_towers = true; + if (reward === "all_maps" || reward === "all") value.all_maps = true; + var write = {collection:PROMO_UNLOCK_COLLECTION,key:PROMO_UNLOCK_KEY,userId:userId,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"}; + try { nk.storageWrite([write]); return value; } + catch (error) { if (attempt === 2) throw error; } + } + throw new Error("could not save player unlocks"); +} +function rpcRedeemUnlockCode(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("authentication required"); + if (!allowPromoAttempt(ctx, nk)) return JSON.stringify({accepted:false,error:"rate_limited"}); + var request; + try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid"}); } + var code = String(request.code || "").toUpperCase().replace(/[\s-]/g, ""); + if (!/^[A-F0-9]{24}$/.test(code)) return JSON.stringify({accepted:false,error:"invalid"}); + var codeKey = nk.sha256Hash(code); + for (var attempt = 0; attempt < 4; attempt++) { + var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID); + if (!record || !record.value) return JSON.stringify({accepted:false,error:"invalid"}); + var value = record.value; + var redeemed = Array.isArray(value.redeemed_users) ? value.redeemed_users : []; + if (redeemed.indexOf(ctx.userId) >= 0) { + var existingUnlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || "")); + return JSON.stringify({accepted:true,already_redeemed:true,reward:value.reward,all_towers:!!existingUnlocks.all_towers,all_maps:!!existingUnlocks.all_maps}); + } + if (Number(value.uses || 0) >= Number(value.max_uses || 1)) return JSON.stringify({accepted:false,error:"exhausted"}); + value.redeemed_users = redeemed.concat([ctx.userId]); + value.uses = Number(value.uses || 0) + 1; + try { + nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record.version}]); + var unlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || "")); + return JSON.stringify({accepted:true,reward:value.reward,all_towers:!!unlocks.all_towers,all_maps:!!unlocks.all_maps}); + } catch (error) { + if (attempt === 3) throw error; + } + } + return JSON.stringify({accepted:false,error:"temporary"}); +} +function rpcGetPromoUnlocks(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("authentication required"); + var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, ctx.userId); + var value = record && record.value ? record.value : {all_towers:false,all_maps:false}; + return JSON.stringify({accepted:true,all_towers:!!value.all_towers,all_maps:!!value.all_maps}); +} function rpcSubmitRun(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("authentication required"); var run; @@ -233,6 +369,40 @@ function rpcJoinPrivateRoom(ctx, logger, nk, payload) { return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)}); } +function rpcInviteFriend(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("authentication required"); + var request; + try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); } + var friendId = String(request.friend_id || ""), code = normalizeRoomCode(request.room_code); + if (!/^[0-9a-f-]{36}$/i.test(friendId) || code.length !== 6 || friendId === ctx.userId) + return JSON.stringify({accepted:false,error:"invalid_invite"}); + var room = privateRoomRecord(nk, code); + if (!room || !room.value || room.value.host_user_id !== ctx.userId) + return JSON.stringify({accepted:false,error:"not_room_host"}); + var mutual = false, cursor = ""; + for (var page = 0; page < 10 && !mutual; page++) { + var result = nk.friendsList(ctx.userId, 100, 0, cursor); + var list = Array.isArray(result) ? result : (result && Array.isArray(result.friends) ? result.friends : []); + for (var i = 0; i < list.length; i++) { + if (list[i].state === 0 && list[i].user && String(list[i].user.userId || list[i].user.id || "") === friendId) { mutual = true; break; } + } + cursor = result && result.cursor ? result.cursor : ""; + if (!cursor) break; + } + if (!mutual) return JSON.stringify({accepted:false,error:"not_mutual_friends"}); + var rateKey = "invite_" + friendId; + var previous = readStorageRecord(nk, "friend_invite_limits", rateKey, ctx.userId); + var now = Date.now(); + if (previous && previous.value && now - Number(previous.value.sent_at || 0) < 15000) + return JSON.stringify({accepted:false,error:"invite_cooldown"}); + var senderName = String(ctx.username || "Pilot"); + var senders = nk.usersGetId([ctx.userId]); + if (senders.length && senders[0].displayName) senderName = String(senders[0].displayName); + nk.storageWrite([{collection:"friend_invite_limits",key:rateKey,userId:ctx.userId,value:{sent_at:now},permissionRead:0,permissionWrite:0}]); + nk.notificationsSend([{userId:friendId,subject:"Void Bastion Invite",content:{room_code:code,from:senderName.slice(0,32)},code:101,senderId:ctx.userId,persistent:false}]); + return JSON.stringify({accepted:true}); +} + function matchmakerMatched(ctx, logger, nk, matches) { var difficulty = 1; if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty)))); @@ -241,7 +411,7 @@ function matchmakerMatched(ctx, logger, nk, matches) { function initialState(params) { var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1)))); 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,waveSchedule:[],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:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0}; } 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)}; } @@ -257,9 +427,15 @@ function snapshot(state,tick) { 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}; } - 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}; + var towers=state.towers.map(function(tower) { + return {id:tower.id,x:tower.x,y:tower.y,type:tower.type,level:tower.level,owner:tower.owner,angle:Math.round(tower.angle*1000)/1000}; + }); + var enemies=state.enemies.map(function(enemy) { + return {id:enemy.id,type:enemy.type,x:Math.round(enemy.x*10)/10,y:Math.round(enemy.y*10)/10,path_index:enemy.path_index,route_id:enemy.route_id,hp:Math.round(enemy.hp*10)/10,max_hp:Math.round(enemy.max_hp*10)/10,angle:Math.round(enemy.angle*1000)/1000,boss:enemy.boss,shield:enemy.shield,shield_hp:Math.round(enemy.shield_hp*10)/10,max_shield_hp:Math.round(enemy.max_shield_hp*10)/10}; + }); + 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:towers,enemies:enemies,shots:state.shots||[]}; } -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.shots=[]; 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 matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) { var rejoined=false; @@ -386,6 +562,7 @@ function updateEnemies(state,delta) { } function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); } function updateTowers(state,delta) { + state.shots=state.shots||[]; for (var i=0;i=0?Math.PI:0); if (t.cooldown>0) continue; + state.shots.push({tower_id:t.id,type:t.type,from_x:t.x,from_y:t.y,to_x:target.x,to_y:target.y}); var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1); if (t.type===5) { 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; @@ -444,6 +622,11 @@ function handleCommand(nk,dispatcher,state,message) { if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot}); } function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) { + var loopStartMs=Date.now(); + if (state.lastLoopStartMs && loopStartMs-state.lastLoopStartMs>250 && logger.warn) + logger.warn("ONLINE DIAG tick_gap_ms=%d tick=%d",loopStartMs-state.lastLoopStartMs,tick); + state.lastLoopStartMs=loopStartMs; + state.shots=[]; if (!Object.keys(state.presences).length) { state.emptyTicks += 1; if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; } @@ -452,6 +635,8 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) { if (state.pausedForReconnect&&tick>=state.reconnectDeadlineTick) { state.gameOver=true; state.pausedForReconnect=false; state.waveActive=false; broadcastEvent(dispatcher,"partner_timeout",{}); } if (state.started&&!state.pausedForReconnect&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); } if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick); + var loopMs=Date.now()-loopStartMs; + if (loopMs>70 && logger.warn) logger.warn("ONLINE DIAG loop_ms=%d tick=%d enemies=%d towers=%d",loopMs,tick,state.enemies.length,state.towers.length); return {state:state}; } function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; } @@ -462,11 +647,17 @@ function InitModule(ctx,logger,nk,initializer) { initializer.registerRpc("server_info",rpcServerInfo); initializer.registerRpc("record_activity",rpcRecordActivity); initializer.registerRpc("private_stats",rpcPrivateStats); + initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode); + initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes); + initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode); + initializer.registerRpc("redeem_unlock_code",rpcRedeemUnlockCode); + initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks); initializer.registerRpc("submit_run",rpcSubmitRun); initializer.registerRpc("update_display_name",rpcUpdateDisplayName); initializer.registerRpc("delete_account",rpcDeleteAccount); initializer.registerRpc("create_private_room",rpcCreatePrivateRoom); initializer.registerRpc("join_private_room",rpcJoinPrivateRoom); + initializer.registerRpc("invite_friend",rpcInviteFriend); initializer.registerMatchmakerMatched(matchmakerMatched); initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal}); logger.info("Void Bastion online runtime %s loaded",GAME_VERSION); diff --git a/server/nakama/tests/runtime_sim_test.js b/server/nakama/tests/runtime_sim_test.js index 2bd8497..365d319 100644 --- a/server/nakama/tests/runtime_sim_test.js +++ b/server/nakama/tests/runtime_sim_test.js @@ -2,6 +2,7 @@ const assert = require("assert"); const fs = require("fs"); const vm = require("vm"); const path = require("path"); +const crypto = require("crypto"); const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8"); const context = { console, Math, Date, JSON, isFinite }; @@ -9,8 +10,10 @@ vm.createContext(context); vm.runInContext(source, context); assert.strictEqual(context.ENEMIES.length, 9); +assert.strictEqual(context.SNAPSHOT_TICKS, 1, "Online snapshots should be sent at the full 10 Hz simulation rate."); assert.strictEqual(context.ENEMIES[0].category, "air"); assert.strictEqual(context.ENEMIES[6].category, "ground"); +assert.strictEqual(context.TOWERS.length, 7); 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."); @@ -18,6 +21,48 @@ 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."); +assert.strictEqual(context.TOWERS[6].targetClass, "air", "The Flak tower must only target air units."); +const hostId = "11111111-1111-1111-1111-111111111111"; +const friendId = "22222222-2222-2222-2222-222222222222"; +const sentInvites = []; +let inviteLimitRecord = null; +const inviteNk = { + storageRead(requests) { + return requests[0].collection === "private_rooms" + ? [{ value: { host_user_id: hostId, match_id: "private-match" } }] + : requests[0].collection === "friend_invite_limits" && inviteLimitRecord ? [inviteLimitRecord] : []; + }, + storageWrite(items) { + if (items[0].collection === "friend_invite_limits") inviteLimitRecord = { value: items[0].value }; + }, + usersGetId() { return [{ displayName: "Commander" }]; }, + friendsList() { return { friends: [{ state: 0, user: { userId: friendId } }], cursor: "" }; }, + notificationsSend(items) { sentInvites.push(...items); } +}; +const inviteCtx = { userId: hostId, username: "pilot_host" }; +assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, true); +assert.strictEqual(sentInvites.length, 1); +assert.strictEqual(sentInvites[0].content.room_code, "ABC123"); +assert.strictEqual(sentInvites[0].content.from, "Commander"); +assert.strictEqual(sentInvites[0].persistent, false, "Game invitations must not remain as stale offline messages."); +assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).error, "invite_cooldown", "Repeated invitations must be rate limited."); +assert.strictEqual(sentInvites.length, 1, "A rate-limited invitation must not send another notification."); +assert.strictEqual(JSON.parse(context.rpcInviteFriend({ userId: friendId }, {}, inviteNk, JSON.stringify({ friend_id: hostId, room_code: "ABC123" }))).accepted, false, "Only the private room host may invite."); +assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, { ...inviteNk, friendsList: () => ({ friends: [] }) }, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, false, "Only mutual friends may be invited."); +const flakState = { + towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }], + enemies: [ + { id: 401, type: 0, x: 850, y: 200, route_id: 0, path_index: 1, hp: 100, shield: false }, + { id: 402, type: 6, x: 850, y: 210, route_id: 0, path_index: 1, hp: 100, shield: false } + ], + credits: [0, 0] +}; +context.updateTowers(flakState, 1); +assert.ok(flakState.enemies.find((enemy) => enemy.id === 401).hp < 100, "Flak should damage air units."); +assert.strictEqual(flakState.enemies.find((enemy) => enemy.id === 402).hp, 100, "Flak must ignore ground units."); +assert.strictEqual(flakState.shots.length, 1, "Firing towers should publish a visual shot event."); +assert.strictEqual(flakState.shots[0].tower_id, 20); +assert.strictEqual(flakState.shots[0].type, 6); 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."); @@ -123,6 +168,89 @@ 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/); +const promoStorage = new Map(); +let promoVersion = 0; +let promoCodeSequence = 0; +let beforePromoDelete = null; +const promoNk = { + uuidv4() { + promoCodeSequence += 1; + return `${String(promoCodeSequence).padStart(8, "0")}-0000-4000-8000-000000000001`; + }, + sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); }, + storageRead(ops) { + return ops.map(op => promoStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); + }, + storageWrite(ops) { + for (const op of ops) { + const key = `${op.collection}:${op.userId}:${op.key}`; + const previous = promoStorage.get(key); + if (op.version === "*" && previous) throw new Error("version conflict"); + if (op.version && op.version !== "*" && (!previous || previous.version !== op.version)) throw new Error("version conflict"); + promoVersion += 1; + promoStorage.set(key, { collection: op.collection, userId: op.userId, key: op.key, value: JSON.parse(JSON.stringify(op.value)), version: String(promoVersion) }); + } + }, + storageList(userId, collection, limit, cursor) { + const objects = Array.from(promoStorage.values()) + .filter(record => record.userId === userId && record.collection === collection) + .slice(0, limit) + .map(record => ({ key: record.key, value: JSON.parse(JSON.stringify(record.value)) })); + return { objects, cursor: "" }; + }, + storageDelete(ops) { + for (const op of ops) { + const key = `${op.collection}:${op.userId}:${op.key}`; + if (beforePromoDelete) beforePromoDelete(key); + const previous = promoStorage.get(key); + if (!previous || op.version !== previous.version) throw new Error("version conflict"); + promoStorage.delete(key); + } + } +}; +assert.throws(() => context.rpcCreateUnlockCode({ userId: "pilot-a" }, logger, promoNk, JSON.stringify({ reward: "all_maps", max_uses: 1 })), /server-to-server/); +assert.throws(() => context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps", note: "x".repeat(161) })), /160 characters/); +const createdPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps", max_uses: 2, note: "Für den Multiplayer-Test" }))); +assert.strictEqual(createdPromo.accepted, true); +assert.strictEqual(createdPromo.code.length, 24); +assert.strictEqual(createdPromo.note, "Für den Multiplayer-Test"); +assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0].status, "open"); +assert.throws(() => context.rpcListUnlockCodes({ userId: "pilot-a" }, logger, promoNk, "{}"), /server-to-server/); +const redeemedPromo = JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-a" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))); +assert.strictEqual(redeemedPromo.accepted, true); +assert.strictEqual(redeemedPromo.all_maps, true); +assert.strictEqual(redeemedPromo.all_towers, false); +const partiallyRedeemed = JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0]; +assert.strictEqual(partiallyRedeemed.status, "partial"); +assert.strictEqual(partiallyRedeemed.uses, 1); +assert.strictEqual(partiallyRedeemed.code, createdPromo.code); +assert.strictEqual(partiallyRedeemed.note, "Für den Multiplayer-Test"); +assert.strictEqual(JSON.parse(context.rpcGetPromoUnlocks({ userId: "pilot-promo-a" }, logger, promoNk, "{}")).all_maps, true); +assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-a" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).already_redeemed, true); +assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-b" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).accepted, true); +assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0].status, "redeemed"); +assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-c" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).error, "exhausted"); +const usedCodeKey = promoNk.sha256Hash(createdPromo.code); +assert.throws(() => context.rpcDeleteUnlockCode({ userId: "pilot-a" }, logger, promoNk, JSON.stringify({ code_key: usedCodeKey })), /server-to-server/); +assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: usedCodeKey }))).error, "already_used"); +assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: "bad-key" }))).error, "invalid"); +const unusedPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_towers" }))); +const unusedCodeKey = promoNk.sha256Hash(unusedPromo.code); +assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes.find(entry => entry.code === unusedPromo.code).code_key, unusedCodeKey); +assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: unusedCodeKey }))).accepted, true); +assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: unusedCodeKey }))).error, "not_found"); +assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-d" }, logger, promoNk, JSON.stringify({ code: unusedPromo.code }))).error, "invalid"); +const racingPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps" }))); +const racingCodeKey = promoNk.sha256Hash(racingPromo.code); +beforePromoDelete = key => { + beforePromoDelete = null; + const previous = promoStorage.get(key); + promoVersion += 1; + promoStorage.set(key, { ...previous, version: String(promoVersion), value: { ...previous.value, uses: 1, redeemed_users: ["pilot-race"] } }); +}; +assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: racingCodeKey }))).error, "already_used"); +assert.ok(promoStorage.has(`unlock_codes:${context.SYSTEM_USER_ID}:${racingCodeKey}`)); + let leaderboardWrite = null; const leaderboardNk = { accountGetId() { return { user: { displayName: "Kommandant Ägir" } }; }, @@ -194,6 +322,12 @@ assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === 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(sent.some(entry => entry.opCode === 1 && entry.data.started === true)); +const compactSnapshot = context.snapshot(state, 29); +const untrimmedSnapshot = { ...compactSnapshot, towers: state.towers, enemies: state.enemies }; +assert.ok(JSON.stringify(compactSnapshot).length < JSON.stringify(untrimmedSnapshot).length, "Frequent snapshots should omit simulation-only properties."); +assert.ok(compactSnapshot.towers.every(tower => !Object.hasOwn(tower, "cooldown"))); +assert.ok(compactSnapshot.enemies.every(enemy => !Object.hasOwn(enemy, "shield_resistance") && !Object.hasOwn(enemy, "reward"))); +assert.ok(state.towers.every(tower => Object.hasOwn(tower, "cooldown")), "Network serialization must not alter simulation state."); const towerA = state.towers.find(tower => tower.owner === 0); state = context.matchLoop({}, logger, nk, dispatcher, 30, state, [message(presenceA, 11, { tower_id: towerA.id })]).state; diff --git a/tests/online_integration_test.gd b/tests/online_integration_test.gd index cb27fb5..11cd761 100644 --- a/tests/online_integration_test.gd +++ b/tests/online_integration_test.gd @@ -32,7 +32,7 @@ func _run() -> void: assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.") var info_data = JSON.parse_string(info.payload) assert(info_data is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.") - assert(info_data.get("version") == "0.23", "Der Server muss die aktuelle Runtime 0.23 geladen haben: %s" % info_data) + assert(info_data.get("version") == "0.25", "Der Server muss die aktuelle Runtime 0.25 geladen haben: %s" % info_data) var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true) @@ -51,13 +51,13 @@ func _run() -> void: var public_leaderboard_name := "Rangpilot %s" % suffix.right(6) var renamed_a = await client_a.rpc_async(session_a, "update_display_name", JSON.stringify({"display_name": public_leaderboard_name})) assert(not renamed_a.is_exception() and bool(JSON.parse_string(renamed_a.payload).get("accepted", false)), "Der Ranglistenpilot muss einen öffentlichen Namen setzen können.") - var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.23-integration"}) + var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.25-integration"}) var submitted = await client_a.rpc_async(session_a, "submit_run", run_payload) assert(not submitted.is_exception(), "Ein plausibles Ergebnis muss serverseitig angenommen werden.") - var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", null, null, 10) - assert(not records.is_exception() and records.records.size() >= 1, "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.") + var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", [session_a.user_id], null, 10) + assert(not records.is_exception() and (records.records.size() >= 1 or records.owner_records.size() >= 1), "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.") var own_record = null - for record in records.records: + for record in records.owner_records: if record.owner_id == session_a.user_id: own_record = record break @@ -67,6 +67,17 @@ func _run() -> void: var socket_b: NakamaSocket = nakama.create_socket_from(client_b) assert(not (await socket_a.connect_async(session_a, true, 5)).is_exception(), "Spieler A benötigt eine Echtzeitverbindung.") assert(not (await socket_b.connect_async(session_b, true, 5)).is_exception(), "Spieler B benötigt eine Echtzeitverbindung.") + var friend_request = await client_a.add_friends_async(session_a, null, PackedStringArray([session_b.username])) + assert(not friend_request.is_exception(), "Spieler A muss eine Freundschaftsanfrage senden können.") + var friend_accept = await client_b.add_friends_async(session_b, PackedStringArray([session_a.user_id])) + assert(not friend_accept.is_exception(), "Spieler B muss die Freundschaftsanfrage bestätigen können.") + var friend_list = await client_a.list_friends_async(session_a, 0, 100) + assert(not friend_list.is_exception() and friend_list.friends.size() == 1 and friend_list.friends[0].user.id == session_b.user_id, + "Die bestätigte Freundschaft muss beim Gastgeber sichtbar sein.") + var received_invites: Array = [] + socket_b.received_notification.connect(func(note): + if int(note.code) == 101: + received_invites.append(JSON.parse_string(note.content))) socket_a.received_matchmaker_matched.connect(func(value): matched_a = value) socket_b.received_matchmaker_matched.connect(func(value): matched_b = value) socket_a.received_match_state.connect(func(value): _capture_snapshot(value, true)) @@ -124,6 +135,14 @@ func _run() -> void: assert(room_code.length() == 6 and not private_match_id.is_empty(), "Der Server muss einen sechsstelligen Raumcode und eine Partie liefern.") var private_match_a = await socket_a.join_match_async(private_match_id) assert(not private_match_a.is_exception(), "Der Gastgeber muss seinem privaten Raum beitreten können.") + var invite = await client_a.rpc_async(session_a, "invite_friend", JSON.stringify({"friend_id": session_b.user_id, "room_code": room_code})) + assert(not invite.is_exception() and bool(JSON.parse_string(invite.payload).get("accepted", false)), "Der Gastgeber muss einen bestätigten Freund einladen können.") + for attempt in 30: + if not received_invites.is_empty(): + break + await create_timer(0.05).timeout + assert(not received_invites.is_empty() and received_invites[0] is Dictionary and String(received_invites[0].get("room_code", "")) == room_code, + "Die Einladung muss als Echtzeitbenachrichtigung mit dem richtigen Raumcode ankommen.") var found_room = await client_b.rpc_async(session_b, "join_private_room", JSON.stringify({"room_code": room_code.to_lower()})) assert(not found_room.is_exception(), "Der Mitspieler muss den Raumcode unabhängig von Groß-/Kleinschreibung finden können.") var found_data = JSON.parse_string(found_room.payload) diff --git a/tests/online_snapshot_test.gd b/tests/online_snapshot_test.gd new file mode 100644 index 0000000..80221b1 --- /dev/null +++ b/tests/online_snapshot_test.gd @@ -0,0 +1,83 @@ +extends Node + + +func _ready() -> void: + call_deferred("_run") + + +func _run() -> void: + var game = load("res://main.tscn").instantiate() + get_tree().root.add_child(game) + await get_tree().process_frame + game.ui_language = "en" + assert(game._ui("FREUNDESLISTE") == "FRIENDS") + assert(game._ui("WELLE") == "WAVE") + game.ui_language = "de" + assert(game._ui("FREUNDESLISTE") == "FREUNDESLISTE") + game._on_match_invite_received({"room_code": "ABC123", "from": "Testpilot"}) + assert(game.pending_match_invite.room_code == "ABC123") + game.pending_match_invite.clear() + game.friend_return_mode = "matchmaking" + game.multiplayer_manager.matchmaking_mode = "private_host" + game.multiplayer_manager.matchmaking_state = "hosting" + game.multiplayer_manager.private_room_code = "ABC123" + assert(game._can_invite_friends()) + game.multiplayer_manager.matchmaking_state = "offline" + assert(not game._can_invite_friends(), "Ein alter Raumcode darf keine Einladung mehr ermöglichen.") + game.coop_online_mode = true + var base_snapshot := { + "difficulty": 1, "reactor_lives": 30, "wave": 1, "wave_active": true, + "enemies_to_spawn": 0, "wave_types_remaining": [], "towers": [], + "enemies": [{"id": 4, "type": 0, "x": 100.0, "y": 200.0, "hp": 40.0, + "max_hp": 40.0, "shield": false, "boss": false}], "shots": [] + } + game._apply_online_snapshot(base_snapshot) + assert(game.enemies.size() == 1) + assert(game.enemies[0].pos == Vector2(100, 200)) + var first_enemy: Dictionary = game.enemies[0] + var next_snapshot: Dictionary = base_snapshot.duplicate(true) + next_snapshot["enemies"][0]["x"] = 200.0 + next_snapshot["enemies"][0]["hp"] = 25.0 + game._apply_online_snapshot(next_snapshot) + assert(is_same(first_enemy, game.enemies[0]), "Gegnerdaten sollen zwischen Paketen wiederverwendet werden.") + game._update_online_interpolation(0.05) + assert(game.enemies[0].pos.x > 100.0 and game.enemies[0].pos.x < 200.0, + "Die Position soll zwischen zwei Serverständen gleichmäßig übergehen.") + game._update_online_interpolation(0.1) + assert(is_equal_approx(game.enemies[0].pos.x, 200.0)) + game.online_projectiles.append({"pos": Vector2.ZERO, "target": Vector2(30, 0), "speed": 920.0, "color": Color.WHITE}) + game._update_online_projectiles(0.1) + assert(game.online_projectiles.is_empty(), "Geschosse dürfen bei einem langen Frame nicht am Ziel vorbeipendeln.") + var online_manager: OnlineManager = game.get_node("OnlineManager") + var received: Array = [] + online_manager.match_snapshot_received.connect(func(snapshot: Dictionary) -> void: received.append(snapshot)) + online_manager.pending_snapshot_payloads.append(JSON.stringify({"tick": 1, "shots": [{"id": 1}]})) + online_manager.pending_snapshot_payloads.append(JSON.stringify({"tick": 2, "shots": [{"id": 2}]})) + online_manager._flush_pending_snapshots() + assert(received.size() == 1 and received[0].tick == 2, "Nur der neueste Spielstand darf angewendet werden.") + assert(received[0].shots.size() == 2, "Schusseffekte aus zusammengefassten Spielständen müssen erhalten bleiben.") + received.clear() + online_manager.current_match = {"match_id": "test-match"} + for tick in 8: + online_manager._on_match_state({"match_id": "test-match", "op_code": online_manager.OP_SNAPSHOT, + "data": JSON.stringify({"tick": tick, "shots": [{"id": tick}]})}) + assert(online_manager.pending_snapshot_payloads.size() == online_manager.MAX_PENDING_SNAPSHOTS, + "Die Warteschlange muss auch nach einem längeren Rückstau begrenzt bleiben.") + online_manager._flush_pending_snapshots() + assert(received.size() == 1 and received[0].tick == 7) + assert(received[0].shots.size() == online_manager.MAX_PENDING_SNAPSHOTS) + game._select_map(0) + game._prepare_draw_geometry() + assert(not game.draw_geometry_dirty and not game.road_tile_cache.is_empty()) + assert(not game.build_cell_cache.is_empty()) + game._select_map(1) + assert(game.draw_geometry_dirty, "Ein Kartenwechsel muss die Weggeometrie erneuern.") + game._prepare_draw_geometry() + assert(not game.draw_geometry_dirty) + game.coop_mode = true + game._configure_coop_arena() + game._prepare_draw_geometry() + var partner_cells: Array = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner) + assert(not partner_cells.is_empty(), "Die gespiegelte Koop-Seite braucht eigene Baufelder.") + print("VOID_BASTION_ONLINE_SNAPSHOT_OK") + get_tree().quit() diff --git a/tests/online_snapshot_test.gd.uid b/tests/online_snapshot_test.gd.uid new file mode 100644 index 0000000..5fa6a62 --- /dev/null +++ b/tests/online_snapshot_test.gd.uid @@ -0,0 +1 @@ +uid://cg5nghnbri5bo diff --git a/tests/online_snapshot_test.tscn b/tests/online_snapshot_test.tscn new file mode 100644 index 0000000..d9ed4ca --- /dev/null +++ b/tests/online_snapshot_test.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/online_snapshot_test.gd" id="1_test"] + +[node name="OnlineSnapshotTest" type="Node"] +script = ExtResource("1_test") diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index 7234718..b1c9474 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -39,8 +39,8 @@ func _run() -> void: 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.") + assert(game.get("TOWER_DATA").size() == 7, "Das Arsenal muss sieben unterschiedliche Turmtypen enthalten.") + assert(game.get("tower_buttons").size() == 7, "Alle sieben 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 @@ -75,7 +75,8 @@ func _run() -> void: 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) + var locked_tower_slot: int = game.call("_tower_display_order").find(3) + game.call("_handle_pointer", game.get("tower_buttons")[locked_tower_slot].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.") @@ -141,10 +142,11 @@ func _run() -> void: 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.8", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.") + var current_version: String = str(ProjectSettings.get_setting("application/config/version", "")) + assert(game.call("_app_version") == current_version and not current_version.is_empty(), "Profil 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.8") 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(current_version) 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.") @@ -163,9 +165,9 @@ func _run() -> void: 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 := {} 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.9", "name": "Alpha.9", "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": "v9.0.0", "name": "Future test release", "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.9", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.") + assert(announced_update.get("version") == "9.0.0", "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") @@ -228,16 +230,17 @@ 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) + game.call("_handle_pointer", game.get("speed_button").get_center(), false) assert(game.get("speed_dropdown_open"), "Die Spielgeschwindigkeit muss über eine Auswahl bedienbar sein.") - game.call("_handle_pointer", Vector2(1190, 198), false) + game.call("_handle_pointer", game.call("_speed_option_rect", 4).get_center(), 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) + game.call("_handle_pointer", game.get("auto_wave_button").get_center(), false) assert(game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox aktivierbar sein.") - game.call("_handle_pointer", Vector2(1330, 82), false) + game.call("_handle_pointer", game.get("auto_wave_button").get_center(), false) assert(not game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox abschaltbar sein.") - game.call("_handle_pointer", Vector2(1110, 45), false) + game.call("_handle_pointer", game.get("pause_button").get_center(), false) + game.call("_handle_pointer", game.get("pause_exit_button").get_center(), 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.") game.call("_start_game") @@ -279,7 +282,7 @@ func _run() -> void: 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_display_order") == [6, 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} diff --git a/tests/social_integration_test.gd b/tests/social_integration_test.gd new file mode 100644 index 0000000..0446fda --- /dev/null +++ b/tests/social_integration_test.gd @@ -0,0 +1,68 @@ +extends SceneTree + +var received_invites: Array = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var nakama = root.get_node("Nakama") + var host := OS.get_environment("VOID_BASTION_TEST_HOST") + var scheme := OS.get_environment("VOID_BASTION_TEST_SCHEME") + var port_text := OS.get_environment("VOID_BASTION_TEST_PORT") + if host.is_empty(): + host = "192.168.178.20" + if scheme.is_empty(): + scheme = "http" + var port := int(port_text) if not port_text.is_empty() else 7350 + var client_a: NakamaClient = nakama.create_client("voidbastion-public-v1", host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) + var client_b: NakamaClient = nakama.create_client("voidbastion-public-v1", host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) + var suffix := str(Time.get_ticks_usec()) + var session_a: NakamaSession = await client_a.authenticate_device_async("social_test_a_" + suffix, "social_a_" + suffix, true) + var session_b: NakamaSession = await client_b.authenticate_device_async("social_test_b_" + suffix, "social_b_" + suffix, true) + assert(not session_a.is_exception() and not session_b.is_exception(), "Beide Testkonten müssen sich anmelden können.") + var socket_a: NakamaSocket = nakama.create_socket_from(client_a) + var socket_b: NakamaSocket = nakama.create_socket_from(client_b) + assert(not (await socket_a.connect_async(session_a, true, 5)).is_exception()) + assert(not (await socket_b.connect_async(session_b, true, 5)).is_exception()) + socket_b.received_notification.connect(func(note): + if int(note.code) == 101: + received_invites.append(JSON.parse_string(note.content))) + var requested = await client_a.add_friends_async(session_a, null, PackedStringArray([session_b.username])) + assert(not requested.is_exception(), "Eine Freundschaftsanfrage muss gesendet werden können.") + var accepted = await client_b.add_friends_async(session_b, PackedStringArray([session_a.user_id])) + assert(not accepted.is_exception(), "Die Anfrage muss angenommen werden können.") + var friend_list = await client_a.list_friends_async(session_a, 0, 100) + assert(not friend_list.is_exception() and friend_list.friends.size() == 1 and friend_list.friends[0].user.id == session_b.user_id, + "Die gegenseitige Freundschaft muss auf dem Server gespeichert sein.") + var followed = await socket_a.follow_users_async(PackedStringArray([session_b.user_id]), PackedStringArray()) + assert(not followed.is_exception(), "Der Freundesstatus muss abonnierbar sein.") + assert(not (await socket_b.update_status_async("playing")).is_exception(), "Der Im-Spiel-Status muss gesetzt werden können.") + var created = await client_a.rpc_async(session_a, "create_private_room", JSON.stringify({"difficulty": 1})) + assert(not created.is_exception(), "Ein privater Raum muss erstellbar sein.") + var room: Dictionary = JSON.parse_string(created.payload) + var code := String(room.get("room_code", "")) + assert(code.length() == 6) + var joined = await socket_a.join_match_async(String(room.get("match_id", ""))) + assert(not joined.is_exception(), "Der Gastgeber muss den privaten Raum betreten können.") + var invited = await client_a.rpc_async(session_a, "invite_friend", JSON.stringify({"friend_id": session_b.user_id, "room_code": code})) + assert(not invited.is_exception() and bool(JSON.parse_string(invited.payload).get("accepted", false)), + "Der Gastgeber muss einen bestätigten Freund einladen können.") + for attempt in 40: + if not received_invites.is_empty(): + break + await create_timer(0.05).timeout + assert(not received_invites.is_empty() and received_invites[0] is Dictionary and String(received_invites[0].get("room_code", "")) == code, + "Die Echtzeit-Einladung muss den Raumcode enthalten.") + var unauthorized = await client_b.rpc_async(session_b, "invite_friend", JSON.stringify({"friend_id": session_a.user_id, "room_code": code})) + assert(not unauthorized.is_exception() and not bool(JSON.parse_string(unauthorized.payload).get("accepted", true)), + "Ein Mitspieler ohne Gastgeberrolle darf keine Einladung versenden.") + socket_a.close() + socket_b.close() + var deleted_a = await client_a.rpc_async(session_a, "delete_account", "{}") + var deleted_b = await client_b.rpc_async(session_b, "delete_account", "{}") + assert(not deleted_a.is_exception() and not deleted_b.is_exception(), "Die Testkonten müssen entfernt werden können.") + print("VOID_BASTION_SOCIAL_INTEGRATION_OK") + quit()