Compare commits
8
Commits
d53467baf2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1294000348 | ||
|
|
8777f28595 | ||
|
|
a536b698f7 | ||
|
|
026d0bad58 | ||
|
|
fc4fcd40a4 | ||
|
|
39ed4c3bf7 | ||
|
|
67abdf13b0 | ||
|
|
4d9e6d9185 |
No files matched your search
@@ -49,10 +49,10 @@ Diese Liste wird fortlaufend erweitert und vor jeder öffentlichen Version abgea
|
|||||||
|
|
||||||
## Veröffentlichung
|
## Veröffentlichung
|
||||||
|
|
||||||
- [x] **A** Alpha-10-Setup-Datei und SHA-256-Prüfsumme stimmen mit dem erneut heruntergeladenen Gitea-Release-Asset überein.
|
- [x] **A** Alpha-11-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.
|
- [x] **A** Der Homepage-Release-Endpunkt zeigt automatisch auf Alpha 11; öffentlicher Download ist erreichbar.
|
||||||
- [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar.
|
- [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar.
|
||||||
- [x] **A** Änderungen, bekannte Fehler und Testhinweise stehen in den Alpha-10-Release-Notizen.
|
- [x] **A** Änderungen, bekannte Fehler und Testhinweise stehen in den Alpha-11-Release-Notizen.
|
||||||
|
|
||||||
## Neue offene Punkte (Stand 06.10.2026)
|
## Neue offene Punkte (Stand 06.10.2026)
|
||||||
|
|
||||||
|
|||||||
@@ -45,15 +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.
|
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 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.
|
Am 10. Oktober 2026 wurde für Alpha 11 das Server-Bundle mit SHA-256 `b522531f8ae6c4ec12b2001db9fbee9fb47b3f48611c58aaab9a07439309d399` aktiviert. Nach dem gezielten Containerneustart meldete Nakama `healthy`. Das Bundle enthält die Freundesnamenssuche, automatische Online-Rundenberichte und die serverseitige Halbierung des ersten Welle-5-Boss-Schilds. Die wiederherstellbare Serverkopie liegt unter `/mnt/user/appdata/void-bastion-online/nakama/modules/build/index.js.pre-20261010-201010`.
|
||||||
|
|
||||||
## Windows-Release und Homepage
|
## 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.
|
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 `-MakeNsisExe` mit der **echten** NSIS-Binärdatei. Auf diesem PC funktionierten Godot 4.7.2 unter `C:\Users\Domin\Downloads\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64_console.exe` und NSIS unter `build\nsis-portable\nsis-3.13\Bin\makensis.exe`. In der eingeschränkten Ausführungsumgebung schlug NSIS mit einem Windows-System-DLL-Ladefehler fehl; der Installer ließ sich mit derselben Build-Anweisung außerhalb der Sandbox erstellen. 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.
|
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. Das Release-Repository ist unabhängig vom Spiel-Quellcode-Repository: `target_commitish` muss daher auf dessen existierenden Branch (derzeit `main`) zeigen, nicht auf den Commit-SHA des Spiels. 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`.
|
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 12: `v0.1.0-alpha.12`, Spiel-Commit `39ed4c3`, Website-Commit `417b2d8`, Nakama-Bundle SHA-256 `f99bdbc674556bfd59197d6cc1665a6db171e6ccde9c9103c19d4e9dfe99b4f7`, Setup SHA-256 `3309856C37B91B316ACF510911FE76F7F75C817D2BC515A43544566AFA50BA93`. Nakama meldete nach dem gezielten Neustart `healthy`; `latest-release.php` zeigte Alpha 12 und der öffentliche Download stimmte bytegenau mit dem lokalen Setup überein.
|
||||||
|
|
||||||
## Absicherung und Wiederherstellung
|
## Absicherung und Wiederherstellung
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Online-Diagnose für Zwei-PC-Tests
|
||||||
|
|
||||||
|
Ab dem nächsten gemeinsamen Client-/Nakama-Update schreibt das Spiel während jeder Online-Partie Messwerte lokal mit. Beim Sieg (Welle 15), bei einer Niederlage oder beim Verlassen sendet jeder PC seinen Bericht automatisch an Nakama. Beide Berichte bekommen dieselbe 16-stellige Rundenkennung und die Seitenzahl `0` oder `1`. Die Spieler müssen dafür keinen Diagnose-Button bedienen. Bei vorübergehendem Übertragungsfehler versucht der Client es dreimal; die lokale `.jsonl`-Datei bleibt als Rückfallebene erhalten.
|
||||||
|
|
||||||
|
Die private Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine Übersicht der letzten Berichte. Mit `{"round_id":"<16-stellige Kennung>"}` liefert sie die zwei vollständigen Seitenberichte. Sie ist wie `private_stats` nur mit dem privaten Nakama-HTTP-Schlüssel erreichbar; Spielclients können sie nicht aufrufen. Für den Vergleich einer Partie erst die Rundenkennung in der Übersicht auswählen und anschließend beide Seiten laden. Die Kennung steht außerdem nach erfolgreichem Upload in der Spielausgabe als `ONLINE DIAG hochgeladen: Runde ...`.
|
||||||
|
|
||||||
|
Online gespeichert werden nur technische Messwerte. Nachrichten, IP-Adressen, Spielernamen, Raumcodes und der rohe Match-Identifier werden nicht als Bericht gespeichert. Der Server nimmt nur Berichte von Konten an, die tatsächlich Teilnehmer der Runde waren. Berichte und Teilnehmerzuordnung werden nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
|
||||||
|
|
||||||
|
Jede `window`-Zeile fasst etwa zehn Sekunden zusammen:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `snapshots_per_second`, `gap_p95_ms`, `gap_max_ms` | Wie regelmäßig Zustände beim PC eintreffen; erwartet sind ungefähr 10/s bzw. 100 ms Abstand. |
|
||||||
|
| `tick_jump_max`, `tick_jumps_over_1` | Ob Server-Schritte zwischen verarbeiteten Zuständen fehlen. Zusammen mit den Empfangsabständen und Warteschlangenwerten zeigt das, ob Zustände ausblieben oder erst verspätet gebündelt ankamen; allein beweist es noch keine Ursache. |
|
||||||
|
| `bytes_avg`, `bytes_max` | Größe der vollständigen Zustandsnachrichten. Wachsende Werte bei vielen Gegnern/Türmen weisen auf Datenmenge als möglichen Faktor hin. |
|
||||||
|
| `coalesced`, `queue_dropped`, `queue_max` | Mehrere Zustände kamen vor einem Bild zusammen oder die Warteschlange lief voll. Das deutet auf einen Client-Rückstau hin. |
|
||||||
|
| `parse_max_us`, `apply_max_us` | Höchste Zeit für JSON-Verarbeitung bzw. Anwenden des Zustands in Godot. |
|
||||||
|
| `frame_max_ms`, `frame_stalls_over_200_ms` | Lokale Bildaussetzer, auch wenn das Netz regelmäßig liefert. |
|
||||||
|
| `enemies`, `towers` | Last im Spiel zum Zeitpunkt der Messung. |
|
||||||
|
|
||||||
|
Zusätzliche `snapshot_gap`, `tick_jump`, `frame_stall`, `socket_closed` und `socket_reconnected`-Zeilen markieren einzelne Ereignisse. Die Uhrzeit ist UTC, damit beide PCs direkt verglichen werden können. Ein Empfangsabstand allein beweist keine Paketverluste: Er kann auch durch Serverlast, Netzwerk-Jitter oder einen blockierten Client entstehen. Für eine eindeutige Zuordnung die gleichzeitigen Berichte **beider PCs** vergleichen; serverseitige `ONLINE DIAG tick_gap_ms`- und `loop_ms`-Warnungen helfen zusätzlich.
|
||||||
|
|
||||||
|
Wichtig: Alte Spielversionen laden noch nichts hoch. Vor einem echten Zwei-PC-Test müssen sowohl der neue Windows-Build als auch der aktualisierte Nakama-Runtime-Code veröffentlicht sein.
|
||||||
+6
-2
@@ -30,6 +30,10 @@ Die Serverfunktion `private_stats` ist ausschließlich über den geheimen Nakama
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
Das Einheiten-Balancing liegt einheitlich im privaten Nakama-Speicher (`game_balance/global`). Die geschützte Statistikseite liest und ändert es über die nur mit `NAKAMA_HTTP_KEY` aufrufbaren RPCs `get_game_balance` und `set_game_balance`; der HTTP-Schlüssel wird nie an Clients ausgeliefert. Angemeldete Spielclients rufen `get_game_balance` beim Start auf, cachen die letzte gültige Konfiguration lokal und verwenden sie auch offline. Jede neue Online-Partie lädt dieselbe Konfiguration serverseitig bei `matchInit`; bereits laufende Partien bleiben unverändert. Falls der Server unerreichbar ist, gelten der letzte lokale Cache bzw. die eingebauten Ausgangswerte. Änderung des Balancings erfordert dadurch keine neue Spiel-EXE, aber das Runtime-Bundle auf dem Nakama-Server und die interne PHP-Seite müssen zunächst aktualisiert werden.
|
||||||
|
|
||||||
|
`server/nakama/tests/balance_duel_test.js` ist ein reproduzierbarer Referenztest, der die echte Nakama-Kampf-Simulation nutzt und alle zulässigen Turm-/Gegner-Kombinationen sowie den ersten Boss bewertet. Er ist ein gezielter 1-gegen-1-Wegtest (ein Level-1-Turm, keine Upgrades/Unterstützung), kein Ersatz für komplette Wellen- und Spielertests. Ergebnisse daher als Warnsignal und Vergleich zwischen Versionen verwenden, nicht als alleinige automatische Balancing-Entscheidung.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
@@ -66,8 +70,8 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag `
|
|||||||
|
|
||||||
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.
|
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.
|
Bei einem stockenden Onlinespiel schreibt der Client jetzt einen fortlaufenden Diagnosebericht pro Partie. Im Online-Pausemenü kann der Bericht mit **DIAGNOSE KOPIEREN** vollständig in die Zwischenablage kopiert werden. Er enthält Zehn-Sekunden-Werte und einzelne Hänger-/Verbindungsereignisse, aber keine Spielernamen, Raumcodes oder Kontenkennungen. Für Ablauf und Auswertung auf zwei PCs siehe [ONLINE_DIAGNOSE.md](ONLINE_DIAGNOSE.md).
|
||||||
|
|
||||||
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 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` und `queue_dropped` im Diagnosebericht trennen zusammengefasste von wegen voller Warteschlange verworfenen Zuständen. 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.
|
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.
|
||||||
@@ -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`.
|
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.10.md`.
|
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.13.md`.
|
||||||
|
|
||||||
## Werbung und Datenschutz
|
## Werbung und Datenschutz
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.11
|
||||||
|
|
||||||
|
## Neu in dieser Testversion
|
||||||
|
|
||||||
|
- Private Koop-Räume können jetzt auch nach dem Wechsel in die Lobby Freunde einladen.
|
||||||
|
- Freunde lassen sich über den sichtbaren Pilotennamen suchen, statt lange technische Kennungen manuell zu kopieren.
|
||||||
|
- Die Schilde des Mutterschiff-Bosses in Welle 5 wurden halbiert.
|
||||||
|
- Online-Partieberichte werden beim Ende oder Verlassen automatisch pseudonymisiert an Nakama übertragen und mit einer gemeinsamen Rundenkennung für beide PCs abgelegt.
|
||||||
|
- Die geschützte Statistikseite enthält nun eine Übersicht und Detailansicht der Online-Diagnoseberichte.
|
||||||
|
|
||||||
|
## Testhinweise
|
||||||
|
|
||||||
|
- Für automatische Diagnoseberichte müssen beide Spieler Alpha 11 und der aktualisierte Nakama-Server verwenden.
|
||||||
|
- Bitte mit zwei Konten Namenssuche, Freundeseinladung aus der Lobby und einen Online-Spielverlauf einschließlich Verlassen testen.
|
||||||
|
- Die Diagnoseberichte enthalten technische Messwerte, keine Namen, Chatnachrichten, IP-Adressen oder Raumcodes. Sie werden serverseitig nach 14 Tagen entfernt.
|
||||||
|
- Dies ist eine unsignierte Alpha-Testversion. Windows SmartScreen kann vor dem ersten Start warnen.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.12
|
||||||
|
|
||||||
|
## Balance-Konfiguration und Online-Korrekturen
|
||||||
|
|
||||||
|
- Türme und Gegner verwenden für Offline-Partien dieselbe zentrale Balance-Konfiguration wie für neue Online-Partien.
|
||||||
|
- Angemeldete Spieler laden freigegebene Werte automatisch; der letzte gültige Stand wird lokal zwischengespeichert und bei fehlender Verbindung weiterverwendet.
|
||||||
|
- Laufende Online-Partien behalten ihren beim Start geladenen Balance-Schnappschuss.
|
||||||
|
- Der erste Boss hat weniger Hüllen- und Schildpunkte.
|
||||||
|
- Die interne Statistikseite kann Balance-Werte für Türme und Gegner bearbeiten.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Das Duellwerkzeug ist eine gezielte Einzelkonfrontation und kein vollständiger automatisierter Durchspieltest. Das Spielbalancing sollte zusätzlich mit echten Missionsläufen geprüft werden.
|
||||||
|
- Balance-Änderungen auf dem Server wirken auf neue Online-Partien sofort und auf Offline-Partien nach dem nächsten Start mit erreichbarem Server; bei Offline-Nutzung ohne Verbindung gelten Cache oder eingebaute Werte.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.13
|
||||||
|
|
||||||
|
## Balance- und Onlinekorrekturen
|
||||||
|
|
||||||
|
- Der Online-Server übernimmt den ausgewählten Schwierigkeitsgrad „Leicht“ jetzt korrekt, statt ihn auf „Mittel“ zurückzusetzen.
|
||||||
|
- Flak-Schaden pro Treffer: 20 → 22.
|
||||||
|
- Belagerer-Grundhülle: 740 → 620. Das wirkt sich auch auf die daraus berechneten Schilde aus.
|
||||||
|
- Gespeicherte Balancewerte aus Schema 1 werden beim ersten Lesen auf Schema 2 migriert. Nur Felder, die noch exakt den alten Flak-/Belagerer-Standardwerten entsprechen, werden angepasst; andere eigene Admin-Balancewerte bleiben erhalten.
|
||||||
|
- Ein reproduzierbarer 15-Wellen-Missionstest ergänzt die vorhandenen Runtime- und Turm-Gegner-Duelle.
|
||||||
|
|
||||||
|
## Prüfumfang
|
||||||
|
|
||||||
|
- `node server/nakama/tests/runtime_sim_test.js`
|
||||||
|
- `node server/nakama/tests/balance_mission_test.js`
|
||||||
|
- `node server/nakama/tests/balance_duel_test.js`
|
||||||
|
- PHP-Syntaxprüfung der internen Statistikseite
|
||||||
|
|
||||||
|
Der Missionslauf ist ein einfacher Level-1-Referenz-Bot ohne Upgrades und kein Ersatz für Spieltests mit echten Spielern.
|
||||||
@@ -14,6 +14,8 @@ var primary_provider := "guest"
|
|||||||
var linked_identities: Array = []
|
var linked_identities: Array = []
|
||||||
var highscores: Dictionary = {}
|
var highscores: Dictionary = {}
|
||||||
var pending_submissions: Array = []
|
var pending_submissions: Array = []
|
||||||
|
var pending_player_events: Array = []
|
||||||
|
var next_player_event_sequence := 0
|
||||||
var created_at := 0
|
var created_at := 0
|
||||||
var storage_enabled := true
|
var storage_enabled := true
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ func _load_or_create_profile() -> void:
|
|||||||
linked_identities = data.get("linked_identities", [])
|
linked_identities = data.get("linked_identities", [])
|
||||||
highscores = data.get("highscores", {})
|
highscores = data.get("highscores", {})
|
||||||
pending_submissions = data.get("pending_submissions", [])
|
pending_submissions = data.get("pending_submissions", [])
|
||||||
|
pending_player_events = data.get("pending_player_events", [])
|
||||||
|
next_player_event_sequence = int(data.get("next_player_event_sequence", 0))
|
||||||
created_at = int(data.get("created_at", 0))
|
created_at = int(data.get("created_at", 0))
|
||||||
if player_id.is_empty():
|
if player_id.is_empty():
|
||||||
_create_guest_profile()
|
_create_guest_profile()
|
||||||
@@ -50,6 +54,8 @@ func _create_guest_profile() -> void:
|
|||||||
linked_identities = []
|
linked_identities = []
|
||||||
highscores = {}
|
highscores = {}
|
||||||
pending_submissions = []
|
pending_submissions = []
|
||||||
|
pending_player_events = []
|
||||||
|
next_player_event_sequence = 0
|
||||||
created_at = int(Time.get_unix_time_from_system())
|
created_at = int(Time.get_unix_time_from_system())
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +72,8 @@ func _save_profile() -> void:
|
|||||||
"linked_identities": linked_identities,
|
"linked_identities": linked_identities,
|
||||||
"highscores": highscores,
|
"highscores": highscores,
|
||||||
"pending_submissions": pending_submissions,
|
"pending_submissions": pending_submissions,
|
||||||
|
"pending_player_events": pending_player_events,
|
||||||
|
"next_player_event_sequence": next_player_event_sequence,
|
||||||
"created_at": created_at
|
"created_at": created_at
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -91,6 +99,29 @@ func record_run(map_index: int, difficulty_index: int, endless: bool, reached_wa
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func queue_player_event(event_name: String, tower_type := -1) -> void:
|
||||||
|
if event_name != "play_start" and event_name != "tower_build":
|
||||||
|
return
|
||||||
|
next_player_event_sequence += 1
|
||||||
|
pending_player_events.append({
|
||||||
|
"event": event_name,
|
||||||
|
"tower_type": tower_type,
|
||||||
|
"source": player_id,
|
||||||
|
"sequence": next_player_event_sequence,
|
||||||
|
"at": int(Time.get_unix_time_from_system() * 1000.0)
|
||||||
|
})
|
||||||
|
_save_profile()
|
||||||
|
|
||||||
|
|
||||||
|
func mark_player_event_complete(sequence: int) -> void:
|
||||||
|
for index in range(pending_player_events.size() - 1, -1, -1):
|
||||||
|
var event = pending_player_events[index]
|
||||||
|
if event is Dictionary and int(event.get("sequence", -1)) == sequence:
|
||||||
|
pending_player_events.remove_at(index)
|
||||||
|
_save_profile()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
func attach_online_identity(online_user_id: String, online_username: String) -> void:
|
func attach_online_identity(online_user_id: String, online_username: String) -> void:
|
||||||
if online_user_id.is_empty():
|
if online_user_id.is_empty():
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-2
@@ -24,8 +24,8 @@ application/modify_resources=true
|
|||||||
application/icon="res://assets/app_icon.png"
|
application/icon="res://assets/app_icon.png"
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="0.1.0.10"
|
application/file_version="0.1.0.11"
|
||||||
application/product_version="0.1.0.10"
|
application/product_version="0.1.0.11"
|
||||||
application/company_name="GerGames"
|
application/company_name="GerGames"
|
||||||
application/product_name="Void Bastion"
|
application/product_name="Void Bastion"
|
||||||
application/file_description="Void Bastion – Alpha-Testversion"
|
application/file_description="Void Bastion – Alpha-Testversion"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ extends Node2D
|
|||||||
const SCREEN := Vector2(1600.0, 900.0)
|
const SCREEN := Vector2(1600.0, 900.0)
|
||||||
const ENGLISH_UI := {
|
const ENGLISH_UI := {
|
||||||
"PROFIL": "PROFILE", "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "SELECT A MAP • UNLOCK SECTORS IN THE SHOP",
|
"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.",
|
"EINSATZSEKTOREN": "DEPLOYMENT SECTORS", "KARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "CHOOSE A MAP • UNLOCK LOCKED SECTORS IN THE SHOP",
|
||||||
|
"AUSGEWÄHLT": "SELECTED", "KOMMUNITY": "COMMUNITY", "HANGAR & KONTO": "HANGAR & ACCOUNT", "SYSTEM": "SYSTEM",
|
||||||
|
"GESPERRT": "LOCKED", "Wähle einen Sektor. Danach legst du Schwierigkeit und Missionsstart fest.": "Choose a sector, then set the difficulty and start your mission.",
|
||||||
"< KARTE": "< MAP", "SCHWIERIGKEIT WÄHLEN": "SELECT DIFFICULTY", "LEICHT": "EASY", "MITTEL": "MEDIUM", "SCHWER": "HARD",
|
"< 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",
|
"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",
|
"CODE EINLÖSEN": "REDEEM CODE", "SPIEL BEENDEN": "QUIT GAME", "MULTIPLAYER": "MULTIPLAYER", "SCHLIESSEN": "CLOSE",
|
||||||
@@ -12,13 +14,13 @@ const ENGLISH_UI := {
|
|||||||
"KOOPERATIVE VERTEIDIGUNG": "COOPERATIVE DEFENSE", "ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR": "TWO COMMANDERS • ONE SHARED REACTOR",
|
"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",
|
"GEMEINSAMER REAKTOR": "SHARED REACTOR", "SCHNELLES SPIEL": "QUICK MATCH", "RAUM BEITRETEN": "JOIN ROOM",
|
||||||
"RAUM ERSTELLEN": "CREATE ROOM", "MIT KI-PARTNER": "WITH AI PARTNER", "FREUNDESLISTE": "FRIENDS",
|
"RAUM ERSTELLEN": "CREATE ROOM", "MIT KI-PARTNER": "WITH AI PARTNER", "FREUNDESLISTE": "FRIENDS",
|
||||||
"FREUNDE EINLADEN": "INVITE FRIENDS", "AKTUALISIEREN": "REFRESH", "KENNUNG KOPIEREN": "COPY PLAYER ID",
|
"FREUNDE EINLADEN": "INVITE FRIENDS", "AKTUALISIEREN": "REFRESH", "NAMEN KOPIEREN": "COPY NAME",
|
||||||
"ANFRAGE SENDEN": "SEND REQUEST", "EINLADEN": "INVITE", "ENTFERNEN": "REMOVE", "ZURÜCKZIEHEN": "WITHDRAW",
|
"ANFRAGE SENDEN": "SEND REQUEST", "EINLADEN": "INVITE", "ENTFERNEN": "REMOVE", "ZURÜCKZIEHEN": "WITHDRAW",
|
||||||
"ANNEHMEN": "ACCEPT", "ONLINE": "ONLINE", "OFFLINE": "OFFLINE", "IM SPIEL": "IN GAME",
|
"ANNEHMEN": "ACCEPT", "ONLINE": "ONLINE", "OFFLINE": "OFFLINE", "IM SPIEL": "IN GAME",
|
||||||
"ANFRAGE GESENDET": "REQUEST SENT", "ANFRAGE ERHALTEN": "REQUEST RECEIVED", "BLOCKIERT": "BLOCKED",
|
"ANFRAGE GESENDET": "REQUEST SENT", "ANFRAGE ERHALTEN": "REQUEST RECEIVED", "BLOCKIERT": "BLOCKED",
|
||||||
"Noch keine Freunde oder offenen Anfragen.": "No friends or pending requests yet.",
|
"Noch keine Freunde oder offenen Anfragen.": "No friends or pending requests yet.",
|
||||||
"Eindeutige Spielerkennung eingeben": "Enter unique player ID",
|
"Spielername eingeben": "Enter player name",
|
||||||
"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.",
|
"Füge Freunde über ihren Spielernamen hinzu. Beide müssen die Anfrage bestätigen.": "Add friends by player name. 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.",
|
"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",
|
"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",
|
"BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL": "LETTERS AND NUMBERS • CASE DOES NOT MATTER",
|
||||||
@@ -33,6 +35,7 @@ const ENGLISH_UI := {
|
|||||||
"Biodom über dem Nebel": "Biodome above the nebula", "Eisige Raffinerie": "Frozen refinery", "Schmiede am Gasriesen": "Forge by the gas giant",
|
"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",
|
"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",
|
"GESPERRT • %d KERNKRISTALLE": "LOCKED • %d CORE CRYSTALS", "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d": "MAP BONUS %d • BEST WAVE %02d • CORE CRYSTALS %04d",
|
||||||
|
"KARTE %02d VON %02d": "MAP %02d OF %02d", "KARTE IM SHOP FREISCHALTEN": "UNLOCK MAP IN SHOP",
|
||||||
"Bisheriges Balancing • 20 Reaktor • Kartenbonus 5": "Classic balance • 20 reactor health • map bonus 5",
|
"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",
|
"+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",
|
"+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12": "+72% base armor • stronger late waves • start 340 • map bonus 12",
|
||||||
@@ -42,12 +45,13 @@ const ENGLISH_UI := {
|
|||||||
"RW %.0f • %s": "RANGE %.0f • %s", "LUFT": "AIR", "BODEN": "GROUND", "LUFT + BODEN": "AIR + GROUND",
|
"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",
|
"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",
|
"PIERCING-STRAHL": "PIERCING BEAM", "LUFT-EINZELZIEL": "AIR SINGLE TARGET",
|
||||||
"DEINE SPIELERKENNUNG: %s": "YOUR PLAYER ID: %s", "EINLADUNG VON %s ANNEHMEN": "ACCEPT INVITE FROM %s",
|
"DEIN SPIELERNAME: %s": "YOUR PLAYER NAME: %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 an %s gesendet.": "Request sent to %s.", "Spielername nicht gefunden oder Anfrage fehlgeschlagen.": "Player name not found or request failed.",
|
||||||
|
"Mehrere Spieler haben diesen Namen. Bitte einen eindeutigen Namen verwenden.": "Multiple players have this name. Please use a unique name.",
|
||||||
"Anfrage bestätigt.": "Request accepted.", "Bestätigung fehlgeschlagen.": "Could not accept request.",
|
"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.",
|
"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.",
|
"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.",
|
"Spielername kopiert.": "Player name copied.", "Keine Serververbindung. Bitte später erneut versuchen.": "No server connection. Please try again later.",
|
||||||
"Freundesliste konnte nicht geladen werden.": "Could not load friends.",
|
"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.",
|
"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",
|
"MITSPIELER": "PARTNER", "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN": "REACTOR HEALTH %02d • SEPARATE RESOURCES",
|
||||||
@@ -194,27 +198,29 @@ const DIFFICULTY_DESCRIPTIONS := [
|
|||||||
"+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12"
|
"+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12"
|
||||||
]
|
]
|
||||||
|
|
||||||
const TOWER_DATA := [
|
var TOWER_DATA := [
|
||||||
{"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "target_class": "ground", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässig gegen Bodenziele."},
|
{"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "target_class": "ground", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässig gegen Bodenziele."},
|
||||||
{"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "target_class": "both", "both_damage_scale": 0.82, "color": Color("62fff1"), "unlock_cost": 0, "description": "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen."},
|
{"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "target_class": "both", "both_damage_scale": 0.82, "color": Color("62fff1"), "unlock_cost": 0, "description": "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen."},
|
||||||
{"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "target_class": "ground", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Bodenraketen treffen Gegnergruppen."},
|
{"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": "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": "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."}
|
{"name": "FLAK", "cost": 210, "damage": 22.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."},
|
||||||
|
{"name": "ADMIN-TURM", "cost": 0, "damage": 1000000.0, "range": 650.0, "rate": 0.08, "shot_speed": 3000.0, "damage_type": "TESTMODUS", "target_class": "both", "color": Color("fff2a1"), "unlock_cost": 0, "description": "Entwicklertest: besiegt jedes Ziel mit einem Schuss."}
|
||||||
]
|
]
|
||||||
|
|
||||||
const ENEMY_DATA := [
|
var ENEMY_DATA := [
|
||||||
{"name": "JAEGER", "category": "air", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28},
|
{"name": "JAEGER", "category": "air", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28},
|
||||||
{"name": "FREGATTE", "category": "air", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28},
|
{"name": "FREGATTE", "category": "air", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28},
|
||||||
{"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28},
|
{"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28},
|
||||||
{"name": "ABFANGJAEGER", "category": "air", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28},
|
{"name": "ABFANGJAEGER", "category": "air", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28},
|
||||||
{"name": "SCHILDKREUZER", "category": "air", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true},
|
{"name": "SCHILDKREUZER", "category": "air", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true},
|
||||||
{"name": "BELAGERER", "category": "air", "hp": 740.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28},
|
{"name": "BELAGERER", "category": "air", "hp": 620.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28},
|
||||||
{"name": "SPAEHER-ROVER", "category": "ground", "hp": 88.0, "speed": 142.0, "reward": 22, "size": 54.0, "breach_damage": 1, "shield_resistance": 0.28},
|
{"name": "SPAEHER-ROVER", "category": "ground", "hp": 88.0, "speed": 142.0, "reward": 22, "size": 54.0, "breach_damage": 1, "shield_resistance": 0.28},
|
||||||
{"name": "STURMPANZER", "category": "ground", "hp": 390.0, "speed": 61.0, "reward": 61, "size": 92.0, "breach_damage": 3, "shield_resistance": 0.28},
|
{"name": "STURMPANZER", "category": "ground", "hp": 390.0, "speed": 61.0, "reward": 61, "size": 92.0, "breach_damage": 3, "shield_resistance": 0.28},
|
||||||
{"name": "BELAGERUNGSLAEUFER", "category": "ground", "hp": 980.0, "speed": 36.0, "reward": 132, "size": 124.0, "breach_damage": 5, "shield_resistance": 0.28}
|
{"name": "BELAGERUNGSLAEUFER", "category": "ground", "hp": 980.0, "speed": 36.0, "reward": 132, "size": 124.0, "breach_damage": 5, "shield_resistance": 0.28}
|
||||||
]
|
]
|
||||||
|
const BALANCE_CACHE_PATH := "user://balance_cache.json"
|
||||||
|
|
||||||
var path_points := PackedVector2Array()
|
var path_points := PackedVector2Array()
|
||||||
var path_cells: Dictionary = {}
|
var path_cells: Dictionary = {}
|
||||||
@@ -265,6 +271,17 @@ var wave_spawn_index := 0
|
|||||||
var online_wave_types_remaining: Array[int] = []
|
var online_wave_types_remaining: Array[int] = []
|
||||||
var enemy_strength_multiplier := 1.0
|
var enemy_strength_multiplier := 1.0
|
||||||
var developer_panel_open := false
|
var developer_panel_open := false
|
||||||
|
const DEVELOPER_PASSWORD := "Spring.3"
|
||||||
|
const ADMIN_TOWER_TYPE := 7
|
||||||
|
const DEVELOPER_AUTH_OP := 15
|
||||||
|
const DEVELOPER_ENERGY_OP := 16
|
||||||
|
var developer_unlocked_this_round := false
|
||||||
|
var developer_server_authorized := false
|
||||||
|
var developer_password_prompt_open := false
|
||||||
|
var developer_password_line_edit: LineEdit
|
||||||
|
var developer_password_dialog_rect := Rect2(510, 330, 580, 235)
|
||||||
|
var developer_password_input_rect := Rect2(570, 410, 460, 54)
|
||||||
|
var developer_password_cancel_button := Rect2(670, 488, 260, 48)
|
||||||
var status_text := ""
|
var status_text := ""
|
||||||
var status_timer := 0.0
|
var status_timer := 0.0
|
||||||
var elapsed := 0.0
|
var elapsed := 0.0
|
||||||
@@ -281,8 +298,8 @@ var next_spawn_route := 0
|
|||||||
|
|
||||||
var start_button := Rect2(625, 674, 350, 58)
|
var start_button := Rect2(625, 674, 350, 58)
|
||||||
var map_buttons := [
|
var map_buttons := [
|
||||||
Rect2(30, 390, 340, 130), Rect2(430, 390, 340, 130), Rect2(830, 390, 340, 130), Rect2(1230, 390, 340, 130),
|
Rect2(55, 312, 350, 150), Rect2(435, 312, 350, 150), Rect2(815, 312, 350, 150), Rect2(1195, 312, 350, 150),
|
||||||
Rect2(255, 540, 340, 130), Rect2(630, 540, 340, 130), Rect2(1005, 540, 340, 130)
|
Rect2(245, 492, 350, 150), Rect2(625, 492, 350, 150), Rect2(1005, 492, 350, 150)
|
||||||
]
|
]
|
||||||
var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)]
|
var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)]
|
||||||
var wave_button := Rect2(1216, 10, 359, 46)
|
var wave_button := Rect2(1216, 10, 359, 46)
|
||||||
@@ -291,11 +308,14 @@ var speed_button := Rect2(1070, 14, 62, 54)
|
|||||||
var auto_wave_button := Rect2(1216, 58, 359, 19)
|
var auto_wave_button := Rect2(1216, 58, 359, 19)
|
||||||
var wave_current_preview_rect := Rect2(220, 8, 258, 66)
|
var wave_current_preview_rect := Rect2(220, 8, 258, 66)
|
||||||
var wave_next_preview_rect := Rect2(486, 8, 258, 66)
|
var wave_next_preview_rect := Rect2(486, 8, 258, 66)
|
||||||
var developer_panel_rect := Rect2(1060, 104, 515, 78)
|
var developer_panel_rect := Rect2(920, 104, 660, 112)
|
||||||
var developer_strength_down_button := Rect2(1074, 140, 42, 30)
|
var developer_strength_down_button := Rect2(934, 140, 42, 28)
|
||||||
var developer_strength_up_button := Rect2(1260, 140, 42, 30)
|
var developer_strength_up_button := Rect2(1110, 140, 42, 28)
|
||||||
var developer_crystals_down_button := Rect2(1320, 140, 50, 30)
|
var developer_crystals_down_button := Rect2(1170, 140, 50, 28)
|
||||||
var developer_crystals_up_button := Rect2(1512, 140, 50, 30)
|
var developer_crystals_up_button := Rect2(1412, 140, 50, 28)
|
||||||
|
var developer_energy_down_button := Rect2(934, 176, 78, 28)
|
||||||
|
var developer_energy_up_button := Rect2(1018, 176, 78, 28)
|
||||||
|
var developer_admin_tower_button := Rect2(1370, 174, 195, 32)
|
||||||
var tower_buttons := [
|
var tower_buttons := [
|
||||||
Rect2(13, 778, 218, 112), Rect2(238, 778, 218, 112), Rect2(463, 778, 218, 112),
|
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(688, 778, 218, 112), Rect2(913, 778, 218, 112), Rect2(1138, 778, 218, 112),
|
||||||
@@ -312,8 +332,8 @@ var target_priority_button := Rect2(666, 698, 300, 44)
|
|||||||
var endless_button := Rect2(545, 515, 510, 70)
|
var endless_button := Rect2(545, 515, 510, 70)
|
||||||
var finish_mission_button := Rect2(625, 610, 350, 62)
|
var finish_mission_button := Rect2(625, 610, 350, 62)
|
||||||
var profile_button := Rect2(1280, 25, 270, 52)
|
var profile_button := Rect2(1280, 25, 270, 52)
|
||||||
var leaderboard_button := Rect2(40, 790, 200, 54)
|
var leaderboard_button := Rect2(40, 772, 245, 62)
|
||||||
var shop_button := Rect2(568, 790, 200, 54)
|
var shop_button := Rect2(550, 772, 245, 62)
|
||||||
var map_step_back_button := Rect2(55, 320, 170, 50)
|
var map_step_back_button := Rect2(55, 320, 170, 50)
|
||||||
var shop_back_button := Rect2(55, 35, 185, 54)
|
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)]
|
var shop_purchase_buttons := [Rect2(190, 545, 360, 62), Rect2(620, 545, 360, 62), Rect2(1050, 545, 360, 62)]
|
||||||
@@ -337,7 +357,7 @@ var leaderboard_difficulty := 1
|
|||||||
var leaderboard_loading := false
|
var leaderboard_loading := false
|
||||||
var leaderboard_message := ""
|
var leaderboard_message := ""
|
||||||
var profile_back_button := Rect2(55, 35, 185, 54)
|
var profile_back_button := Rect2(55, 35, 185, 54)
|
||||||
var multiplayer_button := Rect2(304, 790, 200, 54)
|
var multiplayer_button := Rect2(295, 772, 245, 62)
|
||||||
var multiplayer_back_button := Rect2(55, 35, 185, 54)
|
var multiplayer_back_button := Rect2(55, 35, 185, 54)
|
||||||
var quick_match_button := Rect2(125, 665, 300, 64)
|
var quick_match_button := Rect2(125, 665, 300, 64)
|
||||||
var friend_match_button := Rect2(475, 665, 300, 64)
|
var friend_match_button := Rect2(475, 665, 300, 64)
|
||||||
@@ -362,10 +382,6 @@ var coop_side_button := Rect2(916, 14, 140, 54)
|
|||||||
var online_projectiles: Array[Dictionary] = []
|
var online_projectiles: Array[Dictionary] = []
|
||||||
var online_snapshot_elapsed := 0.0
|
var online_snapshot_elapsed := 0.0
|
||||||
var online_snapshot_interval := 0.1
|
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 room_code_input := ""
|
||||||
var profile_name_input := ""
|
var profile_name_input := ""
|
||||||
var profile_message := ""
|
var profile_message := ""
|
||||||
@@ -382,9 +398,9 @@ var available_update: Dictionary = {}
|
|||||||
var update_notice_visible := false
|
var update_notice_visible := false
|
||||||
var audio_settings_open := false
|
var audio_settings_open := false
|
||||||
var master_volume := 0.65
|
var master_volume := 0.65
|
||||||
var audio_settings_button := Rect2(832, 790, 200, 54)
|
var audio_settings_button := Rect2(805, 772, 245, 62)
|
||||||
var promo_code_button := Rect2(1096, 790, 200, 54)
|
var promo_code_button := Rect2(1060, 772, 245, 62)
|
||||||
var exit_button := Rect2(1360, 790, 200, 54)
|
var exit_button := Rect2(1315, 772, 245, 62)
|
||||||
var promo_code_input_rect := Rect2(500, 335, 600, 64)
|
var promo_code_input_rect := Rect2(500, 335, 600, 64)
|
||||||
var promo_code_redeem_button := Rect2(650, 460, 300, 58)
|
var promo_code_redeem_button := Rect2(650, 460, 300, 58)
|
||||||
var promo_code_cancel_button := Rect2(55, 35, 185, 54)
|
var promo_code_cancel_button := Rect2(55, 35, 185, 54)
|
||||||
@@ -408,6 +424,7 @@ var ui_language := "de"
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_load_balance_cache()
|
||||||
_select_map(selected_map)
|
_select_map(selected_map)
|
||||||
_load_progress()
|
_load_progress()
|
||||||
_load_audio_settings()
|
_load_audio_settings()
|
||||||
@@ -431,15 +448,38 @@ func _ready() -> void:
|
|||||||
online_manager.match_invite_received.connect(_on_match_invite_received)
|
online_manager.match_invite_received.connect(_on_match_invite_received)
|
||||||
_create_promo_code_input()
|
_create_promo_code_input()
|
||||||
_create_friend_name_input()
|
_create_friend_name_input()
|
||||||
|
_create_developer_password_input()
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _create_developer_password_input() -> void:
|
||||||
|
developer_password_line_edit = LineEdit.new()
|
||||||
|
developer_password_line_edit.position = developer_password_input_rect.position + Vector2(12, 4)
|
||||||
|
developer_password_line_edit.size = developer_password_input_rect.size - Vector2(24, 8)
|
||||||
|
developer_password_line_edit.max_length = 40
|
||||||
|
developer_password_line_edit.secret = true
|
||||||
|
developer_password_line_edit.placeholder_text = "PASSWORT"
|
||||||
|
developer_password_line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
developer_password_line_edit.add_theme_color_override("font_color", Color("e8fbff"))
|
||||||
|
developer_password_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("ffad62")
|
||||||
|
input_style.set_border_width_all(1)
|
||||||
|
input_style.set_content_margin_all(10)
|
||||||
|
developer_password_line_edit.add_theme_stylebox_override("normal", input_style)
|
||||||
|
developer_password_line_edit.add_theme_stylebox_override("focus", input_style)
|
||||||
|
developer_password_line_edit.text_submitted.connect(func(_value: String) -> void: _submit_developer_password())
|
||||||
|
add_child(developer_password_line_edit)
|
||||||
|
developer_password_line_edit.hide()
|
||||||
|
|
||||||
|
|
||||||
func _create_friend_name_input() -> void:
|
func _create_friend_name_input() -> void:
|
||||||
friend_name_line_edit = LineEdit.new()
|
friend_name_line_edit = LineEdit.new()
|
||||||
friend_name_line_edit.position = friend_name_rect.position + Vector2(10, 3)
|
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.size = friend_name_rect.size - Vector2(20, 6)
|
||||||
friend_name_line_edit.max_length = 40
|
friend_name_line_edit.max_length = 20
|
||||||
friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben")
|
friend_name_line_edit.placeholder_text = _ui("Spielername eingeben")
|
||||||
friend_name_line_edit.text_submitted.connect(func(_value: String) -> void: _add_friend_from_input())
|
friend_name_line_edit.text_submitted.connect(func(_value: String) -> void: _add_friend_from_input())
|
||||||
add_child(friend_name_line_edit)
|
add_child(friend_name_line_edit)
|
||||||
friend_name_line_edit.hide()
|
friend_name_line_edit.hide()
|
||||||
@@ -477,13 +517,18 @@ func _refresh_friends() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _add_friend_from_input() -> void:
|
func _add_friend_from_input() -> void:
|
||||||
var username := friend_name_line_edit.text.strip_edges()
|
var player_name := friend_name_line_edit.text.strip_edges()
|
||||||
if username.is_empty():
|
if player_name.is_empty():
|
||||||
return
|
return
|
||||||
var ok: bool = await online_manager.add_friend_by_username(username)
|
var result: Dictionary = await online_manager.add_friend_by_display_name(player_name)
|
||||||
friends_message = _ui("Anfrage an %s gesendet.") % username if ok else _ui("Spielerkennung nicht gefunden oder Anfrage fehlgeschlagen.")
|
var ok := bool(result.get("accepted", false))
|
||||||
if ok:
|
if ok:
|
||||||
|
friends_message = _ui("Anfrage an %s gesendet.") % String(result.get("display_name", player_name))
|
||||||
friend_name_line_edit.clear()
|
friend_name_line_edit.clear()
|
||||||
|
elif String(result.get("error", "")) == "ambiguous":
|
||||||
|
friends_message = _ui("Mehrere Spieler haben diesen Namen. Bitte einen eindeutigen Namen verwenden.")
|
||||||
|
else:
|
||||||
|
friends_message = _ui("Spielername nicht gefunden oder Anfrage fehlgeschlagen.")
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
@@ -502,7 +547,7 @@ func _friend_row_action(friend: Dictionary) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _can_invite_friends() -> bool:
|
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()
|
return friend_return_mode == "matchmaking" and multiplayer_manager.matchmaking_mode == "private_host" and multiplayer_manager.matchmaking_state in ["hosting", "lobby"] and not multiplayer_manager.private_room_code.is_empty()
|
||||||
|
|
||||||
|
|
||||||
func _create_promo_code_input() -> void:
|
func _create_promo_code_input() -> void:
|
||||||
@@ -537,14 +582,73 @@ func _create_promo_code_input() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_online_authenticated(_user_id: String, _username: String) -> void:
|
func _on_online_authenticated(_user_id: String, _username: String) -> void:
|
||||||
|
var balance_response: Dictionary = await online_manager.fetch_game_balance()
|
||||||
|
var balance_config = balance_response.get("config", {})
|
||||||
|
if balance_config is Dictionary and not balance_config.is_empty():
|
||||||
|
_save_balance_cache(balance_config)
|
||||||
|
if game_mode in ["menu", "promo_code", "store", "leaderboard", "profile", "multiplayer_menu", "friends", "room_code_entry", "matchmaking"]:
|
||||||
|
_apply_balance_config(balance_config)
|
||||||
var entitlements: Dictionary = await online_manager.get_promo_unlocks()
|
var entitlements: Dictionary = await online_manager.get_promo_unlocks()
|
||||||
_apply_promo_unlocks(entitlements)
|
_apply_promo_unlocks(entitlements)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_balance_cache() -> void:
|
||||||
|
if not FileAccess.file_exists(BALANCE_CACHE_PATH):
|
||||||
|
return
|
||||||
|
var file := FileAccess.open(BALANCE_CACHE_PATH, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return
|
||||||
|
var parsed = JSON.parse_string(file.get_as_text())
|
||||||
|
if parsed is Dictionary:
|
||||||
|
_apply_balance_config(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
func _save_balance_cache(config: Dictionary) -> void:
|
||||||
|
var file := FileAccess.open(BALANCE_CACHE_PATH, FileAccess.WRITE)
|
||||||
|
if file != null:
|
||||||
|
file.store_string(JSON.stringify(config))
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_balance_config(config: Dictionary) -> bool:
|
||||||
|
var towers_config = config.get("towers", [])
|
||||||
|
var enemies_config = config.get("enemies", [])
|
||||||
|
if not towers_config is Array or towers_config.size() != TOWER_DATA.size() - 1 or not enemies_config is Array or enemies_config.size() != ENEMY_DATA.size():
|
||||||
|
return false
|
||||||
|
for values in towers_config:
|
||||||
|
if not values is Dictionary or str(values.get("targetClass", "both")) not in ["air", "ground", "both"]:
|
||||||
|
return false
|
||||||
|
for values in enemies_config:
|
||||||
|
if not values is Dictionary:
|
||||||
|
return false
|
||||||
|
for index in TOWER_DATA.size() - 1:
|
||||||
|
var values: Dictionary = towers_config[index]
|
||||||
|
var tower: Dictionary = TOWER_DATA[index]
|
||||||
|
tower["cost"] = clampi(int(values.get("cost", tower.cost)), 10, 50000)
|
||||||
|
tower["damage"] = clampf(float(values.get("damage", tower.damage)), 0.1, 10000.0)
|
||||||
|
tower["range"] = clampf(float(values.get("range", tower.range)), 20.0, 1200.0)
|
||||||
|
tower["rate"] = clampf(float(values.get("cooldown", tower.rate)), 0.04, 20.0)
|
||||||
|
var target_class := str(values.get("targetClass", tower.get("target_class", "both")))
|
||||||
|
tower["target_class"] = target_class
|
||||||
|
tower["both_damage_scale"] = clampf(float(values.get("bothScale", tower.get("both_damage_scale", 1.0))), 0.05, 2.0)
|
||||||
|
for index in ENEMY_DATA.size():
|
||||||
|
var values: Dictionary = enemies_config[index]
|
||||||
|
var enemy: Dictionary = ENEMY_DATA[index]
|
||||||
|
enemy["hp"] = clampf(float(values.get("hp", enemy.hp)), 1.0, 1000000.0)
|
||||||
|
enemy["speed"] = clampf(float(values.get("speed", enemy.speed)), 1.0, 1500.0)
|
||||||
|
enemy["reward"] = clampi(int(values.get("reward", enemy.reward)), 0, 50000)
|
||||||
|
enemy["breach_damage"] = clampi(int(values.get("breachDamage", enemy.breach_damage)), 1, 100)
|
||||||
|
enemy["shield_resistance"] = clampf(float(values.get("shieldResistance", enemy.shield_resistance)), 0.01, 1.0)
|
||||||
|
var admin_config = config.get("admin_tower", {})
|
||||||
|
if admin_config is Dictionary:
|
||||||
|
TOWER_DATA[ADMIN_TOWER_TYPE]["range"] = clampf(float(admin_config.get("range", 650.0)), 80.0, 1200.0)
|
||||||
|
TOWER_DATA[ADMIN_TOWER_TYPE]["rate"] = clampf(float(admin_config.get("cooldown", 0.08)), 0.04, 20.0)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
func _apply_promo_unlocks(entitlements: Dictionary) -> void:
|
func _apply_promo_unlocks(entitlements: Dictionary) -> void:
|
||||||
if bool(entitlements.get("all_towers", false)):
|
if bool(entitlements.get("all_towers", false)):
|
||||||
unlocked_towers = []
|
unlocked_towers = []
|
||||||
for tower_type in TOWER_DATA.size():
|
for tower_type in TOWER_DATA.size() - 1:
|
||||||
unlocked_towers.append(tower_type)
|
unlocked_towers.append(tower_type)
|
||||||
if bool(entitlements.get("all_maps", false)):
|
if bool(entitlements.get("all_maps", false)):
|
||||||
unlocked_maps = []
|
unlocked_maps = []
|
||||||
@@ -596,13 +700,8 @@ func _promo_reward_label(reward: String) -> String:
|
|||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
elapsed += delta
|
elapsed += delta
|
||||||
if coop_online_mode and delta > 0.2:
|
if coop_online_mode:
|
||||||
online_frame_stall_count += 1
|
online_manager.record_online_frame(delta)
|
||||||
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:
|
if status_timer > 0.0:
|
||||||
status_timer -= delta
|
status_timer -= delta
|
||||||
if game_mode == "playing" or (game_mode == "paused" and coop_online_mode):
|
if game_mode == "playing" or (game_mode == "paused" and coop_online_mode):
|
||||||
@@ -657,9 +756,14 @@ func _spawn_enemy() -> void:
|
|||||||
var type := int(schedule_entry.get("type", _enemy_type_for_wave(wave, randf(), is_boss)))
|
var type := int(schedule_entry.get("type", _enemy_type_for_wave(wave, randf(), is_boss)))
|
||||||
wave_spawn_index += 1
|
wave_spawn_index += 1
|
||||||
var data: Dictionary = ENEMY_DATA[type]
|
var data: Dictionary = ENEMY_DATA[type]
|
||||||
var health_scale := _enemy_health_scale(wave, is_boss) * enemy_strength_multiplier
|
var base_health_scale := _enemy_health_scale(wave, is_boss)
|
||||||
|
var first_boss_hull_scale := 0.5 if is_boss and wave == 5 else 1.0
|
||||||
|
var health_scale := base_health_scale * enemy_strength_multiplier * first_boss_hull_scale
|
||||||
|
var shield_scale := base_health_scale * enemy_strength_multiplier
|
||||||
var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06))
|
var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06))
|
||||||
var max_shield_hp: float = data.hp * health_scale * 0.55 if has_shield else 0.0
|
var max_shield_hp: float = data.hp * shield_scale * 0.55 if has_shield else 0.0
|
||||||
|
if is_boss and wave == 5:
|
||||||
|
max_shield_hp *= 0.5
|
||||||
var route_id := 0
|
var route_id := 0
|
||||||
var route := path_points
|
var route := path_points
|
||||||
if coop_mode:
|
if coop_mode:
|
||||||
@@ -674,7 +778,7 @@ func _spawn_enemy() -> void:
|
|||||||
"route_id": route_id,
|
"route_id": route_id,
|
||||||
"hp": data.hp * health_scale,
|
"hp": data.hp * health_scale,
|
||||||
"max_hp": data.hp * health_scale,
|
"max_hp": data.hp * health_scale,
|
||||||
"base_max_hp": data.hp * _enemy_health_scale(wave, is_boss),
|
"base_max_hp": data.hp * base_health_scale * first_boss_hull_scale,
|
||||||
"speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed),
|
"speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed),
|
||||||
"reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)),
|
"reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)),
|
||||||
"angle": 0.0,
|
"angle": 0.0,
|
||||||
@@ -747,7 +851,7 @@ func _tower_target_class(tower_type: int) -> String:
|
|||||||
func _tower_display_order() -> Array[int]:
|
func _tower_display_order() -> Array[int]:
|
||||||
var ordered_types: Array[int] = []
|
var ordered_types: Array[int] = []
|
||||||
for target_class in ["air", "ground", "both"]:
|
for target_class in ["air", "ground", "both"]:
|
||||||
for tower_type in TOWER_DATA.size():
|
for tower_type in TOWER_DATA.size() - 1:
|
||||||
if _tower_target_class(tower_type) == target_class:
|
if _tower_target_class(tower_type) == target_class:
|
||||||
ordered_types.append(tower_type)
|
ordered_types.append(tower_type)
|
||||||
return ordered_types
|
return ordered_types
|
||||||
@@ -836,6 +940,8 @@ func _find_target(tower: Dictionary) -> Dictionary:
|
|||||||
var best_is_shielded := false
|
var best_is_shielded := false
|
||||||
var effective_range := _tower_range(tower.type, tower.level)
|
var effective_range := _tower_range(tower.type, tower.level)
|
||||||
for enemy in enemies:
|
for enemy in enemies:
|
||||||
|
if coop_mode and int(tower.type) == ADMIN_TOWER_TYPE and int(enemy.get("route_id", 0)) != (0 if tower.pos.x < COOP_MIRROR_X else 1):
|
||||||
|
continue
|
||||||
if _tower_can_target(tower.type, int(enemy.type)) and tower.pos.distance_to(enemy.pos) <= effective_range:
|
if _tower_can_target(tower.type, int(enemy.type)) and tower.pos.distance_to(enemy.pos) <= effective_range:
|
||||||
var route: PackedVector2Array = _enemy_route(enemy)
|
var route: PackedVector2Array = _enemy_route(enemy)
|
||||||
var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index])
|
var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index])
|
||||||
@@ -886,7 +992,10 @@ func _fire_tower(tower: Dictionary, target: Dictionary) -> void:
|
|||||||
var damage := _tower_damage(tower.type, tower.level)
|
var damage := _tower_damage(tower.type, tower.level)
|
||||||
if _tower_target_class(tower.type) == "both":
|
if _tower_target_class(tower.type) == "both":
|
||||||
damage *= float(data.get("both_damage_scale", 0.7))
|
damage *= float(data.get("both_damage_scale", 0.7))
|
||||||
if tower.type == 1:
|
if tower.type == ADMIN_TOWER_TYPE:
|
||||||
|
_apply_damage(target, damage, int(tower.get("owner", 0)), tower.type)
|
||||||
|
beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.16, "color": data.color, "piercing": false})
|
||||||
|
elif tower.type == 1:
|
||||||
_apply_damage(target, damage, int(tower.get("owner", 0)), tower.type)
|
_apply_damage(target, damage, int(tower.get("owner", 0)), tower.type)
|
||||||
beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color})
|
beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color})
|
||||||
elif tower.type == 5:
|
elif tower.type == 5:
|
||||||
@@ -939,6 +1048,14 @@ func _update_projectiles(delta: float) -> void:
|
|||||||
func _apply_damage(enemy: Dictionary, amount: float, owner := 0, weapon_type := -1) -> void:
|
func _apply_damage(enemy: Dictionary, amount: float, owner := 0, weapon_type := -1) -> void:
|
||||||
if not enemies.has(enemy):
|
if not enemies.has(enemy):
|
||||||
return
|
return
|
||||||
|
if weapon_type == ADMIN_TOWER_TYPE:
|
||||||
|
enemy.shield = false
|
||||||
|
enemy.shield_hp = 0.0
|
||||||
|
enemy.hp = 0.0
|
||||||
|
_award_credits(owner, int(enemy.reward))
|
||||||
|
_spawn_burst(enemy.pos, Color("ff6b3d"), 11 + enemy.type * 4)
|
||||||
|
enemies.erase(enemy)
|
||||||
|
return
|
||||||
var applied_damage := amount
|
var applied_damage := amount
|
||||||
if bool(enemy.get("shield", false)):
|
if bool(enemy.get("shield", false)):
|
||||||
if weapon_type == 1:
|
if weapon_type == 1:
|
||||||
@@ -996,7 +1113,21 @@ func _update_effects(delta: float) -> void:
|
|||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F9 and game_mode in ["playing", "paused"]:
|
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F9 and game_mode in ["playing", "paused"]:
|
||||||
developer_panel_open = not developer_panel_open
|
if developer_password_prompt_open:
|
||||||
|
developer_password_prompt_open = false
|
||||||
|
developer_password_line_edit.hide()
|
||||||
|
elif developer_unlocked_this_round:
|
||||||
|
developer_panel_open = not developer_panel_open
|
||||||
|
else:
|
||||||
|
developer_password_prompt_open = true
|
||||||
|
developer_password_line_edit.clear()
|
||||||
|
developer_password_line_edit.show()
|
||||||
|
developer_password_line_edit.grab_focus()
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
if developer_password_prompt_open and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||||
|
developer_password_prompt_open = false
|
||||||
|
developer_password_line_edit.hide()
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
return
|
return
|
||||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]:
|
if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]:
|
||||||
@@ -1072,6 +1203,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
func _handle_pointer(position: Vector2, is_touch := false) -> void:
|
func _handle_pointer(position: Vector2, is_touch := false) -> void:
|
||||||
_play_ui_sound()
|
_play_ui_sound()
|
||||||
|
if developer_password_prompt_open:
|
||||||
|
if developer_password_cancel_button.has_point(position):
|
||||||
|
developer_password_prompt_open = false
|
||||||
|
developer_password_line_edit.hide()
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
if developer_panel_open and game_mode in ["playing", "paused"]:
|
if developer_panel_open and game_mode in ["playing", "paused"]:
|
||||||
if developer_strength_down_button.has_point(position):
|
if developer_strength_down_button.has_point(position):
|
||||||
_set_enemy_strength_multiplier(enemy_strength_multiplier - 0.1)
|
_set_enemy_strength_multiplier(enemy_strength_multiplier - 0.1)
|
||||||
@@ -1085,6 +1222,21 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
|
|||||||
if developer_crystals_up_button.has_point(position):
|
if developer_crystals_up_button.has_point(position):
|
||||||
_adjust_core_crystals(25)
|
_adjust_core_crystals(25)
|
||||||
return
|
return
|
||||||
|
if developer_energy_down_button.has_point(position):
|
||||||
|
_adjust_developer_energy(-500)
|
||||||
|
return
|
||||||
|
if developer_energy_up_button.has_point(position):
|
||||||
|
_adjust_developer_energy(500)
|
||||||
|
return
|
||||||
|
if developer_admin_tower_button.has_point(position):
|
||||||
|
if coop_online_mode and not developer_server_authorized:
|
||||||
|
_show_status("ONLINE-TESTMODUS WIRD NOCH AUTORISIERT")
|
||||||
|
return
|
||||||
|
selected_tower_type = ADMIN_TOWER_TYPE
|
||||||
|
selected_tower_index = -1
|
||||||
|
_show_status("ADMIN-TURM GEWÄHLT • BAUPLATZ AUF DER KARTE ANKLICKEN")
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
if developer_panel_rect.has_point(position):
|
if developer_panel_rect.has_point(position):
|
||||||
return
|
return
|
||||||
if audio_settings_open:
|
if audio_settings_open:
|
||||||
@@ -1101,6 +1253,9 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
|
|||||||
queue_redraw()
|
queue_redraw()
|
||||||
return
|
return
|
||||||
if game_mode == "menu":
|
if game_mode == "menu":
|
||||||
|
if _is_mobile_menu_layout():
|
||||||
|
_handle_mobile_menu_pointer(position)
|
||||||
|
return
|
||||||
if not pending_match_invite.is_empty() and Rect2(470, 226, 660, 54).has_point(position):
|
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", ""))
|
room_code_input = String(pending_match_invite.get("room_code", ""))
|
||||||
pending_match_invite.clear()
|
pending_match_invite.clear()
|
||||||
@@ -1259,8 +1414,8 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
|
|||||||
elif friend_add_button.has_point(position):
|
elif friend_add_button.has_point(position):
|
||||||
_add_friend_from_input()
|
_add_friend_from_input()
|
||||||
elif friend_copy_button.has_point(position) and online_manager.session != null:
|
elif friend_copy_button.has_point(position) and online_manager.session != null:
|
||||||
DisplayServer.clipboard_set(String(online_manager.session.username))
|
DisplayServer.clipboard_set(account_manager.display_name)
|
||||||
friends_message = _ui("Spielerkennung kopiert.")
|
friends_message = _ui("Spielername kopiert.")
|
||||||
else:
|
else:
|
||||||
for index in mini(friend_action_buttons.size(), online_manager.friends.size()):
|
for index in mini(friend_action_buttons.size(), online_manager.friends.size()):
|
||||||
if friend_action_buttons[index].has_point(position):
|
if friend_action_buttons[index].has_point(position):
|
||||||
@@ -1483,12 +1638,15 @@ func _toggle_coop_side() -> void:
|
|||||||
pending_touch_build_position = Vector2(-1, -1)
|
pending_touch_build_position = Vector2(-1, -1)
|
||||||
camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if camera_offset.x < 1.0 else 0.0
|
camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if camera_offset.x < 1.0 else 0.0
|
||||||
cursor_position = cursor_screen_position + camera_offset
|
cursor_position = cursor_screen_position + camera_offset
|
||||||
var side_name := "PARTNER-SEITE" if coop_online_mode else "KI-SEITE"
|
var viewing_right := camera_offset.x > 0.0
|
||||||
_show_status(side_name if camera_offset.x > 0.0 else "DEINE SEITE")
|
var viewing_own_side := (viewing_right and _local_credit_slot() == 1) or (not viewing_right and _local_credit_slot() == 0)
|
||||||
|
_show_status("DEINE SEITE" if viewing_own_side else ("PARTNER-SEITE" if coop_online_mode else "KI-SEITE"))
|
||||||
|
|
||||||
|
|
||||||
func _start_coop_ai_game() -> void:
|
func _start_coop_ai_game() -> void:
|
||||||
online_manager.set_presence_status("playing")
|
online_manager.set_presence_status("playing")
|
||||||
|
online_manager.record_local_play_start()
|
||||||
|
_reset_developer_access()
|
||||||
coop_mode = true
|
coop_mode = true
|
||||||
coop_ai_mode = true
|
coop_ai_mode = true
|
||||||
coop_online_mode = false
|
coop_online_mode = false
|
||||||
@@ -1525,6 +1683,7 @@ func _start_coop_ai_game() -> void:
|
|||||||
|
|
||||||
func _start_coop_online_game(player_slot: int) -> void:
|
func _start_coop_online_game(player_slot: int) -> void:
|
||||||
online_manager.set_presence_status("playing")
|
online_manager.set_presence_status("playing")
|
||||||
|
_reset_developer_access()
|
||||||
local_player_slot = clampi(player_slot, 0, 1)
|
local_player_slot = clampi(player_slot, 0, 1)
|
||||||
coop_mode = true
|
coop_mode = true
|
||||||
coop_ai_mode = false
|
coop_ai_mode = false
|
||||||
@@ -1549,6 +1708,7 @@ func _start_coop_online_game(player_slot: int) -> void:
|
|||||||
game_speed = 1.0
|
game_speed = 1.0
|
||||||
tutorial_step = 3
|
tutorial_step = 3
|
||||||
camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if local_player_slot == 1 else 0.0
|
camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if local_player_slot == 1 else 0.0
|
||||||
|
online_manager.set_diagnostic_slot(local_player_slot, local_player_slot == 1)
|
||||||
ads_manager.begin_mission()
|
ads_manager.begin_mission()
|
||||||
_apply_online_snapshot(multiplayer_manager.latest_snapshot)
|
_apply_online_snapshot(multiplayer_manager.latest_snapshot)
|
||||||
_show_status("ONLINE-KOOP BEREIT • DU VERTEIDIGST %s" % ("RECHTS" if local_player_slot == 1 else "LINKS"))
|
_show_status("ONLINE-KOOP BEREIT • DU VERTEIDIGST %s" % ("RECHTS" if local_player_slot == 1 else "LINKS"))
|
||||||
@@ -1690,6 +1850,8 @@ func _refresh_leaderboard() -> void:
|
|||||||
|
|
||||||
func _start_game() -> void:
|
func _start_game() -> void:
|
||||||
online_manager.set_presence_status("playing")
|
online_manager.set_presence_status("playing")
|
||||||
|
online_manager.record_local_play_start()
|
||||||
|
_reset_developer_access()
|
||||||
coop_online_mode = false
|
coop_online_mode = false
|
||||||
credits = int(_difficulty().start_credits)
|
credits = int(_difficulty().start_credits)
|
||||||
reactor_lives = int(_difficulty().lives)
|
reactor_lives = int(_difficulty().lives)
|
||||||
@@ -1720,6 +1882,7 @@ func _start_game() -> void:
|
|||||||
|
|
||||||
func _return_to_menu() -> void:
|
func _return_to_menu() -> void:
|
||||||
online_manager.set_presence_status("menu")
|
online_manager.set_presence_status("menu")
|
||||||
|
_reset_developer_access()
|
||||||
if coop_online_mode:
|
if coop_online_mode:
|
||||||
online_manager.leave_current_match()
|
online_manager.leave_current_match()
|
||||||
game_mode = "menu"
|
game_mode = "menu"
|
||||||
@@ -1744,6 +1907,58 @@ func _return_to_menu() -> void:
|
|||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _reset_developer_access() -> void:
|
||||||
|
developer_panel_open = false
|
||||||
|
developer_unlocked_this_round = false
|
||||||
|
developer_server_authorized = false
|
||||||
|
developer_password_prompt_open = false
|
||||||
|
if developer_password_line_edit:
|
||||||
|
developer_password_line_edit.clear()
|
||||||
|
developer_password_line_edit.hide()
|
||||||
|
|
||||||
|
|
||||||
|
func _submit_developer_password() -> void:
|
||||||
|
if developer_password_line_edit == null:
|
||||||
|
return
|
||||||
|
if developer_password_line_edit.text == DEVELOPER_PASSWORD:
|
||||||
|
developer_unlocked_this_round = true
|
||||||
|
developer_panel_open = true
|
||||||
|
developer_password_prompt_open = false
|
||||||
|
developer_password_line_edit.hide()
|
||||||
|
if coop_online_mode:
|
||||||
|
developer_server_authorized = false
|
||||||
|
online_manager.send_match_command(DEVELOPER_AUTH_OP, {"password": developer_password_line_edit.text})
|
||||||
|
_show_status("TESTMODUS FREI • ONLINE-SERVER WIRD AUTORISIERT")
|
||||||
|
else:
|
||||||
|
developer_server_authorized = true
|
||||||
|
_show_status("TESTMODUS FÜR DIESE RUNDE FREIGESCHALTET")
|
||||||
|
else:
|
||||||
|
developer_password_line_edit.clear()
|
||||||
|
_show_status("PASSWORT FALSCH")
|
||||||
|
developer_password_line_edit.grab_focus()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _adjust_developer_energy(amount: int) -> void:
|
||||||
|
if amount not in [-500, 500]:
|
||||||
|
return
|
||||||
|
if coop_online_mode:
|
||||||
|
if not developer_server_authorized:
|
||||||
|
_show_status("ONLINE-TESTMODUS WIRD NOCH AUTORISIERT")
|
||||||
|
return
|
||||||
|
online_manager.send_match_command(DEVELOPER_ENERGY_OP, {"amount": amount})
|
||||||
|
_show_status("ENERGIEÄNDERUNG AN SERVER GESENDET")
|
||||||
|
return
|
||||||
|
if coop_mode:
|
||||||
|
var current_energy := int(multiplayer_manager.player_credits[local_player_slot])
|
||||||
|
multiplayer_manager.player_credits[local_player_slot] = maxi(0, current_energy + amount)
|
||||||
|
credits = int(multiplayer_manager.player_credits[0])
|
||||||
|
else:
|
||||||
|
credits = maxi(0, credits + amount)
|
||||||
|
_show_status("TESTENERGIE %s500" % ("+" if amount > 0 else "−"))
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func _start_wave() -> void:
|
func _start_wave() -> void:
|
||||||
if wave_active:
|
if wave_active:
|
||||||
_show_status("WELLE LÄUFT BEREITS")
|
_show_status("WELLE LÄUFT BEREITS")
|
||||||
@@ -1782,6 +1997,8 @@ func _toggle_pause() -> void:
|
|||||||
func _game_over() -> void:
|
func _game_over() -> void:
|
||||||
if game_mode != "playing":
|
if game_mode != "playing":
|
||||||
return
|
return
|
||||||
|
if coop_online_mode:
|
||||||
|
online_manager._finish_match_diagnostics("game_over")
|
||||||
if endless_mode:
|
if endless_mode:
|
||||||
_endless_run_complete()
|
_endless_run_complete()
|
||||||
return
|
return
|
||||||
@@ -1910,15 +2127,19 @@ func _complete_tutorial() -> void:
|
|||||||
|
|
||||||
func _place_tower(position: Vector2) -> void:
|
func _place_tower(position: Vector2) -> void:
|
||||||
position = _snap_to_grid(position)
|
position = _snap_to_grid(position)
|
||||||
if selected_tower_type < 0 or not unlocked_towers.has(selected_tower_type):
|
var is_admin_tower := selected_tower_type == ADMIN_TOWER_TYPE
|
||||||
|
if is_admin_tower and (not developer_unlocked_this_round or (coop_online_mode and not developer_server_authorized)):
|
||||||
|
_show_status("ADMIN-TURM IST NOCH NICHT FREIGESCHALTET")
|
||||||
|
return
|
||||||
|
if selected_tower_type < 0 or (not is_admin_tower and not unlocked_towers.has(selected_tower_type)):
|
||||||
_show_status("TURM ZUERST IM ARSENAL FREISCHALTEN")
|
_show_status("TURM ZUERST IM ARSENAL FREISCHALTEN")
|
||||||
return
|
return
|
||||||
var data: Dictionary = TOWER_DATA[selected_tower_type]
|
var data: Dictionary = TOWER_DATA[selected_tower_type]
|
||||||
var credit_slot := _local_credit_slot()
|
var credit_slot := _local_credit_slot()
|
||||||
if _player_credits(credit_slot) < data.cost:
|
if not is_admin_tower and _player_credits(credit_slot) < data.cost:
|
||||||
_show_status("NICHT GENUG ENERGIE")
|
_show_status("NICHT GENUG ENERGIE")
|
||||||
return
|
return
|
||||||
if not _is_valid_build_position(position, credit_slot):
|
if not _is_valid_build_position(position, credit_slot, is_admin_tower):
|
||||||
_show_status("BAUPLATZ BLOCKIERT")
|
_show_status("BAUPLATZ BLOCKIERT")
|
||||||
return
|
return
|
||||||
if coop_online_mode:
|
if coop_online_mode:
|
||||||
@@ -1927,15 +2148,17 @@ func _place_tower(position: Vector2) -> void:
|
|||||||
pending_touch_build_position = Vector2(-1, -1)
|
pending_touch_build_position = Vector2(-1, -1)
|
||||||
_show_status("BAUAUFTRAG AN SERVER GESENDET")
|
_show_status("BAUAUFTRAG AN SERVER GESENDET")
|
||||||
return
|
return
|
||||||
_spend_credits(0, int(data.cost))
|
if not is_admin_tower:
|
||||||
towers_built_this_run += 1
|
_spend_credits(0, int(data.cost))
|
||||||
|
towers_built_this_run += 1
|
||||||
|
online_manager.record_local_tower_build(selected_tower_type)
|
||||||
towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0, "target_priority": "shield" if selected_tower_type == 1 else "first"})
|
towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0, "target_priority": "shield" if selected_tower_type == 1 else "first"})
|
||||||
selected_tower_index = towers.size() - 1
|
selected_tower_index = towers.size() - 1
|
||||||
selected_tower_type = -1
|
selected_tower_type = -1
|
||||||
pending_touch_build_position = Vector2(-1, -1)
|
pending_touch_build_position = Vector2(-1, -1)
|
||||||
if not tutorial_completed and tutorial_step == 1:
|
if not tutorial_completed and tutorial_step == 1:
|
||||||
tutorial_step = 2
|
tutorial_step = 2
|
||||||
_spawn_burst(position, Color("65eaff"), 12)
|
_spawn_burst(position, Color("fff2a1") if is_admin_tower else Color("65eaff"), 12)
|
||||||
|
|
||||||
|
|
||||||
func _upgrade_selected() -> void:
|
func _upgrade_selected() -> void:
|
||||||
@@ -2152,7 +2375,7 @@ func _ai_priority_category() -> String:
|
|||||||
func _ai_choose_tower_type(category: String) -> int:
|
func _ai_choose_tower_type(category: String) -> int:
|
||||||
var best_type := 1
|
var best_type := 1
|
||||||
var best_score := -INF
|
var best_score := -INF
|
||||||
for tower_type in TOWER_DATA.size():
|
for tower_type in TOWER_DATA.size() - 1:
|
||||||
var target_class := _tower_target_class(tower_type)
|
var target_class := _tower_target_class(tower_type)
|
||||||
if target_class != category and target_class != "both":
|
if target_class != category and target_class != "both":
|
||||||
continue
|
continue
|
||||||
@@ -2317,7 +2540,7 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
|
|||||||
var shot_from := Vector2(float(fired_shot.get("from_x", 0.0)), float(fired_shot.get("from_y", 0.0)))
|
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_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
|
var shot_color: Color = TOWER_DATA[tower_type].color
|
||||||
if tower_type in [1, 5]:
|
if tower_type in [1, 5, ADMIN_TOWER_TYPE]:
|
||||||
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})
|
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:
|
else:
|
||||||
online_projectiles.append({"pos": shot_from, "target": shot_to, "speed": float(TOWER_DATA[tower_type].shot_speed), "type": tower_type, "color": shot_color})
|
online_projectiles.append({"pos": shot_from, "target": shot_to, "speed": float(TOWER_DATA[tower_type].shot_speed), "type": tower_type, "color": shot_color})
|
||||||
@@ -2325,14 +2548,7 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
|
|||||||
selected_tower_index = -1
|
selected_tower_index = -1
|
||||||
if bool(snapshot.get("game_over", false)) and game_mode == "playing":
|
if bool(snapshot.get("game_over", false)) and game_mode == "playing":
|
||||||
_game_over()
|
_game_over()
|
||||||
online_apply_max_us = maxi(online_apply_max_us, Time.get_ticks_usec() - apply_start_us)
|
online_manager.record_snapshot_apply(Time.get_ticks_usec() - apply_start_us, enemies.size(), towers.size())
|
||||||
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:
|
func _update_online_interpolation(delta: float) -> void:
|
||||||
@@ -2358,8 +2574,18 @@ func _handle_online_event(event: Dictionary) -> void:
|
|||||||
if not coop_online_mode:
|
if not coop_online_mode:
|
||||||
return
|
return
|
||||||
match String(event.get("type", "")):
|
match String(event.get("type", "")):
|
||||||
|
"developer_authorized":
|
||||||
|
if int(event.get("slot", -1)) == local_player_slot:
|
||||||
|
developer_server_authorized = true
|
||||||
|
_show_status("ONLINE-TESTMODUS AUF DEM SERVER FREIGESCHALTET")
|
||||||
|
"developer_auth_rejected":
|
||||||
|
if int(event.get("slot", -1)) == local_player_slot:
|
||||||
|
developer_server_authorized = false
|
||||||
|
_show_status("SERVER HAT DAS TESTPASSWORT ABGELEHNT")
|
||||||
"wave_complete":
|
"wave_complete":
|
||||||
_show_status("WELLE %d ABGEWEHRT +%d" % [int(event.get("wave", wave)), int(event.get("bonus", 0))])
|
_show_status("WELLE %d ABGEWEHRT +%d" % [int(event.get("wave", wave)), int(event.get("bonus", 0))])
|
||||||
|
if int(event.get("wave", wave)) >= MAX_WAVES:
|
||||||
|
online_manager._finish_match_diagnostics("victory")
|
||||||
"partner_left":
|
"partner_left":
|
||||||
_show_status("VERBINDUNG ZUM PARTNER VERLOREN")
|
_show_status("VERBINDUNG ZUM PARTNER VERLOREN")
|
||||||
"command_rejected":
|
"command_rejected":
|
||||||
@@ -2382,7 +2608,7 @@ func _load_audio_settings() -> void:
|
|||||||
ui_language = "de"
|
ui_language = "de"
|
||||||
_apply_master_volume()
|
_apply_master_volume()
|
||||||
if friend_name_line_edit:
|
if friend_name_line_edit:
|
||||||
friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben")
|
friend_name_line_edit.placeholder_text = _ui("Spielername eingeben")
|
||||||
|
|
||||||
|
|
||||||
func _ui(text: String) -> String:
|
func _ui(text: String) -> String:
|
||||||
@@ -2394,7 +2620,7 @@ func _set_ui_language(value: String) -> void:
|
|||||||
return
|
return
|
||||||
ui_language = value
|
ui_language = value
|
||||||
if friend_name_line_edit:
|
if friend_name_line_edit:
|
||||||
friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben")
|
friend_name_line_edit.placeholder_text = _ui("Spielername eingeben")
|
||||||
_save_audio_settings()
|
_save_audio_settings()
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
@@ -2562,6 +2788,8 @@ func _draw() -> void:
|
|||||||
_draw_endless_offer()
|
_draw_endless_offer()
|
||||||
elif game_mode == "endless_result":
|
elif game_mode == "endless_result":
|
||||||
_draw_endless_result()
|
_draw_endless_result()
|
||||||
|
if developer_password_prompt_open:
|
||||||
|
_draw_developer_password_prompt()
|
||||||
|
|
||||||
|
|
||||||
func _active_world_width() -> float:
|
func _active_world_width() -> float:
|
||||||
@@ -2661,8 +2889,9 @@ func _prepare_draw_geometry() -> void:
|
|||||||
var center := Vector2(x_index * GRID_SIZE + GRID_SIZE * 0.5, y_index * GRID_SIZE + GRID_SIZE * 0.5)
|
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):
|
if not _is_in_build_zone(center) or _is_in_reactor_zone(center) or _is_path_cell(center):
|
||||||
continue
|
continue
|
||||||
|
var cell_side := 1 if center.x > COOP_MIRROR_X else 0
|
||||||
build_cell_cache.append({"rect": Rect2(center - Vector2(34, 34), Vector2(68, 68)),
|
build_cell_cache.append({"rect": Rect2(center - Vector2(34, 34), Vector2(68, 68)),
|
||||||
"partner": coop_mode and center.x > COOP_MIRROR_X})
|
"partner": coop_mode and cell_side != _local_credit_slot()})
|
||||||
draw_geometry_dirty = false
|
draw_geometry_dirty = false
|
||||||
|
|
||||||
|
|
||||||
@@ -2761,12 +2990,17 @@ func _draw_build_grid() -> void:
|
|||||||
|
|
||||||
func _draw_tower(tower: Dictionary) -> void:
|
func _draw_tower(tower: Dictionary) -> void:
|
||||||
var size: float = 74.0 + (tower.level - 1) * 4.0
|
var size: float = 74.0 + (tower.level - 1) * 4.0
|
||||||
if coop_mode and int(tower.get("owner", 0)) == 1:
|
if coop_mode and int(tower.get("owner", 0)) != _local_credit_slot():
|
||||||
draw_circle(tower.pos, size * 0.54, Color(1.0, 0.56, 0.20, 0.18))
|
draw_circle(tower.pos, size * 0.54, Color(1.0, 0.56, 0.20, 0.18))
|
||||||
draw_arc(tower.pos, size * 0.54, 0.0, TAU, 28, Color(1.0, 0.70, 0.34, 0.72), 2.0)
|
draw_arc(tower.pos, size * 0.54, 0.0, TAU, 28, Color(1.0, 0.70, 0.34, 0.72), 2.0)
|
||||||
draw_set_transform(tower.pos - camera_offset, tower.angle, Vector2.ONE)
|
draw_set_transform(tower.pos - camera_offset, tower.angle, Vector2.ONE)
|
||||||
var destination := Rect2(-size * 0.5, -size * 0.5, size, size)
|
var destination := Rect2(-size * 0.5, -size * 0.5, size, size)
|
||||||
if tower.type < 3:
|
if tower.type == ADMIN_TOWER_TYPE:
|
||||||
|
draw_circle(Vector2.ZERO, size * 0.42, Color("504621"))
|
||||||
|
draw_arc(Vector2.ZERO, size * 0.47, 0.0, TAU, 40, Color("fff2a1"), 4.0)
|
||||||
|
draw_circle(Vector2.ZERO, size * 0.20, Color("fff2a1"))
|
||||||
|
draw_string(ThemeDB.fallback_font, Vector2(-10, 5), "A", HORIZONTAL_ALIGNMENT_CENTER, 20, 14, Color("201a0b"))
|
||||||
|
elif tower.type < 3:
|
||||||
var source_width: float = TOWER_TEXTURE.get_width() / 3.0
|
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()))
|
draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(source_width * tower.type, 0, source_width, TOWER_TEXTURE.get_height()))
|
||||||
elif tower.type == 6:
|
elif tower.type == 6:
|
||||||
@@ -2885,18 +3119,33 @@ func _draw_hud() -> void:
|
|||||||
if developer_panel_open:
|
if developer_panel_open:
|
||||||
draw_rect(developer_panel_rect, Color(0.008, 0.024, 0.045, 0.96), true)
|
draw_rect(developer_panel_rect, Color(0.008, 0.024, 0.045, 0.96), true)
|
||||||
draw_rect(developer_panel_rect, Color("ffad62"), false, 1.5)
|
draw_rect(developer_panel_rect, Color("ffad62"), false, 1.5)
|
||||||
draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 490, 11, Color("ffc277"))
|
draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 620, 11, Color("ffc277"))
|
||||||
_draw_button(developer_strength_down_button, "−", false, Color("44333a"))
|
_draw_button(developer_strength_down_button, "−", false, Color("44333a"))
|
||||||
draw_string(font, developer_panel_rect.position + Vector2(58, 57), "GEGNER ×%.1f" % enemy_strength_multiplier, HORIZONTAL_ALIGNMENT_CENTER, 128, 13, Color.WHITE)
|
draw_string(font, developer_panel_rect.position + Vector2(58, 57), "GEGNER ×%.1f" % enemy_strength_multiplier, HORIZONTAL_ALIGNMENT_CENTER, 128, 13, Color.WHITE)
|
||||||
_draw_button(developer_strength_up_button, "+", false, Color("44333a"))
|
_draw_button(developer_strength_up_button, "+", false, Color("44333a"))
|
||||||
_draw_button(developer_crystals_down_button, "−25", false, Color("44333a"))
|
_draw_button(developer_crystals_down_button, "−25", false, Color("44333a"))
|
||||||
draw_string(font, developer_panel_rect.position + Vector2(310, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff"))
|
draw_string(font, developer_panel_rect.position + Vector2(298, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff"))
|
||||||
_draw_button(developer_crystals_up_button, "+25", false, Color("44333a"))
|
_draw_button(developer_crystals_up_button, "+25", false, Color("44333a"))
|
||||||
|
_draw_button(developer_energy_down_button, "−500", false, Color("44333a"))
|
||||||
|
_draw_button(developer_energy_up_button, "+500", false, Color("44333a"))
|
||||||
|
draw_string(font, Vector2(1102, 195), "ENERGIE %04d" % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_CENTER, 230, 11, Color("8ff4ff"))
|
||||||
|
_draw_button(developer_admin_tower_button, "ADMIN-TURM BAUEN", false, Color("5c4d2e" if (not coop_online_mode or developer_server_authorized) else "34404b"))
|
||||||
if status_timer > 0.0:
|
if status_timer > 0.0:
|
||||||
draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true)
|
draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true)
|
||||||
draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff"))
|
draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff"))
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_developer_password_prompt() -> void:
|
||||||
|
var font := ThemeDB.fallback_font
|
||||||
|
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.76), true)
|
||||||
|
draw_rect(developer_password_dialog_rect, Color("071725"), true)
|
||||||
|
draw_rect(developer_password_dialog_rect, Color("ffad62"), false, 2.0)
|
||||||
|
draw_string(font, Vector2(developer_password_dialog_rect.position.x, 375), "ENTWICKLERMODUS", HORIZONTAL_ALIGNMENT_CENTER, developer_password_dialog_rect.size.x, 25, Color("ffc277"))
|
||||||
|
draw_string(font, Vector2(developer_password_dialog_rect.position.x + 28, 399), "Passwort einmal für diese Runde eingeben", HORIZONTAL_ALIGNMENT_CENTER, developer_password_dialog_rect.size.x - 56, 13, Color("b8d4df"))
|
||||||
|
draw_rect(developer_password_input_rect, Color("4ab8d1"), false, 1.0)
|
||||||
|
_draw_button(developer_password_cancel_button, "ABBRECHEN", false, Color("26394a"))
|
||||||
|
|
||||||
|
|
||||||
func _draw_wave_preview_panel(rect: Rect2, heading: String, for_wave: int) -> void:
|
func _draw_wave_preview_panel(rect: Rect2, heading: String, for_wave: int) -> void:
|
||||||
var font := ThemeDB.fallback_font
|
var font := ThemeDB.fallback_font
|
||||||
var summary := _wave_preview_summary(for_wave)
|
var summary := _wave_preview_summary(for_wave)
|
||||||
@@ -3088,28 +3337,42 @@ func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55
|
|||||||
|
|
||||||
|
|
||||||
func _draw_menu() -> void:
|
func _draw_menu() -> void:
|
||||||
|
if _is_mobile_menu_layout():
|
||||||
|
_draw_mobile_menu()
|
||||||
|
return
|
||||||
var font := ThemeDB.fallback_font
|
var font := ThemeDB.fallback_font
|
||||||
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52))
|
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.66), true)
|
||||||
draw_string(font, Vector2(0, 145), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("c7f5ff"))
|
draw_string(font, Vector2(2, 147), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color(0.0, 0.0, 0.0, 0.62))
|
||||||
draw_string(font, Vector2(0, 202), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color("56cce9"))
|
draw_string(font, Vector2(0, 143), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("d9f9ff"))
|
||||||
|
draw_string(font, Vector2(0, 200), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 21, Color("56cce9"))
|
||||||
if not pending_match_invite.is_empty():
|
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(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"))
|
_draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57"))
|
||||||
if menu_step == "map":
|
if menu_step == "map":
|
||||||
draw_string(font, Vector2(0, 300), _ui("EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff"))
|
draw_rect(Rect2(34, 252, 1532, 414), Color(0.004, 0.018, 0.032, 0.32), true)
|
||||||
|
draw_rect(Rect2(34, 252, 1532, 414), Color(0.18, 0.55, 0.67, 0.42), false, 1.0)
|
||||||
|
draw_string(font, Vector2(0, 280), _ui("EINSATZSEKTOREN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("84eaff"))
|
||||||
|
draw_string(font, Vector2(0, 301), _ui("KARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("98b8c7"))
|
||||||
for index in map_buttons.size():
|
for index in map_buttons.size():
|
||||||
var card: Rect2 = map_buttons[index]
|
var card: Rect2 = map_buttons[index]
|
||||||
var map_index := index
|
var map_index := index
|
||||||
var is_unlocked := unlocked_maps.has(map_index)
|
var is_unlocked := unlocked_maps.has(map_index)
|
||||||
draw_texture_rect(MAP_TEXTURES[map_index], card, false, Color(0.82, 0.88, 0.94, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0))
|
var is_selected := selected_map == map_index and is_unlocked
|
||||||
draw_rect(card, Color(0.005, 0.015, 0.035, 0.22 if is_unlocked else 0.48), true)
|
draw_rect(card.grow(4.0), Color(0.0, 0.0, 0.0, 0.4), true)
|
||||||
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)
|
draw_texture_rect(MAP_TEXTURES[map_index], card, false, Color(0.96, 0.99, 1.0, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0))
|
||||||
var footer_y := card.size.y - 52.0
|
draw_rect(card, Color(0.005, 0.015, 0.035, 0.08 if is_unlocked else 0.38), true)
|
||||||
draw_rect(Rect2(card.position + Vector2(0, footer_y), Vector2(card.size.x, 52)), Color(0.01, 0.025, 0.05, 0.88), true)
|
var footer := Rect2(card.position.x, card.end.y - 54.0, card.size.x, 54.0)
|
||||||
draw_string(font, card.position + Vector2(0, footer_y + 21), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 14, Color.WHITE)
|
draw_rect(footer, Color(0.005, 0.018, 0.032, 0.9), true)
|
||||||
|
draw_rect(card, Color("70eaff") if is_selected else (Color("d99d55") if not is_unlocked else Color("345769")), false, 3.0 if is_selected else 1.5)
|
||||||
|
var badge_text := _ui("AUSGEWÄHLT") if is_selected else (_ui("GESPERRT") if not is_unlocked else "")
|
||||||
|
if not badge_text.is_empty():
|
||||||
|
var badge := Rect2(card.position + Vector2(10, 10), Vector2(116, 25))
|
||||||
|
draw_rect(badge, Color("116c83", 0.96) if is_selected else Color("80562d", 0.96), true)
|
||||||
|
draw_string(font, badge.position + Vector2(0, 17), badge_text, HORIZONTAL_ALIGNMENT_CENTER, badge.size.x, 10, Color.WHITE)
|
||||||
|
draw_string(font, card.position + Vector2(0, card.size.y - 33), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 16, Color.WHITE)
|
||||||
var description: String = _ui(MAP_DESCRIPTIONS[map_index]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[map_index])
|
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, card.position + Vector2(0, card.size.y - 11), description, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 12, Color("8fddef") if is_unlocked else Color("ffcf7a"))
|
||||||
draw_string(font, Vector2(0, 720), _ui("Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("d7e2ea"))
|
draw_string(font, Vector2(0, 698), _ui("Wähle einen Sektor. Danach legst du Schwierigkeit und Missionsstart fest."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d7e2ea"))
|
||||||
else:
|
else:
|
||||||
_draw_button(map_step_back_button, "< KARTE", false, Color("26394a"))
|
_draw_button(map_step_back_button, "< KARTE", false, Color("26394a"))
|
||||||
var selected_card := Rect2(500, 315, 600, 145)
|
var selected_card := Rect2(500, 315, 600, 145)
|
||||||
@@ -3125,13 +3388,154 @@ func _draw_menu() -> void:
|
|||||||
draw_string(font, Vector2(0, 603), _ui(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_button(start_button, "MISSION STARTEN", false, Color("18556f"))
|
||||||
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_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_line(Vector2(40, 744), Vector2(1560, 744), Color("448094", 0.68), 1.0)
|
||||||
|
draw_string(font, Vector2(40, 761), _ui("KOMMUNITY"), HORIZONTAL_ALIGNMENT_CENTER, 500, 10, Color("7fa8b8"))
|
||||||
|
draw_string(font, Vector2(550, 761), _ui("HANGAR & KONTO"), HORIZONTAL_ALIGNMENT_CENTER, 755, 10, Color("7fa8b8"))
|
||||||
|
draw_string(font, Vector2(1315, 761), _ui("SYSTEM"), HORIZONTAL_ALIGNMENT_CENTER, 245, 10, Color("7fa8b8"))
|
||||||
_draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b"))
|
_draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b"))
|
||||||
_draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75"))
|
_draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75"))
|
||||||
_draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a"))
|
_draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a"))
|
||||||
_draw_button(audio_settings_button, "EINSTELLUNGEN", 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(promo_code_button, "CODE EINLÖSEN", false, Color("34576b"))
|
||||||
_draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342"))
|
_draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342"))
|
||||||
draw_string(font, Vector2(0, 860), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293"))
|
draw_string(font, Vector2(0, 867), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, 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 _is_mobile_menu_layout() -> bool:
|
||||||
|
if OS.has_feature("mobile") or OS.get_cmdline_user_args().has("--mobile-menu-preview"):
|
||||||
|
return true
|
||||||
|
var viewport_size := get_viewport_rect().size
|
||||||
|
return viewport_size.x < 1180.0 or viewport_size.aspect() < 1.15
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_map_card_rect() -> Rect2:
|
||||||
|
return Rect2(240, 154, 1120, 300)
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_map_previous_rect() -> Rect2:
|
||||||
|
return Rect2(148, 268, 72, 72)
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_map_next_rect() -> Rect2:
|
||||||
|
return Rect2(1380, 268, 72, 72)
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_invite_rect() -> Rect2:
|
||||||
|
return Rect2(470, 112, 660, 42)
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_difficulty_rect(index: int) -> Rect2:
|
||||||
|
return Rect2(330 + index * 315, 535, 300, 62)
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_start_rect() -> Rect2:
|
||||||
|
return Rect2(350, 645, 900, 76)
|
||||||
|
|
||||||
|
|
||||||
|
func _mobile_nav_rect(index: int) -> Rect2:
|
||||||
|
return Rect2(50 + (index % 3) * 500, 758 + floori(float(index) / 3.0) * 62, 470, 54)
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_mobile_menu_pointer(position: Vector2) -> void:
|
||||||
|
if not pending_match_invite.is_empty() and _mobile_invite_rect().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
|
||||||
|
if update_notice_visible and update_dismiss_button.has_point(position):
|
||||||
|
update_notice_visible = false
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
if profile_button.has_point(position):
|
||||||
|
game_mode = "profile"
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
for index in range(6):
|
||||||
|
if not _mobile_nav_rect(index).has_point(position):
|
||||||
|
continue
|
||||||
|
match index:
|
||||||
|
0:
|
||||||
|
_open_leaderboard()
|
||||||
|
1:
|
||||||
|
game_mode = "multiplayer_menu"
|
||||||
|
2:
|
||||||
|
store_return_mode = "menu"
|
||||||
|
store_tab = "towers"
|
||||||
|
game_mode = "store"
|
||||||
|
3:
|
||||||
|
audio_settings_open = true
|
||||||
|
4:
|
||||||
|
game_mode = "promo_code"
|
||||||
|
promo_code_message = ""
|
||||||
|
promo_code_line_edit.show()
|
||||||
|
promo_code_line_edit.grab_focus()
|
||||||
|
5:
|
||||||
|
get_tree().quit()
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
if _mobile_map_previous_rect().has_point(position):
|
||||||
|
_select_map(wrapi(selected_map - 1, 0, MAP_NAMES.size()))
|
||||||
|
menu_step = "map"
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
if _mobile_map_next_rect().has_point(position):
|
||||||
|
_select_map(wrapi(selected_map + 1, 0, MAP_NAMES.size()))
|
||||||
|
menu_step = "map"
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
for index in difficulty_buttons.size():
|
||||||
|
if _mobile_difficulty_rect(index).has_point(position):
|
||||||
|
selected_difficulty = index
|
||||||
|
_save_progress()
|
||||||
|
queue_redraw()
|
||||||
|
return
|
||||||
|
if _mobile_start_rect().has_point(position):
|
||||||
|
if not unlocked_maps.has(selected_map):
|
||||||
|
store_return_mode = "menu"
|
||||||
|
store_tab = "maps"
|
||||||
|
game_mode = "store"
|
||||||
|
queue_redraw()
|
||||||
|
else:
|
||||||
|
_start_game()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_mobile_menu() -> void:
|
||||||
|
var font := ThemeDB.fallback_font
|
||||||
|
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52), true)
|
||||||
|
draw_string(font, Vector2(0, 74), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff"))
|
||||||
|
draw_string(font, Vector2(0, 108), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("56cce9"))
|
||||||
|
_draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57"))
|
||||||
|
var card := _mobile_map_card_rect()
|
||||||
|
var is_unlocked := unlocked_maps.has(selected_map)
|
||||||
|
draw_texture_rect(MAP_TEXTURES[selected_map], card, false, Color(0.82, 0.88, 0.94, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0))
|
||||||
|
draw_rect(card, Color(0.005, 0.015, 0.035, 0.18 if is_unlocked else 0.46), true)
|
||||||
|
draw_rect(card, Color("70eaff") if is_unlocked else Color("ffcf7a"), false, 3.0)
|
||||||
|
draw_rect(Rect2(card.position.x, card.end.y - 84, card.size.x, 84), Color(0.01, 0.025, 0.05, 0.9), true)
|
||||||
|
if not pending_match_invite.is_empty():
|
||||||
|
_draw_button(_mobile_invite_rect(), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b"))
|
||||||
|
draw_string(font, Vector2(card.position.x, card.end.y - 47), _ui(MAP_NAMES[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 24, Color.WHITE)
|
||||||
|
var map_caption := _ui(MAP_DESCRIPTIONS[selected_map]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[selected_map])
|
||||||
|
draw_string(font, Vector2(card.position.x, card.end.y - 18), map_caption, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 16, Color("8fddef") if is_unlocked else Color("ffcf7a"))
|
||||||
|
_draw_button(_mobile_map_previous_rect(), "◀", false, Color("203c50"))
|
||||||
|
_draw_button(_mobile_map_next_rect(), "▶", false, Color("203c50"))
|
||||||
|
draw_string(font, Vector2(0, 480), _ui("KARTE %02d VON %02d") % [selected_map + 1, MAP_NAMES.size()], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("9abac7"))
|
||||||
|
draw_string(font, Vector2(0, 519), _ui("SCHWIERIGKEIT WÄHLEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("9fdce9"))
|
||||||
|
for index in difficulty_buttons.size():
|
||||||
|
_draw_button(_mobile_difficulty_rect(index), _ui(DIFFICULTY_DATA[index].name), selected_difficulty == index, Color("27394d"))
|
||||||
|
draw_string(font, Vector2(0, 624), _ui(DIFFICULTY_DESCRIPTIONS[selected_difficulty]), HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("ffcf7a"))
|
||||||
|
_draw_button(_mobile_start_rect(), _ui("MISSION STARTEN") if is_unlocked else _ui("KARTE IM SHOP FREISCHALTEN"), false, Color("18556f"))
|
||||||
|
draw_string(font, Vector2(0, 741), _ui("KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d") % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("86c8d8"))
|
||||||
|
var nav_texts := ["BESTENLISTE", "MULTIPLAYER", "ARSENAL / SHOP", "EINSTELLUNGEN", "CODE EINLÖSEN", "SPIEL BEENDEN"]
|
||||||
|
var nav_colors := [Color("24475b"), Color("254f75"), Color("24516a"), Color("24516a"), Color("34576b"), Color("5c3342")]
|
||||||
|
for index in nav_texts.size():
|
||||||
|
_draw_button(_mobile_nav_rect(index), _ui(nav_texts[index]), false, nav_colors[index])
|
||||||
|
draw_string(font, Vector2(0, 892), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 11, Color("688293"))
|
||||||
if update_notice_visible:
|
if update_notice_visible:
|
||||||
_draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d"))
|
_draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d"))
|
||||||
_draw_button(update_dismiss_button, "×", false, Color("3d4650"))
|
_draw_button(update_dismiss_button, "×", false, Color("3d4650"))
|
||||||
@@ -3253,13 +3657,13 @@ func _draw_friends() -> void:
|
|||||||
_draw_button(friends_back_button, "< ZURÜCK", false, Color("26394a"))
|
_draw_button(friends_back_button, "< ZURÜCK", false, Color("26394a"))
|
||||||
_draw_button(friends_refresh_button, "AKTUALISIEREN", false, Color("24516a"))
|
_draw_button(friends_refresh_button, "AKTUALISIEREN", false, Color("24516a"))
|
||||||
draw_string(font, Vector2(0, 105), _ui("FREUNDESLISTE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 40, Color("c7f5ff"))
|
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"
|
var own_name: String = account_manager.display_name if account_manager != null else "OFFLINE"
|
||||||
draw_string(font, Vector2(260, 169), _ui("DEINE SPIELERKENNUNG: %s") % own_username, HORIZONTAL_ALIGNMENT_LEFT, 740, 18, Color("8ff4ff"))
|
draw_string(font, Vector2(260, 169), _ui("DEIN SPIELERNAME: %s") % own_name, HORIZONTAL_ALIGNMENT_LEFT, 740, 18, Color("8ff4ff"))
|
||||||
_draw_button(friend_copy_button, "KENNUNG KOPIEREN", false, Color("285568"))
|
_draw_button(friend_copy_button, "NAMEN 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(0.01, 0.035, 0.065, 0.98), true)
|
||||||
draw_rect(friend_name_rect, Color("4ab8d1"), false, 2.0)
|
draw_rect(friend_name_rect, Color("4ab8d1"), false, 2.0)
|
||||||
_draw_button(friend_add_button, "ANFRAGE SENDEN", false, Color("185f68"))
|
_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_string(font, Vector2(260, 292), _ui("Füge Freunde über ihren Spielernamen 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(0.01, 0.035, 0.065, 0.96), true)
|
||||||
draw_rect(Rect2(220, 315, 1160, 455), Color("39788e"), false, 2.0)
|
draw_rect(Rect2(220, 315, 1160, 455), Color("39788e"), false, 2.0)
|
||||||
friend_action_buttons.clear()
|
friend_action_buttons.clear()
|
||||||
@@ -3541,8 +3945,8 @@ func _draw_tutorial() -> void:
|
|||||||
_draw_button(tutorial_skip_button, "ÜBERSPRINGEN", false, Color("24394c"))
|
_draw_button(tutorial_skip_button, "ÜBERSPRINGEN", false, Color("24394c"))
|
||||||
|
|
||||||
|
|
||||||
func _is_valid_build_position(position: Vector2, owner := 0) -> bool:
|
func _is_valid_build_position(position: Vector2, owner := 0, allow_any_coop_side := false) -> bool:
|
||||||
var correct_coop_side := true
|
var correct_coop_side := true
|
||||||
if coop_mode:
|
if coop_mode and not allow_any_coop_side:
|
||||||
correct_coop_side = position.x < COOP_MIRROR_X - GRID_SIZE * 0.5 if owner == 0 else position.x > COOP_MIRROR_X + GRID_SIZE * 0.5
|
correct_coop_side = position.x < COOP_MIRROR_X - GRID_SIZE * 0.5 if owner == 0 else position.x > COOP_MIRROR_X + GRID_SIZE * 0.5
|
||||||
return correct_coop_side and _is_in_build_zone(position) and not _is_in_reactor_zone(position) and not _is_path_cell(position) and position.x >= GRID_SIZE * 0.5 and position.x <= _active_world_width() - GRID_SIZE * 0.5 and position.y >= GRID_SIZE * 0.5 and position.y <= WORLD_SIZE.y - GRID_SIZE * 0.5 and _tower_at(position, GRID_SIZE * 0.72) < 0
|
return correct_coop_side and _is_in_build_zone(position) and not _is_in_reactor_zone(position) and not _is_path_cell(position) and position.x >= GRID_SIZE * 0.5 and position.x <= _active_world_width() - GRID_SIZE * 0.5 and position.y >= GRID_SIZE * 0.5 and position.y <= WORLD_SIZE.y - GRID_SIZE * 0.5 and _tower_at(position, GRID_SIZE * 0.72) < 0
|
||||||
+288
-25
@@ -48,10 +48,33 @@ var snapshot_bytes_max := 0
|
|||||||
var snapshot_parse_max_us := 0
|
var snapshot_parse_max_us := 0
|
||||||
var snapshot_large_gap_count := 0
|
var snapshot_large_gap_count := 0
|
||||||
var snapshot_coalesced_count := 0
|
var snapshot_coalesced_count := 0
|
||||||
|
var snapshot_queue_dropped_count := 0
|
||||||
|
var snapshot_queue_max := 0
|
||||||
|
var snapshot_bytes_total := 0
|
||||||
|
var snapshot_tick_previous := -1
|
||||||
|
var snapshot_tick_jump_max := 0
|
||||||
|
var snapshot_tick_jumps := 0
|
||||||
|
var snapshot_gap_samples: Array[int] = []
|
||||||
|
var diagnostic_file: FileAccess
|
||||||
|
var diagnostic_path := ""
|
||||||
|
var diagnostic_match_key := ""
|
||||||
|
var diagnostic_match_id := ""
|
||||||
|
var diagnostic_finished_match_key := ""
|
||||||
|
var diagnostic_open_failed := false
|
||||||
|
var diagnostic_slot := -1
|
||||||
|
var diagnostic_frame_max_ms := 0
|
||||||
|
var diagnostic_frame_stalls := 0
|
||||||
|
var diagnostic_apply_max_us := 0
|
||||||
|
var diagnostic_enemy_count := 0
|
||||||
|
var diagnostic_tower_count := 0
|
||||||
|
var diagnostic_last_summary := ""
|
||||||
|
var diagnostic_records: Array[Dictionary] = []
|
||||||
var pending_snapshot_payloads: Array[String] = []
|
var pending_snapshot_payloads: Array[String] = []
|
||||||
var friends: Array[Dictionary] = []
|
var friends: Array[Dictionary] = []
|
||||||
var friend_statuses: Dictionary = {}
|
var friend_statuses: Dictionary = {}
|
||||||
var presence_status := ""
|
var presence_status := ""
|
||||||
|
var player_event_flush_busy := false
|
||||||
|
var player_event_flush_again := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -63,6 +86,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
_flush_pending_snapshots()
|
_flush_pending_snapshots()
|
||||||
|
if diagnostic_file != null and Time.get_ticks_msec() - snapshot_report_ms >= 10000:
|
||||||
|
_report_diagnostic_window()
|
||||||
|
|
||||||
|
|
||||||
func _flush_pending_snapshots() -> void:
|
func _flush_pending_snapshots() -> void:
|
||||||
@@ -78,6 +103,16 @@ func _flush_pending_snapshots() -> void:
|
|||||||
snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us)
|
snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us)
|
||||||
if not parsed is Dictionary:
|
if not parsed is Dictionary:
|
||||||
continue
|
continue
|
||||||
|
var server_tick := int(parsed.get("tick", -1))
|
||||||
|
if server_tick >= 0:
|
||||||
|
if snapshot_tick_previous >= 0:
|
||||||
|
var tick_jump := server_tick - snapshot_tick_previous
|
||||||
|
snapshot_tick_jump_max = maxi(snapshot_tick_jump_max, tick_jump)
|
||||||
|
if tick_jump > 1:
|
||||||
|
snapshot_tick_jumps += 1
|
||||||
|
if tick_jump > 3:
|
||||||
|
_write_diagnostic_record("tick_jump", {"from_tick": snapshot_tick_previous, "to_tick": server_tick, "steps": tick_jump})
|
||||||
|
snapshot_tick_previous = server_tick
|
||||||
latest = parsed
|
latest = parsed
|
||||||
var shots = parsed.get("shots", [])
|
var shots = parsed.get("shots", [])
|
||||||
if shots is Array:
|
if shots is Array:
|
||||||
@@ -90,6 +125,179 @@ func _flush_pending_snapshots() -> void:
|
|||||||
match_snapshot_received.emit(latest)
|
match_snapshot_received.emit(latest)
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_match_diagnostics() -> void:
|
||||||
|
if current_match == null:
|
||||||
|
return
|
||||||
|
var match_key := String(current_match.match_id).sha256_text().substr(0, 12)
|
||||||
|
if match_key == diagnostic_finished_match_key:
|
||||||
|
return
|
||||||
|
if diagnostic_match_key == match_key and (diagnostic_file != null or diagnostic_open_failed):
|
||||||
|
return
|
||||||
|
if diagnostic_file != null:
|
||||||
|
_finish_match_diagnostics("new_match")
|
||||||
|
diagnostic_match_key = match_key
|
||||||
|
diagnostic_match_id = String(current_match.match_id)
|
||||||
|
diagnostic_finished_match_key = ""
|
||||||
|
diagnostic_open_failed = false
|
||||||
|
diagnostic_slot = -1
|
||||||
|
diagnostic_records.clear()
|
||||||
|
snapshot_previous_ms = 0
|
||||||
|
snapshot_report_ms = Time.get_ticks_msec()
|
||||||
|
snapshot_tick_previous = -1
|
||||||
|
_reset_diagnostic_window()
|
||||||
|
var stamp := Time.get_datetime_string_from_system(true).replace(":", "-")
|
||||||
|
diagnostic_path = "user://online-diag-%s-%s-%d.jsonl" % [stamp, match_key, Time.get_ticks_usec()]
|
||||||
|
diagnostic_file = FileAccess.open(diagnostic_path, FileAccess.WRITE)
|
||||||
|
if diagnostic_file == null:
|
||||||
|
diagnostic_open_failed = true
|
||||||
|
push_warning("Online-Diagnose konnte nicht gespeichert werden: %s" % diagnostic_path)
|
||||||
|
return
|
||||||
|
_write_diagnostic_record("start", {
|
||||||
|
"match": match_key,
|
||||||
|
"host": current_host,
|
||||||
|
"version": str(ProjectSettings.get_setting("application/config/version", "")),
|
||||||
|
"transport": "nakama_websocket",
|
||||||
|
"expected_snapshots_per_second": 10
|
||||||
|
})
|
||||||
|
print("ONLINE DIAG file=%s" % ProjectSettings.globalize_path(diagnostic_path))
|
||||||
|
|
||||||
|
|
||||||
|
func set_diagnostic_slot(slot: int, viewing_right: bool) -> void:
|
||||||
|
if diagnostic_slot == slot:
|
||||||
|
return
|
||||||
|
diagnostic_slot = slot
|
||||||
|
_write_diagnostic_record("side", {"slot": slot, "viewing_right": viewing_right})
|
||||||
|
|
||||||
|
|
||||||
|
func record_online_frame(delta: float) -> void:
|
||||||
|
if diagnostic_file == null:
|
||||||
|
return
|
||||||
|
var frame_ms := roundi(delta * 1000.0)
|
||||||
|
diagnostic_frame_max_ms = maxi(diagnostic_frame_max_ms, frame_ms)
|
||||||
|
if frame_ms > 200:
|
||||||
|
diagnostic_frame_stalls += 1
|
||||||
|
if frame_ms > 1000:
|
||||||
|
_write_diagnostic_record("frame_stall", {"duration_ms": frame_ms, "last_server_tick": snapshot_tick_previous})
|
||||||
|
|
||||||
|
|
||||||
|
func record_snapshot_apply(duration_us: int, enemy_count: int, tower_count: int) -> void:
|
||||||
|
diagnostic_apply_max_us = maxi(diagnostic_apply_max_us, duration_us)
|
||||||
|
diagnostic_enemy_count = enemy_count
|
||||||
|
diagnostic_tower_count = tower_count
|
||||||
|
|
||||||
|
|
||||||
|
func _write_diagnostic_record(event_name: String, details: Dictionary) -> void:
|
||||||
|
var record := details.duplicate(true)
|
||||||
|
record["event"] = event_name
|
||||||
|
record["utc"] = Time.get_datetime_string_from_system(true)
|
||||||
|
record["slot"] = diagnostic_slot
|
||||||
|
if event_name != "start":
|
||||||
|
diagnostic_records.append(record)
|
||||||
|
if diagnostic_records.size() > 160:
|
||||||
|
diagnostic_records.pop_front()
|
||||||
|
if diagnostic_file == null:
|
||||||
|
return
|
||||||
|
diagnostic_file.store_line(JSON.stringify(record))
|
||||||
|
diagnostic_file.flush()
|
||||||
|
|
||||||
|
|
||||||
|
func _report_diagnostic_window() -> void:
|
||||||
|
var now_ms := Time.get_ticks_msec()
|
||||||
|
var sorted_gaps := snapshot_gap_samples.duplicate()
|
||||||
|
sorted_gaps.sort()
|
||||||
|
var p95_gap := 0
|
||||||
|
if not sorted_gaps.is_empty():
|
||||||
|
p95_gap = sorted_gaps[clampi(int(ceil(float(sorted_gaps.size()) * 0.95)) - 1, 0, sorted_gaps.size() - 1)]
|
||||||
|
var seconds := maxf(0.001, float(now_ms - snapshot_report_ms) / 1000.0)
|
||||||
|
var record := {
|
||||||
|
"seconds": snappedf(seconds, 0.001),
|
||||||
|
"snapshots": snapshot_count,
|
||||||
|
"snapshots_per_second": snappedf(float(snapshot_count) / seconds, 0.01),
|
||||||
|
"gap_p95_ms": p95_gap,
|
||||||
|
"gap_max_ms": snapshot_gap_max_ms,
|
||||||
|
"gaps_over_500_ms": snapshot_large_gap_count,
|
||||||
|
"last_server_tick": snapshot_tick_previous,
|
||||||
|
"tick_jump_max": snapshot_tick_jump_max,
|
||||||
|
"tick_jumps_over_1": snapshot_tick_jumps,
|
||||||
|
"bytes_avg": snapshot_bytes_total / maxi(1, snapshot_count),
|
||||||
|
"bytes_max": snapshot_bytes_max,
|
||||||
|
"parse_max_us": snapshot_parse_max_us,
|
||||||
|
"apply_max_us": diagnostic_apply_max_us,
|
||||||
|
"frame_max_ms": diagnostic_frame_max_ms,
|
||||||
|
"frame_stalls_over_200_ms": diagnostic_frame_stalls,
|
||||||
|
"coalesced": snapshot_coalesced_count,
|
||||||
|
"queue_dropped": snapshot_queue_dropped_count,
|
||||||
|
"queue_max": snapshot_queue_max,
|
||||||
|
"enemies": diagnostic_enemy_count,
|
||||||
|
"towers": diagnostic_tower_count
|
||||||
|
}
|
||||||
|
diagnostic_last_summary = JSON.stringify(record)
|
||||||
|
_write_diagnostic_record("window", record)
|
||||||
|
print("ONLINE DIAG %s" % diagnostic_last_summary)
|
||||||
|
snapshot_report_ms = now_ms
|
||||||
|
_reset_diagnostic_window()
|
||||||
|
|
||||||
|
|
||||||
|
func _reset_diagnostic_window() -> void:
|
||||||
|
snapshot_count = 0
|
||||||
|
snapshot_gap_max_ms = 0
|
||||||
|
snapshot_bytes_max = 0
|
||||||
|
snapshot_parse_max_us = 0
|
||||||
|
snapshot_large_gap_count = 0
|
||||||
|
snapshot_coalesced_count = 0
|
||||||
|
snapshot_queue_dropped_count = 0
|
||||||
|
snapshot_queue_max = 0
|
||||||
|
snapshot_bytes_total = 0
|
||||||
|
snapshot_tick_jump_max = 0
|
||||||
|
snapshot_tick_jumps = 0
|
||||||
|
snapshot_gap_samples.clear()
|
||||||
|
diagnostic_frame_max_ms = 0
|
||||||
|
diagnostic_frame_stalls = 0
|
||||||
|
diagnostic_apply_max_us = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _finish_match_diagnostics(reason: String) -> void:
|
||||||
|
if diagnostic_match_key.is_empty():
|
||||||
|
return
|
||||||
|
if snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000:
|
||||||
|
_report_diagnostic_window()
|
||||||
|
_write_diagnostic_record("end", {"reason": reason})
|
||||||
|
var match_id := diagnostic_match_id
|
||||||
|
if not match_id.is_empty() and is_authenticated() and not diagnostic_records.is_empty():
|
||||||
|
var report := {"match_id": match_id, "version": str(ProjectSettings.get_setting("application/config/version", "development")),
|
||||||
|
"records": diagnostic_records.duplicate(true)}
|
||||||
|
while JSON.stringify(report).to_utf8_buffer().size() > 60000 and report["records"].size() > 1:
|
||||||
|
report["records"].pop_front()
|
||||||
|
_upload_match_diagnostics.call_deferred(report)
|
||||||
|
diagnostic_file = null
|
||||||
|
diagnostic_finished_match_key = diagnostic_match_key
|
||||||
|
diagnostic_match_key = ""
|
||||||
|
diagnostic_match_id = ""
|
||||||
|
diagnostic_open_failed = false
|
||||||
|
diagnostic_records.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _upload_match_diagnostics(report: Dictionary) -> void:
|
||||||
|
for attempt in 3:
|
||||||
|
if not is_authenticated():
|
||||||
|
break
|
||||||
|
var result = await client.rpc_async(session, "submit_online_diagnostics", JSON.stringify(report))
|
||||||
|
var parsed := _parse_rpc_result(result)
|
||||||
|
if bool(parsed.get("accepted", false)):
|
||||||
|
print("ONLINE DIAG hochgeladen: Runde %s, Seite %d" % [String(parsed.get("round_id", "")), int(parsed.get("slot", -1))])
|
||||||
|
return
|
||||||
|
await get_tree().create_timer(2.0 * (attempt + 1)).timeout
|
||||||
|
push_warning("Online-Diagnose konnte nicht hochgeladen werden; lokale Datei bleibt erhalten.")
|
||||||
|
|
||||||
|
|
||||||
|
func get_diagnostic_bundle() -> String:
|
||||||
|
if diagnostic_file != null and (snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000):
|
||||||
|
_report_diagnostic_window()
|
||||||
|
if diagnostic_path.is_empty() or not FileAccess.file_exists(diagnostic_path):
|
||||||
|
return "Noch keine Online-Diagnose vorhanden."
|
||||||
|
return "Void Bastion Online-Diagnose\nDatei: %s\n\n%s" % [ProjectSettings.globalize_path(diagnostic_path), FileAccess.get_file_as_string(diagnostic_path)]
|
||||||
|
|
||||||
|
|
||||||
func _is_headless_or_editor() -> bool:
|
func _is_headless_or_editor() -> bool:
|
||||||
return DisplayServer.get_name() == "headless" or Engine.is_editor_hint()
|
return DisplayServer.get_name() == "headless" or Engine.is_editor_hint()
|
||||||
|
|
||||||
@@ -143,6 +351,7 @@ func _after_authentication() -> void:
|
|||||||
var parent_game = get_parent()
|
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 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)
|
authenticated.emit(session.user_id, session.username)
|
||||||
|
await flush_pending_player_events()
|
||||||
await flush_pending_scores()
|
await flush_pending_scores()
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +365,44 @@ func _record_activity() -> void:
|
|||||||
await client.rpc_async(session, "record_activity", JSON.stringify(payload))
|
await client.rpc_async(session, "record_activity", JSON.stringify(payload))
|
||||||
|
|
||||||
|
|
||||||
|
func record_local_play_start() -> void:
|
||||||
|
_queue_player_event("play_start")
|
||||||
|
|
||||||
|
|
||||||
|
func record_local_tower_build(tower_type: int) -> void:
|
||||||
|
if tower_type < 0 or tower_type >= 7:
|
||||||
|
return
|
||||||
|
_queue_player_event("tower_build", tower_type)
|
||||||
|
|
||||||
|
|
||||||
|
func _queue_player_event(event_name: String, tower_type := -1) -> void:
|
||||||
|
if not account_manager:
|
||||||
|
return
|
||||||
|
account_manager.queue_player_event(event_name, tower_type)
|
||||||
|
if is_authenticated():
|
||||||
|
flush_pending_player_events()
|
||||||
|
|
||||||
|
|
||||||
|
func flush_pending_player_events() -> void:
|
||||||
|
if player_event_flush_busy:
|
||||||
|
player_event_flush_again = true
|
||||||
|
return
|
||||||
|
if not account_manager or not is_authenticated():
|
||||||
|
return
|
||||||
|
player_event_flush_busy = true
|
||||||
|
while is_authenticated() and not account_manager.pending_player_events.is_empty():
|
||||||
|
var event: Dictionary = account_manager.pending_player_events[0]
|
||||||
|
var result = await client.rpc_async(session, "record_player_event", JSON.stringify(event))
|
||||||
|
var parsed := _parse_rpc_result(result)
|
||||||
|
if not bool(parsed.get("accepted", false)):
|
||||||
|
break
|
||||||
|
account_manager.mark_player_event_complete(int(event.get("sequence", -1)))
|
||||||
|
player_event_flush_busy = false
|
||||||
|
if player_event_flush_again:
|
||||||
|
player_event_flush_again = false
|
||||||
|
call_deferred("flush_pending_player_events")
|
||||||
|
|
||||||
|
|
||||||
func _bind_socket_signals() -> void:
|
func _bind_socket_signals() -> void:
|
||||||
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
||||||
socket.received_match_state.connect(_on_match_state)
|
socket.received_match_state.connect(_on_match_state)
|
||||||
@@ -188,6 +435,13 @@ func server_info() -> Dictionary:
|
|||||||
return _parse_rpc_result(result)
|
return _parse_rpc_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
func fetch_game_balance() -> Dictionary:
|
||||||
|
if not is_authenticated():
|
||||||
|
return {}
|
||||||
|
var result = await client.rpc_async(session, "get_game_balance", "{}")
|
||||||
|
return _parse_rpc_result(result)
|
||||||
|
|
||||||
|
|
||||||
func submit_run(record: Dictionary) -> bool:
|
func submit_run(record: Dictionary) -> bool:
|
||||||
if not is_authenticated():
|
if not is_authenticated():
|
||||||
return false
|
return false
|
||||||
@@ -298,15 +552,22 @@ func refresh_friends() -> bool:
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
func add_friend_by_username(username: String) -> bool:
|
func add_friend_by_display_name(display_name: String) -> Dictionary:
|
||||||
var normalized := username.strip_edges()
|
var normalized := display_name.strip_edges()
|
||||||
if not is_authenticated() or normalized.is_empty() or normalized == session.username:
|
if not is_authenticated() or normalized.is_empty():
|
||||||
return false
|
return {"accepted": false, "error": "offline"}
|
||||||
var result = await client.add_friends_async(session, null, PackedStringArray([normalized]))
|
var lookup_result = await client.rpc_async(session, "find_player_by_name", JSON.stringify({"display_name": normalized}))
|
||||||
|
var found := _parse_rpc_result(lookup_result)
|
||||||
|
if not bool(found.get("found", false)):
|
||||||
|
return {"accepted": false, "error": String(found.get("error", "not_found"))}
|
||||||
|
var user_id := String(found.get("user_id", ""))
|
||||||
|
if user_id.is_empty() or user_id == session.user_id:
|
||||||
|
return {"accepted": false, "error": "self"}
|
||||||
|
var result = await client.add_friends_async(session, PackedStringArray([user_id]))
|
||||||
if result == null or result.is_exception():
|
if result == null or result.is_exception():
|
||||||
return false
|
return {"accepted": false, "error": "send_failed"}
|
||||||
await refresh_friends()
|
await refresh_friends()
|
||||||
return true
|
return {"accepted": true, "display_name": String(found.get("display_name", normalized))}
|
||||||
|
|
||||||
|
|
||||||
func accept_friend(user_id: String) -> bool:
|
func accept_friend(user_id: String) -> bool:
|
||||||
@@ -416,12 +677,14 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
|
|||||||
func cancel_matchmaking() -> void:
|
func cancel_matchmaking() -> void:
|
||||||
intentional_disconnect = true
|
intentional_disconnect = true
|
||||||
pending_snapshot_payloads.clear()
|
pending_snapshot_payloads.clear()
|
||||||
|
_finish_match_diagnostics("cancelled_matchmaking")
|
||||||
|
var match_to_leave = current_match
|
||||||
|
current_match = null
|
||||||
if socket and not matchmaker_ticket.is_empty():
|
if socket and not matchmaker_ticket.is_empty():
|
||||||
await socket.remove_matchmaker_async(matchmaker_ticket)
|
await socket.remove_matchmaker_async(matchmaker_ticket)
|
||||||
matchmaker_ticket = ""
|
matchmaker_ticket = ""
|
||||||
if current_match != null and not private_room_code.is_empty():
|
if socket and match_to_leave != null:
|
||||||
await socket.leave_match_async(current_match.match_id)
|
await socket.leave_match_async(match_to_leave.match_id)
|
||||||
current_match = null
|
|
||||||
private_room_code = ""
|
private_room_code = ""
|
||||||
matchmaking_state_changed.emit("idle")
|
matchmaking_state_changed.emit("idle")
|
||||||
|
|
||||||
@@ -526,9 +789,11 @@ func request_wave_start() -> bool:
|
|||||||
|
|
||||||
func leave_current_match() -> void:
|
func leave_current_match() -> void:
|
||||||
pending_snapshot_payloads.clear()
|
pending_snapshot_payloads.clear()
|
||||||
if socket and current_match != null:
|
_finish_match_diagnostics("left_match")
|
||||||
await socket.leave_match_async(current_match.match_id)
|
var match_to_leave = current_match
|
||||||
current_match = null
|
current_match = null
|
||||||
|
if socket and match_to_leave != null:
|
||||||
|
await socket.leave_match_async(match_to_leave.match_id)
|
||||||
private_room_code = ""
|
private_room_code = ""
|
||||||
lobby_ready = false
|
lobby_ready = false
|
||||||
|
|
||||||
@@ -537,30 +802,25 @@ func _on_match_state(data) -> void:
|
|||||||
if current_match == null or data.match_id != current_match.match_id:
|
if current_match == null or data.match_id != current_match.match_id:
|
||||||
return
|
return
|
||||||
if data.op_code == OP_SNAPSHOT:
|
if data.op_code == OP_SNAPSHOT:
|
||||||
|
_begin_match_diagnostics()
|
||||||
var now_ms := Time.get_ticks_msec()
|
var now_ms := Time.get_ticks_msec()
|
||||||
if snapshot_previous_ms > 0:
|
if snapshot_previous_ms > 0:
|
||||||
var gap_ms := now_ms - snapshot_previous_ms
|
var gap_ms := now_ms - snapshot_previous_ms
|
||||||
snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms)
|
snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms)
|
||||||
|
snapshot_gap_samples.append(gap_ms)
|
||||||
if gap_ms > 500:
|
if gap_ms > 500:
|
||||||
snapshot_large_gap_count += 1
|
snapshot_large_gap_count += 1
|
||||||
|
_write_diagnostic_record("snapshot_gap", {"duration_ms": gap_ms, "last_server_tick": snapshot_tick_previous})
|
||||||
snapshot_previous_ms = now_ms
|
snapshot_previous_ms = now_ms
|
||||||
snapshot_count += 1
|
snapshot_count += 1
|
||||||
snapshot_bytes_max = maxi(snapshot_bytes_max, String(data.data).to_utf8_buffer().size())
|
var payload_bytes := String(data.data).to_utf8_buffer().size()
|
||||||
|
snapshot_bytes_max = maxi(snapshot_bytes_max, payload_bytes)
|
||||||
|
snapshot_bytes_total += payload_bytes
|
||||||
pending_snapshot_payloads.append(String(data.data))
|
pending_snapshot_payloads.append(String(data.data))
|
||||||
|
snapshot_queue_max = maxi(snapshot_queue_max, pending_snapshot_payloads.size())
|
||||||
if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS:
|
if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS:
|
||||||
pending_snapshot_payloads.pop_front()
|
pending_snapshot_payloads.pop_front()
|
||||||
snapshot_coalesced_count += 1
|
snapshot_queue_dropped_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:
|
elif data.op_code == OP_EVENT:
|
||||||
var parsed = JSON.parse_string(data.data)
|
var parsed = JSON.parse_string(data.data)
|
||||||
if parsed is Dictionary:
|
if parsed is Dictionary:
|
||||||
@@ -569,6 +829,7 @@ func _on_match_state(data) -> void:
|
|||||||
|
|
||||||
func _on_socket_closed() -> void:
|
func _on_socket_closed() -> void:
|
||||||
print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null))
|
print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null))
|
||||||
|
_write_diagnostic_record("socket_closed", {"reconnect": not intentional_disconnect and current_match != null})
|
||||||
pending_snapshot_payloads.clear()
|
pending_snapshot_payloads.clear()
|
||||||
presence_status = ""
|
presence_status = ""
|
||||||
friend_statuses.clear()
|
friend_statuses.clear()
|
||||||
@@ -621,6 +882,7 @@ func _recover_match(match_id: String) -> void:
|
|||||||
var rejoined = await socket.join_match_async(match_id)
|
var rejoined = await socket.join_match_async(match_id)
|
||||||
if rejoined != null and not rejoined.is_exception():
|
if rejoined != null and not rejoined.is_exception():
|
||||||
print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1))
|
print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1))
|
||||||
|
_write_diagnostic_record("socket_reconnected", {"attempt": attempt + 1})
|
||||||
current_match = rejoined
|
current_match = rejoined
|
||||||
intentional_disconnect = false
|
intentional_disconnect = false
|
||||||
reconnect_in_progress = false
|
reconnect_in_progress = false
|
||||||
@@ -631,6 +893,7 @@ func _recover_match(match_id: String) -> void:
|
|||||||
return
|
return
|
||||||
reconnect_in_progress = false
|
reconnect_in_progress = false
|
||||||
print("ONLINE DIAG websocket_reconnect_failed")
|
print("ONLINE DIAG websocket_reconnect_failed")
|
||||||
|
_write_diagnostic_record("socket_reconnect_failed", {})
|
||||||
matchmaking_state_changed.emit("connection_lost")
|
matchmaking_state_changed.emit("connection_lost")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ RequestExecutionLevel user
|
|||||||
!include "LogicLib.nsh"
|
!include "LogicLib.nsh"
|
||||||
|
|
||||||
!define PRODUCT_NAME "Void Bastion"
|
!define PRODUCT_NAME "Void Bastion"
|
||||||
!define PRODUCT_VERSION "0.1.0-alpha.10"
|
!define PRODUCT_VERSION "0.1.0-alpha.16"
|
||||||
!define PRODUCT_PUBLISHER "GerGames"
|
!define PRODUCT_PUBLISHER "GerGames"
|
||||||
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
|
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
|
||||||
!define PRODUCT_EXE "Void-Bastion.exe"
|
!define PRODUCT_EXE "Void-Bastion.exe"
|
||||||
@@ -21,7 +21,7 @@ BrandingText "GerGames"
|
|||||||
ShowInstDetails show
|
ShowInstDetails show
|
||||||
ShowUninstDetails show
|
ShowUninstDetails show
|
||||||
|
|
||||||
VIProductVersion "0.1.0.10"
|
VIProductVersion "0.1.0.16"
|
||||||
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
|
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
|
||||||
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
|
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
|
||||||
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
|
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Void Bastion"
|
config/name="Void Bastion"
|
||||||
config/version="0.1.0-alpha.10"
|
config/version="0.1.0-alpha.16"
|
||||||
run/main_scene="res://main.tscn"
|
run/main_scene="res://main.tscn"
|
||||||
config/features=PackedStringArray("4.7")
|
config/features=PackedStringArray("4.7")
|
||||||
config/icon="res://assets/app_icon.png"
|
config/icon="res://assets/app_icon.png"
|
||||||
@@ -34,7 +34,7 @@ window/size/window_width_override=1440
|
|||||||
window/size/window_height_override=810
|
window/size/window_height_override=810
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
window/handheld/orientation=4
|
window/handheld/orientation=0
|
||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,21 @@ Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. V
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
`find_player_by_name` löst einen sichtbaren Spielernamen per exakter, nicht groß-/kleinschreibungsabhängiger Suche in eine interne Benutzer-ID auf. Wenn mehrere Konten denselben Anzeigenamen haben, wird die Anfrage als `ambiguous` abgewiesen, statt versehentlich das falsche Konto hinzuzufügen. Der Client zeigt und kopiert im Freundemenü nur den sichtbaren Namen. Im privaten Warteraum ist für bestätigte Freunde `EINLADEN` verfügbar.
|
||||||
|
|
||||||
|
## Online-Diagnose
|
||||||
|
|
||||||
|
`submit_online_diagnostics` nimmt am Ende einer Online-Partie automatisch technische Berichte der beiden Clients entgegen. Der Server prüft anhand der beim Matchbeitritt gespeicherten Teilnehmerzuordnung, ob das sendende Konto zur Runde gehört. Beide Berichte werden mit derselben 16-stelligen Rundenkennung und unterschiedlichen Seitenzahlen gespeichert. Es werden nur erlaubte numerische Messwerte und vordefinierte Ereignisse übernommen; keine Namen, Nachrichten, IP-Adressen oder Raumcodes. Die Speicherung ist serverprivat und wird nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
|
||||||
|
|
||||||
|
Die Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine kompakte Übersicht. Mit `{"round_id":"<Kennung>"}` liefert sie beide vollständigen Seitenberichte. Der private HTTP-Schlüssel gehört ausschließlich in die Server-/Webhosting-Konfiguration, niemals in den Spielclient. Weitere Auswertungshinweise stehen in `ONLINE_DIAGNOSE.md`.
|
||||||
|
|
||||||
## Private Statistik-RPCs
|
## Private Statistik-RPCs
|
||||||
|
|
||||||
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
|
- `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.
|
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
|
||||||
|
- `get_game_balance`: authentifizierte Spielclients erhalten die freigegebene Balance-Konfiguration; `set_game_balance`: ausschließlich Server-zu-Server mit `NAKAMA_HTTP_KEY` darf sie ändern. Die Werte gelten für neue Online-Partien; `matchInit` lädt einen privaten Snapshot in den Match-Zustand.
|
||||||
- `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.
|
- `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.
|
||||||
|
|
||||||
|
Balancing-Regressionstests: `node tests/runtime_sim_test.js` prüft Runtime- und RPC-Verhalten; `node tests/balance_duel_test.js` spielt reproduzierbare Einzelduelle mit der echten Server-Kampflogik durch; `node tests/balance_mission_test.js` simuliert 15-Wellen-Missionen mit festem Zufallsstart, Turmwahl nach Gegnerzusammensetzung und Level-1-Türmen. Der Missionstest ist ein strenger automatischer Referenz-Bot ohne Upgrades, keine Vorhersage menschlicher Spielergebnisse. Die Werte des Bots sollen als Ausgangspunkt für Balancevergleiche dienen, nicht als alleiniges Freigabekriterium. Alle Tests müssen nach Balanceänderungen erneut laufen.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
var GAME_VERSION = "0.25";
|
var GAME_VERSION = "0.27";
|
||||||
var MAP_COUNT = 7;
|
var MAP_COUNT = 7;
|
||||||
var DIFFICULTY_COUNT = 3;
|
var DIFFICULTY_COUNT = 3;
|
||||||
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
||||||
@@ -6,14 +6,25 @@ var MATCH_MODULE = "void_bastion_coop";
|
|||||||
var ROOM_COLLECTION = "private_rooms";
|
var ROOM_COLLECTION = "private_rooms";
|
||||||
var ACTIVITY_COLLECTION = "player_activity";
|
var ACTIVITY_COLLECTION = "player_activity";
|
||||||
var ACTIVITY_KEY = "summary";
|
var ACTIVITY_KEY = "summary";
|
||||||
|
var INTERNAL_TEST_USERNAME_PATTERN = "^(godot_[ab]_[0-9]+|social_[ab]_[0-9]+|GODOT-[AB]|SYSTEMTEST)$";
|
||||||
var STATS_COLLECTION = "internal_stats";
|
var STATS_COLLECTION = "internal_stats";
|
||||||
var STATS_KEY = "global";
|
var STATS_KEY = "global";
|
||||||
var PROMO_CODE_COLLECTION = "unlock_codes";
|
var PROMO_CODE_COLLECTION = "unlock_codes";
|
||||||
var PROMO_UNLOCK_COLLECTION = "player_unlocks";
|
var PROMO_UNLOCK_COLLECTION = "player_unlocks";
|
||||||
var PROMO_UNLOCK_KEY = "website_codes";
|
var PROMO_UNLOCK_KEY = "website_codes";
|
||||||
var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
|
var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
|
||||||
|
var DIAG_PARTICIPANT_COLLECTION = "online_diag_participants";
|
||||||
|
var DIAG_REPORT_COLLECTION = "online_diag_reports";
|
||||||
|
var DIAG_RETENTION_MS = 14 * 86400000;
|
||||||
|
var BALANCE_COLLECTION = "game_balance";
|
||||||
|
var BALANCE_KEY = "global";
|
||||||
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
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 OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
|
||||||
|
var OP_DEV_AUTH = 15, OP_DEV_ENERGY = 16, ADMIN_TOWER_TYPE = 7, DEV_MODE_PASSWORD = "Spring.3";
|
||||||
|
var DEV_ADMIN_USER_IDS = {
|
||||||
|
"6605c44c-17a2-4cc4-bf3e-e772fd6229b0": true,
|
||||||
|
"6f32e90b-1ed8-4baf-bfa2-17646600da79": true
|
||||||
|
};
|
||||||
var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
|
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 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 LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]];
|
||||||
@@ -27,7 +38,7 @@ var TOWERS = [
|
|||||||
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
|
{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: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"}
|
{cost:210,damage:22,range:230,cooldown:0.42,targetClass:"air"}
|
||||||
];
|
];
|
||||||
var ENEMIES = [
|
var ENEMIES = [
|
||||||
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
|
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
|
||||||
@@ -35,7 +46,7 @@ var ENEMIES = [
|
|||||||
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
|
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
|
||||||
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
|
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
|
||||||
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
|
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
|
||||||
{category:"air",hp:740,speed:48,reward:108,breachDamage:4},
|
{category:"air",hp:620,speed:48,reward:108,breachDamage:4},
|
||||||
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
|
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
|
||||||
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
|
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
|
||||||
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
|
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
|
||||||
@@ -46,6 +57,93 @@ var DIFFICULTIES = [
|
|||||||
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
|
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function defaultBalanceConfig() {
|
||||||
|
return {schema:3,towers:JSON.parse(JSON.stringify(TOWERS)),enemies:JSON.parse(JSON.stringify(ENEMIES)),admin_tower:{range:650,cooldown:0.08}};
|
||||||
|
}
|
||||||
|
function boundedNumber(value, fallback, minimum, maximum, integer) {
|
||||||
|
var number = Number(value);
|
||||||
|
if (!isFinite(number)) number = fallback;
|
||||||
|
number = Math.max(minimum, Math.min(maximum, number));
|
||||||
|
return integer ? Math.floor(number) : number;
|
||||||
|
}
|
||||||
|
function sanitizeBalanceConfig(input) {
|
||||||
|
var defaults = defaultBalanceConfig();
|
||||||
|
if (!input || !Array.isArray(input.towers) || input.towers.length !== defaults.towers.length ||
|
||||||
|
!Array.isArray(input.enemies) || input.enemies.length !== defaults.enemies.length) throw new Error("invalid balance schema");
|
||||||
|
var towers = defaults.towers.map(function(base, index) {
|
||||||
|
var candidate = input.towers[index] || {};
|
||||||
|
var targetClass = String(candidate.targetClass || base.targetClass);
|
||||||
|
if (["air","ground","both"].indexOf(targetClass) < 0) targetClass = base.targetClass;
|
||||||
|
return {
|
||||||
|
cost:boundedNumber(candidate.cost,base.cost,10,50000,true),
|
||||||
|
damage:boundedNumber(candidate.damage,base.damage,0.1,10000,false),
|
||||||
|
range:boundedNumber(candidate.range,base.range,20,1200,false),
|
||||||
|
cooldown:boundedNumber(candidate.cooldown,base.cooldown,0.04,20,false),
|
||||||
|
targetClass:targetClass,
|
||||||
|
bothScale:boundedNumber(candidate.bothScale,base.bothScale===undefined?1:base.bothScale,0.05,2,false)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var enemies = defaults.enemies.map(function(base, index) {
|
||||||
|
var candidate = input.enemies[index] || {};
|
||||||
|
return {
|
||||||
|
category:base.category,
|
||||||
|
hp:boundedNumber(candidate.hp,base.hp,1,1000000,false),
|
||||||
|
speed:boundedNumber(candidate.speed,base.speed,1,1500,false),
|
||||||
|
reward:boundedNumber(candidate.reward,base.reward,0,50000,true),
|
||||||
|
breachDamage:boundedNumber(candidate.breachDamage,base.breachDamage,1,100,true),
|
||||||
|
shieldResistance:boundedNumber(candidate.shieldResistance,base.shieldResistance,0.01,1,false)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var adminInput=input.admin_tower||{};
|
||||||
|
var adminTower={
|
||||||
|
range:boundedNumber(adminInput.range,defaults.admin_tower.range,80,1200,false),
|
||||||
|
cooldown:boundedNumber(adminInput.cooldown,defaults.admin_tower.cooldown,0.04,20,false)
|
||||||
|
};
|
||||||
|
return {schema:3,towers:towers,enemies:enemies,admin_tower:adminTower};
|
||||||
|
}
|
||||||
|
function readBalanceConfig(nk) {
|
||||||
|
var record = nk && typeof nk.storageRead === "function" ? readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID) : null;
|
||||||
|
var config = defaultBalanceConfig(), updatedAt = 0;
|
||||||
|
if (record && record.value && record.value.config) {
|
||||||
|
try {
|
||||||
|
var storedConfig = record.value.config;
|
||||||
|
config = sanitizeBalanceConfig(storedConfig);
|
||||||
|
updatedAt = Number(record.value.updated_at||0);
|
||||||
|
if (Number(storedConfig.schema || 1) < 2) {
|
||||||
|
var legacyTower = storedConfig.towers[6] || {};
|
||||||
|
var legacyBoss = storedConfig.enemies[5] || {};
|
||||||
|
if (Number(legacyTower.damage) === 20) config.towers[6].damage = 22;
|
||||||
|
if (Number(legacyBoss.hp) === 740) config.enemies[5].hp = 620;
|
||||||
|
}
|
||||||
|
if (Number(storedConfig.schema || 1) < 3) {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
if (nk && typeof nk.storageWrite === "function") {
|
||||||
|
try { nk.storageWrite([{collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record.version||"*"}]); }
|
||||||
|
catch (writeError) { updatedAt = Number(record.value.updated_at||0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) { config = defaultBalanceConfig(); }
|
||||||
|
}
|
||||||
|
return {config:config,updated_at:updatedAt};
|
||||||
|
}
|
||||||
|
function rpcGetGameBalance(ctx, logger, nk, payload) {
|
||||||
|
// Nakama server-to-server HTTP-key calls intentionally have no player userId.
|
||||||
|
// The internal statistics page uses that path; game clients call with a session.
|
||||||
|
return JSON.stringify(readBalanceConfig(nk));
|
||||||
|
}
|
||||||
|
function rpcSetGameBalance(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 config = sanitizeBalanceConfig(request.config);
|
||||||
|
var record = readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID);
|
||||||
|
var updatedAt = Date.now();
|
||||||
|
var write = {collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"};
|
||||||
|
nk.storageWrite([write]);
|
||||||
|
return JSON.stringify({accepted:true,updated_at:updatedAt,config:config});
|
||||||
|
}
|
||||||
|
|
||||||
function clampInteger(value, minimum, maximum) {
|
function clampInteger(value, minimum, maximum) {
|
||||||
var parsed = Math.floor(Number(value));
|
var parsed = Math.floor(Number(value));
|
||||||
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
|
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
|
||||||
@@ -136,7 +234,19 @@ function rpcRecordActivity(ctx, logger, nk, payload) {
|
|||||||
var now = Date.now(), day = utcDay(now);
|
var now = Date.now(), day = utcDay(now);
|
||||||
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
||||||
updatePlayerStats(nk, playerRecord, platform, version, day, now);
|
updatePlayerStats(nk, playerRecord, platform, version, day, now);
|
||||||
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:{first_seen:playerRecord&&playerRecord.value?Number(playerRecord.value.first_seen||now):now,last_seen:now,last_seen_day:day,platform:platform,version:version},permissionRead:0,permissionWrite:0}]);
|
for (var attempt=0;attempt<3;attempt++) {
|
||||||
|
playerRecord=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,ctx.userId);
|
||||||
|
var activity=playerRecord&&playerRecord.value?JSON.parse(JSON.stringify(playerRecord.value)):{};
|
||||||
|
activity.first_seen=Number(activity.first_seen||now);
|
||||||
|
activity.last_seen=now;
|
||||||
|
activity.last_seen_day=day;
|
||||||
|
activity.platform=platform;
|
||||||
|
activity.version=version;
|
||||||
|
try {
|
||||||
|
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:activity,permissionRead:0,permissionWrite:0,version:playerRecord&&playerRecord.version?playerRecord.version:"*"}]);
|
||||||
|
return JSON.stringify({accepted:true});
|
||||||
|
} catch (error) { if (attempt===2) throw error; }
|
||||||
|
}
|
||||||
return JSON.stringify({accepted:true});
|
return JSON.stringify({accepted:true});
|
||||||
}
|
}
|
||||||
function rpcPrivateStats(ctx, logger, nk, payload) {
|
function rpcPrivateStats(ctx, logger, nk, payload) {
|
||||||
@@ -145,6 +255,75 @@ function rpcPrivateStats(ctx, logger, nk, payload) {
|
|||||||
var stats = record && record.value ? record.value : emptyStats(Date.now());
|
var stats = record && record.value ? record.value : emptyStats(Date.now());
|
||||||
return JSON.stringify(stats);
|
return JSON.stringify(stats);
|
||||||
}
|
}
|
||||||
|
function updatePlayerActivity(nk,userId,event,type,source,sequence,eventAt) {
|
||||||
|
var now=Date.now();
|
||||||
|
for (var attempt=0;attempt<3;attempt++) {
|
||||||
|
var record=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,userId);
|
||||||
|
var activity=record&&record.value?JSON.parse(JSON.stringify(record.value)):{};
|
||||||
|
var sequences=activity.event_sequences&&typeof activity.event_sequences==="object"?activity.event_sequences:{};
|
||||||
|
if (source && sequence<=Number(sequences[source]||0)) return {accepted:true,duplicate:true};
|
||||||
|
activity.first_seen=Number(activity.first_seen||now);
|
||||||
|
activity.last_seen=now;
|
||||||
|
activity.last_seen_day=utcDay(now);
|
||||||
|
activity.last_played=Math.max(Number(activity.last_played||0),Math.min(now,Math.max(0,Number(eventAt||now))));
|
||||||
|
if (event==="tower_build") {
|
||||||
|
var builds=Array.isArray(activity.tower_builds)?activity.tower_builds.slice(0,TOWERS.length):[];
|
||||||
|
while (builds.length<TOWERS.length) builds.push(0);
|
||||||
|
builds[type]=Math.max(0,Math.floor(Number(builds[type]||0)))+1;
|
||||||
|
activity.tower_builds=builds;
|
||||||
|
activity.tower_build_total=Math.max(0,Math.floor(Number(activity.tower_build_total||0)))+1;
|
||||||
|
}
|
||||||
|
if (source) { sequences[source]=sequence; activity.event_sequences=sequences; }
|
||||||
|
try {
|
||||||
|
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:userId,value:activity,permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"}]);
|
||||||
|
return {accepted:true,duplicate:false};
|
||||||
|
} catch (error) { if (attempt===2) throw error; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rpcRecordPlayerEvent(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 event=String(request.event||""), type=Math.floor(Number(request.tower_type));
|
||||||
|
var source=String(request.source||""),sequence=Math.floor(Number(request.sequence));
|
||||||
|
if (["play_start","tower_build"].indexOf(event)<0 || (event==="tower_build"&&(type<0||type>=TOWERS.length)) ||
|
||||||
|
!/^[A-Za-z0-9_-]{8,64}$/.test(source) || !isFinite(sequence) || sequence<1 || sequence>1000000000)
|
||||||
|
return JSON.stringify({accepted:false,error:"invalid_event"});
|
||||||
|
var eventAt=Number(request.at||Date.now());
|
||||||
|
return JSON.stringify(updatePlayerActivity(nk,ctx.userId,event,type,source,sequence,eventAt));
|
||||||
|
}
|
||||||
|
function isInternalTestUsername(username) { return new RegExp(INTERNAL_TEST_USERNAME_PATTERN,"i").test(String(username||"")); }
|
||||||
|
function playerDirectoryRow(row) {
|
||||||
|
return {user_id:String(row.id||""),name:String(row.display_name||row.username||"PILOT").substring(0,32),
|
||||||
|
registered_at:Number(row.registered_at||0),last_seen:Number(row.last_seen||0),last_played:Number(row.last_played||0),
|
||||||
|
tower_build_total:Number(row.tower_build_total||0)};
|
||||||
|
}
|
||||||
|
function rpcListRegisteredPlayers(ctx,logger,nk,payload) {
|
||||||
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
|
var request;
|
||||||
|
try { request=JSON.parse(payload||"{}"); } catch (error) { request={}; }
|
||||||
|
var page=Math.max(0,Math.min(10000,Math.floor(Number(request.page||0))||0));
|
||||||
|
var countRows=nk.sqlQuery("SELECT count(*) AS total, count(*) FILTER (WHERE a.value->>'last_seen_day'=$1) AS active_today FROM users u LEFT JOIN storage a ON a.user_id=u.id AND a.collection='player_activity' AND a.key='summary' WHERE u.username<>'' AND u.username !~* $2",[utcDay(Date.now()),INTERNAL_TEST_USERNAME_PATTERN]);
|
||||||
|
var rows=nk.sqlQuery("SELECT u.id,u.username,u.display_name,extract(epoch from u.create_time)*1000 AS registered_at,coalesce((a.value->>'last_seen')::bigint,0) AS last_seen,coalesce((a.value->>'last_played')::bigint,0) AS last_played,coalesce((a.value->>'tower_build_total')::bigint,0) AS tower_build_total FROM users u LEFT JOIN storage a ON a.user_id=u.id AND a.collection='player_activity' AND a.key='summary' WHERE u.username<>'' AND u.username !~* $1 ORDER BY last_seen DESC,u.create_time DESC LIMIT 50 OFFSET $2",[INTERNAL_TEST_USERNAME_PATTERN,page*50]);
|
||||||
|
return JSON.stringify({page:page,page_size:50,total:Number(countRows[0]&&countRows[0].total||0),active_today:Number(countRows[0]&&countRows[0].active_today||0),players:rows.map(playerDirectoryRow)});
|
||||||
|
}
|
||||||
|
function rpcGetRegisteredPlayer(ctx,logger,nk,payload) {
|
||||||
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
|
var request;
|
||||||
|
try { request=JSON.parse(payload||"{}"); } catch (error) { request={}; }
|
||||||
|
var userId=String(request.user_id||"");
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(userId)) return JSON.stringify({found:false});
|
||||||
|
var rows=nk.sqlQuery("SELECT id,username,display_name,extract(epoch from create_time)*1000 AS registered_at FROM users WHERE id=$1::uuid LIMIT 1",[userId]);
|
||||||
|
if (!rows.length || isInternalTestUsername(rows[0].username) || !rows[0].username) return JSON.stringify({found:false});
|
||||||
|
var activityRecord=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,userId);
|
||||||
|
var activity=activityRecord&&activityRecord.value?activityRecord.value:{};
|
||||||
|
var builds=Array.isArray(activity.tower_builds)?activity.tower_builds.slice(0,TOWERS.length):[];
|
||||||
|
while (builds.length<TOWERS.length) builds.push(0);
|
||||||
|
return JSON.stringify({found:true,player:{user_id:userId,name:String(rows[0].display_name||rows[0].username).substring(0,32),
|
||||||
|
registered_at:Number(rows[0].registered_at||0),last_seen:Number(activity.last_seen||0),last_played:Number(activity.last_played||0),
|
||||||
|
platform:String(activity.platform||""),version:String(activity.version||""),tower_builds:builds,
|
||||||
|
tower_build_total:Number(activity.tower_build_total||0)}});
|
||||||
|
}
|
||||||
function rpcCreateUnlockCode(ctx, logger, nk, payload) {
|
function rpcCreateUnlockCode(ctx, logger, nk, payload) {
|
||||||
if (ctx.userId) throw new Error("server-to-server access required");
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
var request;
|
var request;
|
||||||
@@ -320,6 +499,18 @@ function rpcUpdateDisplayName(ctx, logger, nk, payload) {
|
|||||||
nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null);
|
nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null);
|
||||||
return JSON.stringify({accepted:true,display_name:name});
|
return JSON.stringify({accepted:true,display_name:name});
|
||||||
}
|
}
|
||||||
|
function rpcFindPlayerByName(ctx, logger, nk, payload) {
|
||||||
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
|
var request;
|
||||||
|
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({found:false,error:"invalid_json"}); }
|
||||||
|
var name = normalizeDisplayName(request.display_name);
|
||||||
|
if (validateDisplayName(name)) return JSON.stringify({found:false,error:"invalid_name"});
|
||||||
|
var rows = nk.sqlQuery("SELECT id, display_name FROM users WHERE lower(display_name) = lower($1) LIMIT 3", [name]);
|
||||||
|
var candidates = rows.filter(function(row) { return String(row.id) !== ctx.userId; });
|
||||||
|
if (!candidates.length) return JSON.stringify({found:false,error:"not_found"});
|
||||||
|
if (candidates.length !== 1 || rows.length > 2) return JSON.stringify({found:false,error:"ambiguous"});
|
||||||
|
return JSON.stringify({found:true,user_id:String(candidates[0].id),display_name:String(candidates[0].display_name)});
|
||||||
|
}
|
||||||
function rpcDeleteAccount(ctx, logger, nk, payload) {
|
function rpcDeleteAccount(ctx, logger, nk, payload) {
|
||||||
if (!ctx.userId) throw new Error("authentication required");
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
||||||
@@ -409,12 +600,13 @@ function matchmakerMatched(ctx, logger, nk, matches) {
|
|||||||
return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty});
|
return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty});
|
||||||
}
|
}
|
||||||
function initialState(params) {
|
function initialState(params) {
|
||||||
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
|
var requestedDifficulty = params.difficulty === undefined || params.difficulty === null ? 1 : Number(params.difficulty);
|
||||||
|
var difficulty = Math.max(0,Math.min(2,Math.floor(isFinite(requestedDifficulty) ? requestedDifficulty : 1)));
|
||||||
var credits = DIFFICULTIES[difficulty].startCredits;
|
var credits = DIFFICULTIES[difficulty].startCredits;
|
||||||
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0};
|
return {presences:{},slots:{},players:{},ready:{},devAuthorized:{},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,balance:defaultBalanceConfig()};
|
||||||
}
|
}
|
||||||
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
|
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
|
||||||
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
|
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); state.balance=readBalanceConfig(nk).config; return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
|
||||||
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
|
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
|
||||||
function firstFreeSlot(state) {
|
function firstFreeSlot(state) {
|
||||||
var used={},ids=Object.keys(state.slots);
|
var used={},ids=Object.keys(state.slots);
|
||||||
@@ -449,6 +641,16 @@ function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
|
|||||||
}
|
}
|
||||||
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
|
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
|
||||||
state.emptyTicks=0;
|
state.emptyTicks=0;
|
||||||
|
if (ctx.matchId) {
|
||||||
|
var roundId = nk.sha256Hash(ctx.matchId).slice(0, 16);
|
||||||
|
var participantSlots = {}, participantIds = Object.keys(state.slots);
|
||||||
|
for (var participantIndex = 0; participantIndex < participantIds.length; participantIndex++) {
|
||||||
|
var participantId = participantIds[participantIndex];
|
||||||
|
participantSlots[nk.sha256Hash(ctx.matchId + ":" + participantId)] = state.slots[participantId];
|
||||||
|
}
|
||||||
|
nk.storageWrite([{collection:DIAG_PARTICIPANT_COLLECTION,key:roundId,userId:SYSTEM_USER_ID,
|
||||||
|
value:{participants:participantSlots,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
|
||||||
|
}
|
||||||
dispatcher.matchLabelUpdate(matchLabel(state));
|
dispatcher.matchLabelUpdate(matchLabel(state));
|
||||||
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
|
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
|
||||||
broadcastSnapshot(dispatcher,state,tick);
|
broadcastSnapshot(dispatcher,state,tick);
|
||||||
@@ -460,41 +662,50 @@ function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
|
|||||||
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
|
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
|
||||||
}
|
}
|
||||||
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
|
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
|
||||||
function towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
|
function towerInfo(state,type) {
|
||||||
function towerRange(t) { return TOWERS[t.type].range*(1+(t.level-1)*0.08); }
|
if (type===ADMIN_TOWER_TYPE) {
|
||||||
function towerCooldown(t) { return TOWERS[t.type].cooldown/(1+(t.level-1)*0.12); }
|
var admin=state.balance&&state.balance.admin_tower||defaultBalanceConfig().admin_tower;
|
||||||
function upgradeCost(t) { return Math.floor(TOWERS[t.type].cost*(0.72+t.level*0.28)); }
|
return {cost:0,damage:1000000,range:admin.range,cooldown:admin.cooldown,targetClass:"both",bothScale:1};
|
||||||
|
}
|
||||||
|
return state.balance && state.balance.towers[type] || TOWERS[type];
|
||||||
|
}
|
||||||
|
function enemyInfo(state,type) { return state.balance && state.balance.enemies[type] || ENEMIES[type]; }
|
||||||
|
function towerDamage(state,t) { return towerInfo(state,t.type).damage*(1+(t.level-1)*0.52); }
|
||||||
|
function towerRange(state,t) { return towerInfo(state,t.type).range*(1+(t.level-1)*0.08); }
|
||||||
|
function towerCooldown(state,t) { return towerInfo(state,t.type).cooldown/(1+(t.level-1)*0.12); }
|
||||||
|
function upgradeCost(state,t) { return Math.floor(towerInfo(state,t.type).cost*(0.72+t.level*0.28)); }
|
||||||
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
|
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
|
||||||
function distanceToSegment(px,py,ax,ay,bx,by) {
|
function distanceToSegment(px,py,ax,ay,bx,by) {
|
||||||
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
|
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
|
||||||
t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t));
|
t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t));
|
||||||
}
|
}
|
||||||
function validBuildPosition(state,slot,x,y) {
|
function validBuildPosition(state,slot,x,y,allowAnySide) {
|
||||||
var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5;
|
var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5;
|
||||||
if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||y<PLAYFIELD_TOP||y>PLAYFIELD_BOTTOM) return false;
|
if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||x<120||x>WORLD_WIDTH-120||y<PLAYFIELD_TOP||y>PLAYFIELD_BOTTOM) return false;
|
||||||
if (slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false;
|
if (!allowAnySide&&slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false;
|
||||||
if (slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false;
|
if (!allowAnySide&&slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false;
|
||||||
if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false;
|
if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false;
|
||||||
for (var r=0;r<PATHS.length;r++) for (var i=0;i<PATHS[r].length-1;i++) if (distanceToSegment(x,y,PATHS[r][i][0],PATHS[r][i][1],PATHS[r][i+1][0],PATHS[r][i+1][1])<GRID*0.62) return false;
|
for (var r=0;r<PATHS.length;r++) for (var i=0;i<PATHS[r].length-1;i++) if (distanceToSegment(x,y,PATHS[r][i][0],PATHS[r][i][1],PATHS[r][i+1][0],PATHS[r][i+1][1])<GRID*0.62) return false;
|
||||||
for (var j=0;j<state.towers.length;j++) if (distanceSquared(x,y,state.towers[j].x,state.towers[j].y)<GRID*GRID*0.52) return false;
|
for (var j=0;j<state.towers.length;j++) if (distanceSquared(x,y,state.towers[j].x,state.towers[j].y)<GRID*GRID*0.52) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function handleBuild(state,slot,payload) {
|
function handleBuild(state,slot,payload,userId) {
|
||||||
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
|
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
|
||||||
if (!isFinite(x)||!isFinite(y)||type<0||type>=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]<TOWERS[type].cost) return false;
|
var isAdmin=type===ADMIN_TOWER_TYPE;
|
||||||
state.credits[slot]-=TOWERS[type].cost;
|
if (!isFinite(x)||!isFinite(y)||type<0||type>TOWERS.length||(isAdmin&&!state.devAuthorized[userId])||!validBuildPosition(state,slot,x,y,isAdmin)||state.credits[slot]<towerInfo(state,type).cost) return false;
|
||||||
|
state.credits[slot]-=towerInfo(state,type).cost;
|
||||||
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
|
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
|
||||||
}
|
}
|
||||||
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
|
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
|
||||||
function handleUpgrade(state,slot,payload) {
|
function handleUpgrade(state,slot,payload) {
|
||||||
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
|
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
|
||||||
if (i<0||state.towers[i].level>=3) return false;
|
if (i<0||state.towers[i].level>=3) return false;
|
||||||
var cost=upgradeCost(state.towers[i]); if (state.credits[slot]<cost) return false;
|
var cost=upgradeCost(state,state.towers[i]); if (state.credits[slot]<cost) return false;
|
||||||
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
|
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
|
||||||
}
|
}
|
||||||
function handleSell(state,slot,payload) {
|
function handleSell(state,slot,payload) {
|
||||||
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
|
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
|
||||||
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
|
var tower=state.towers[i]; state.credits[slot]+=Math.floor(towerInfo(state,tower.type).cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
|
||||||
}
|
}
|
||||||
function enemyTypeForWave(wave,roll,difficulty,boss) {
|
function enemyTypeForWave(wave,roll,difficulty,boss) {
|
||||||
if (boss) return wave>=10?8:5;
|
if (boss) return wave>=10?8:5;
|
||||||
@@ -530,14 +741,17 @@ function startWave(state) {
|
|||||||
function spawnEnemy(state) {
|
function spawnEnemy(state) {
|
||||||
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
|
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
|
||||||
var boss=entry.boss,type=entry.type;
|
var boss=entry.boss,type=entry.type;
|
||||||
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty];
|
var base=enemyInfo(state,type),d=DIFFICULTIES[state.difficulty];
|
||||||
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
|
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
|
||||||
|
var hullScale=scale*(boss&&state.wave===5?0.5:1);
|
||||||
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
|
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
|
||||||
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
|
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
|
||||||
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
|
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
|
||||||
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
|
if (boss && state.wave === 5) maxShieldHp *= 0.5;
|
||||||
|
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*hullScale,max_hp:base.hp*hullScale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:base.shieldResistance});
|
||||||
}
|
}
|
||||||
function applyDamageToEnemy(enemy,damage,towerType) {
|
function applyDamageToEnemy(enemy,damage,towerType) {
|
||||||
|
if (towerType===ADMIN_TOWER_TYPE) { enemy.shield=false; enemy.shield_hp=0; enemy.hp=0; return; }
|
||||||
var applied=damage;
|
var applied=damage;
|
||||||
if (enemy.shield) {
|
if (enemy.shield) {
|
||||||
if (towerType===1) {
|
if (towerType===1) {
|
||||||
@@ -556,7 +770,7 @@ function updateEnemies(state,delta) {
|
|||||||
e.angle=Math.atan2(dy,dx);
|
e.angle=Math.atan2(dy,dx);
|
||||||
if (dist<=Math.max(9,e.speed*delta)) {
|
if (dist<=Math.max(9,e.speed*delta)) {
|
||||||
e.x=target[0]; e.y=target[1]; e.path_index+=1;
|
e.x=target[0]; e.y=target[1]; e.path_index+=1;
|
||||||
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(ENEMIES[e.type].breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
|
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(enemyInfo(state,e.type).breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
|
||||||
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
|
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,19 +778,20 @@ function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,R
|
|||||||
function updateTowers(state,delta) {
|
function updateTowers(state,delta) {
|
||||||
state.shots=state.shots||[];
|
state.shots=state.shots||[];
|
||||||
for (var i=0;i<state.towers.length;i++) {
|
for (var i=0;i<state.towers.length;i++) {
|
||||||
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
|
var t=state.towers[i],tower=towerInfo(state,t.type); t.cooldown-=delta; var range=towerRange(state,t),targetIndex=-1,best=-1,bestShield=false;
|
||||||
for (var j=0;j<state.enemies.length;j++) {
|
for (var j=0;j<state.enemies.length;j++) {
|
||||||
var candidate=state.enemies[j];
|
var candidate=state.enemies[j];
|
||||||
if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[candidate.type].category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
|
if (t.type===ADMIN_TOWER_TYPE&&candidate.route_id!==(t.x<MIRROR_X?0:1)) continue;
|
||||||
|
if ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,candidate.type).category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
|
||||||
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
|
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
|
||||||
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
|
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetIndex<0) continue;
|
if (targetIndex<0) continue;
|
||||||
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
|
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5,6].indexOf(t.type)>=0?Math.PI:0);
|
||||||
if (t.cooldown>0) continue;
|
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});
|
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);
|
var damage=towerDamage(state,t)*(tower.targetClass==="both"?tower.bothScale:1);
|
||||||
if (t.type===5) {
|
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;
|
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
|
||||||
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
|
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
|
||||||
@@ -588,9 +803,9 @@ function updateTowers(state,delta) {
|
|||||||
} else if (t.type===2||t.type===3||t.type===4) {
|
} else if (t.type===2||t.type===3||t.type===4) {
|
||||||
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
|
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
|
||||||
var tx=target.x,ty=target.y,targetId=target.id;
|
var tx=target.x,ty=target.y,targetId=target.id;
|
||||||
for (var k=state.enemies.length-1;k>=0;k--) if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[state.enemies[k].type].category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
|
for (var k=state.enemies.length-1;k>=0;k--) if ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,state.enemies[k].type).category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
|
||||||
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
|
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
|
||||||
t.cooldown=towerCooldown(t);
|
t.cooldown=towerCooldown(state,t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function updateWave(state,delta,dispatcher) {
|
function updateWave(state,delta,dispatcher) {
|
||||||
@@ -605,7 +820,13 @@ function updateWave(state,delta,dispatcher) {
|
|||||||
function handleCommand(nk,dispatcher,state,message) {
|
function handleCommand(nk,dispatcher,state,message) {
|
||||||
var slot=state.slots[message.sender.userId]; if (slot===undefined) return;
|
var slot=state.slots[message.sender.userId]; if (slot===undefined) return;
|
||||||
var payload=parseMessage(nk,message); if (payload===null) return; var changed=false;
|
var payload=parseMessage(nk,message); if (payload===null) return; var changed=false;
|
||||||
if (message.opCode===OP_BUILD) changed=handleBuild(state,slot,payload);
|
if (message.opCode===OP_BUILD) {
|
||||||
|
changed=handleBuild(state,slot,payload,message.sender.userId);
|
||||||
|
if (changed&&Math.floor(Number(payload.tower_type))<TOWERS.length) {
|
||||||
|
try { updatePlayerActivity(nk,message.sender.userId,"tower_build",Math.floor(Number(payload.tower_type)),"",0,Date.now()); }
|
||||||
|
catch (telemetryError) { /* Telemetry must never reject an accepted tower build. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
|
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
|
||||||
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
|
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
|
||||||
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
|
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
|
||||||
@@ -616,9 +837,32 @@ function handleCommand(nk,dispatcher,state,message) {
|
|||||||
state.players[message.sender.userId]={name:name||"PILOT"};
|
state.players[message.sender.userId]={name:name||"PILOT"};
|
||||||
var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2;
|
var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2;
|
||||||
for (var i=0;i<ids.length;i++) if (!state.ready[ids[i]]||state.presences[ids[i]]===undefined) allReady=false;
|
for (var i=0;i<ids.length;i++) if (!state.ready[ids[i]]||state.presences[ids[i]]===undefined) allReady=false;
|
||||||
if (allReady) { state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{}); }
|
if (allReady) {
|
||||||
|
state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{});
|
||||||
|
for (var playerIndex=0;playerIndex<ids.length;playerIndex++) {
|
||||||
|
try { updatePlayerActivity(nk,ids[playerIndex],"play_start",-1,"",0,Date.now()); }
|
||||||
|
catch (telemetryError) { /* Telemetry must never stop the match. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
changed=true;
|
changed=true;
|
||||||
}
|
}
|
||||||
|
else if (message.opCode===OP_DEV_AUTH&&state.started&&!state.gameOver) {
|
||||||
|
if (DEV_ADMIN_USER_IDS[message.sender.userId]===true&&payload.password===DEV_MODE_PASSWORD) {
|
||||||
|
state.devAuthorized[message.sender.userId]=true;
|
||||||
|
broadcastEvent(dispatcher,"developer_authorized",{slot:slot});
|
||||||
|
changed=true;
|
||||||
|
} else {
|
||||||
|
broadcastEvent(dispatcher,"developer_auth_rejected",{slot:slot});
|
||||||
|
changed=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (message.opCode===OP_DEV_ENERGY&&state.started&&!state.gameOver&&state.devAuthorized[message.sender.userId]) {
|
||||||
|
var energyChange=Math.floor(Number(payload.amount));
|
||||||
|
if (energyChange===500||energyChange===-500) {
|
||||||
|
state.credits[slot]=Math.max(0,state.credits[slot]+energyChange);
|
||||||
|
changed=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
|
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
|
||||||
}
|
}
|
||||||
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
|
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
|
||||||
@@ -642,11 +886,105 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
|
|||||||
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); 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}; }
|
||||||
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
|
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
|
||||||
|
|
||||||
|
function sanitizeDiagnosticRecord(input) {
|
||||||
|
if (!input || typeof input !== "object") return null;
|
||||||
|
var allowedEvents = ["window","snapshot_gap","tick_jump","frame_stall","socket_closed","socket_reconnected","socket_reconnect_failed","side","end"];
|
||||||
|
var event = String(input.event || "");
|
||||||
|
if (allowedEvents.indexOf(event) < 0) return null;
|
||||||
|
var result = {event:event};
|
||||||
|
var utc = String(input.utc || "");
|
||||||
|
if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d/.test(utc)) result.utc = utc.slice(0, 25);
|
||||||
|
var fields = ["seconds","snapshots","snapshots_per_second","gap_p95_ms","gap_max_ms","gaps_over_500_ms","last_server_tick","tick_jump_max","tick_jumps_over_1","bytes_avg","bytes_max","parse_max_us","apply_max_us","frame_max_ms","frame_stalls_over_200_ms","coalesced","queue_dropped","queue_max","enemies","towers","duration_ms","from_tick","to_tick","steps","attempt"];
|
||||||
|
for (var i = 0; i < fields.length; i++) {
|
||||||
|
var value = Number(input[fields[i]]);
|
||||||
|
if (input[fields[i]] !== undefined && isFinite(value) && value >= 0 && value <= 1000000000) result[fields[i]] = value;
|
||||||
|
}
|
||||||
|
if (event === "end") result.reason = ["left_match","cancelled_matchmaking","game_over","victory","new_match"].indexOf(String(input.reason)) >= 0 ? String(input.reason) : "other";
|
||||||
|
if (event === "side") result.viewing_right = input.viewing_right === true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function rpcSubmitOnlineDiagnostics(ctx, logger, nk, payload) {
|
||||||
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
|
if (!payload || payload.length > 65000) return JSON.stringify({accepted:false,error:"too_large"});
|
||||||
|
var request;
|
||||||
|
try { request = JSON.parse(payload); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
|
||||||
|
var matchId = String(request.match_id || "");
|
||||||
|
if (!matchId || matchId.length > 128 || !Array.isArray(request.records) || request.records.length > 160)
|
||||||
|
return JSON.stringify({accepted:false,error:"invalid_payload"});
|
||||||
|
var roundId = nk.sha256Hash(matchId).slice(0, 16);
|
||||||
|
var participant = readStorageRecord(nk, DIAG_PARTICIPANT_COLLECTION, roundId, SYSTEM_USER_ID);
|
||||||
|
var slots = participant && participant.value ? participant.value.participants || {} : {};
|
||||||
|
var participantKey = nk.sha256Hash(matchId + ":" + ctx.userId);
|
||||||
|
if (slots[participantKey] === undefined || Date.now() - Number(participant.value.created_at || 0) > DIAG_RETENTION_MS)
|
||||||
|
return JSON.stringify({accepted:false,error:"not_participant"});
|
||||||
|
var slot = Number(slots[participantKey]);
|
||||||
|
if (slot !== 0 && slot !== 1) return JSON.stringify({accepted:false,error:"invalid_slot"});
|
||||||
|
var records = request.records.map(sanitizeDiagnosticRecord).filter(function(record) { return record !== null; });
|
||||||
|
if (!records.length) return JSON.stringify({accepted:false,error:"no_records"});
|
||||||
|
var key = roundId + "-" + slot;
|
||||||
|
nk.storageWrite([{collection:DIAG_REPORT_COLLECTION,key:key,userId:SYSTEM_USER_ID,
|
||||||
|
value:{round_id:roundId,slot:slot,created_at:Date.now(),version:normalizeMetricLabel(request.version,"unknown"),records:records},
|
||||||
|
permissionRead:0,permissionWrite:0}]);
|
||||||
|
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
|
||||||
|
if (logger.info) logger.info("ONLINE DIAG received round=%s slot=%d records=%d", roundId, slot, records.length);
|
||||||
|
return JSON.stringify({accepted:true,round_id:roundId,slot:slot});
|
||||||
|
}
|
||||||
|
function pruneOnlineDiagnostics(nk) {
|
||||||
|
var collections = [DIAG_REPORT_COLLECTION, DIAG_PARTICIPANT_COLLECTION];
|
||||||
|
var cutoff = Date.now() - DIAG_RETENTION_MS;
|
||||||
|
for (var c = 0; c < collections.length; c++) {
|
||||||
|
var cursor = "", pages = 0, stale = [];
|
||||||
|
do {
|
||||||
|
var page = nk.storageList(SYSTEM_USER_ID, collections[c], 100, cursor);
|
||||||
|
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
|
||||||
|
for (var i = 0; i < objects.length; i++) {
|
||||||
|
if (Number(objects[i].value && objects[i].value.created_at || 0) < cutoff && objects[i].key)
|
||||||
|
stale.push({collection:collections[c],key:objects[i].key,userId:SYSTEM_USER_ID});
|
||||||
|
}
|
||||||
|
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
|
||||||
|
pages++;
|
||||||
|
} while (cursor && pages < 10);
|
||||||
|
for (var deleteIndex = 0; deleteIndex < stale.length; deleteIndex += 100)
|
||||||
|
nk.storageDelete(stale.slice(deleteIndex, deleteIndex + 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rpcListOnlineDiagnostics(ctx, logger, nk, payload) {
|
||||||
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
|
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
|
||||||
|
var request;
|
||||||
|
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
|
||||||
|
var requestedRound = String(request.round_id || "");
|
||||||
|
if (requestedRound) {
|
||||||
|
if (!/^[a-f0-9]{16}$/.test(requestedRound)) return JSON.stringify({reports:[],error:"invalid_round_id"});
|
||||||
|
var pair = nk.storageRead([0,1].map(function(slot) { return {collection:DIAG_REPORT_COLLECTION,key:requestedRound+"-"+slot,userId:SYSTEM_USER_ID}; }));
|
||||||
|
return JSON.stringify({reports:pair.map(function(record) { return record.value; })});
|
||||||
|
}
|
||||||
|
var reports = [], cursor = "", pages = 0, cutoff = Date.now() - DIAG_RETENTION_MS;
|
||||||
|
do {
|
||||||
|
var page = nk.storageList(SYSTEM_USER_ID, DIAG_REPORT_COLLECTION, 100, cursor);
|
||||||
|
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
|
||||||
|
for (var i = 0; i < objects.length; i++) {
|
||||||
|
var object = objects[i], value = object && object.value ? object.value : {};
|
||||||
|
if (Number(value.created_at || 0) >= cutoff) reports.push({round_id:value.round_id,slot:value.slot,
|
||||||
|
created_at:value.created_at,version:value.version,record_count:Array.isArray(value.records)?value.records.length:0});
|
||||||
|
}
|
||||||
|
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
|
||||||
|
pages++;
|
||||||
|
} while (cursor && pages < 10);
|
||||||
|
reports.sort(function(a,b) { return b.created_at - a.created_at; });
|
||||||
|
return JSON.stringify({reports:reports.slice(0,100),truncated:!!cursor||reports.length>100});
|
||||||
|
}
|
||||||
|
|
||||||
function InitModule(ctx,logger,nk,initializer) {
|
function InitModule(ctx,logger,nk,initializer) {
|
||||||
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
|
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
|
||||||
initializer.registerRpc("server_info",rpcServerInfo);
|
initializer.registerRpc("server_info",rpcServerInfo);
|
||||||
initializer.registerRpc("record_activity",rpcRecordActivity);
|
initializer.registerRpc("record_activity",rpcRecordActivity);
|
||||||
|
initializer.registerRpc("record_player_event",rpcRecordPlayerEvent);
|
||||||
initializer.registerRpc("private_stats",rpcPrivateStats);
|
initializer.registerRpc("private_stats",rpcPrivateStats);
|
||||||
|
initializer.registerRpc("list_registered_players",rpcListRegisteredPlayers);
|
||||||
|
initializer.registerRpc("get_registered_player",rpcGetRegisteredPlayer);
|
||||||
|
initializer.registerRpc("get_game_balance",rpcGetGameBalance);
|
||||||
|
initializer.registerRpc("set_game_balance",rpcSetGameBalance);
|
||||||
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
|
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
|
||||||
initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes);
|
initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes);
|
||||||
initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode);
|
initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode);
|
||||||
@@ -654,6 +992,9 @@ function InitModule(ctx,logger,nk,initializer) {
|
|||||||
initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks);
|
initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks);
|
||||||
initializer.registerRpc("submit_run",rpcSubmitRun);
|
initializer.registerRpc("submit_run",rpcSubmitRun);
|
||||||
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
|
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
|
||||||
|
initializer.registerRpc("find_player_by_name",rpcFindPlayerByName);
|
||||||
|
initializer.registerRpc("submit_online_diagnostics",rpcSubmitOnlineDiagnostics);
|
||||||
|
initializer.registerRpc("list_online_diagnostics",rpcListOnlineDiagnostics);
|
||||||
initializer.registerRpc("delete_account",rpcDeleteAccount);
|
initializer.registerRpc("delete_account",rpcDeleteAccount);
|
||||||
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
|
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
|
||||||
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
|
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const vm = require("vm");
|
||||||
|
const path = require("path");
|
||||||
|
const assert = require("assert");
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
||||||
|
const context = { console, Math, Date, JSON, isFinite };
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
|
||||||
|
function duel(towerType, enemyType, wave = 1, boss = false, difficulty = 1) {
|
||||||
|
const base = context.ENEMIES[enemyType];
|
||||||
|
const waveScale = (1 + Math.max(0, wave - 1) * 0.20 + Math.max(0, wave - 3) * 0.09 + (difficulty === 2 ? Math.max(0, wave - 5) * 0.055 : 0)) * (boss ? 3.25 : 1) * context.DIFFICULTIES[difficulty].hp;
|
||||||
|
const hullScale = waveScale * (boss && wave === 5 ? 0.5 : 1);
|
||||||
|
const shielded = enemyType === 4 || (boss && wave === 5);
|
||||||
|
const shield = shielded ? base.hp * waveScale * 0.55 * (boss && wave === 5 ? 0.5 : 1) : 0;
|
||||||
|
const testedEnemy = { id: 1, type: enemyType, x: -40, y: 120, path_index: 1, route_id: 0,
|
||||||
|
hp: base.hp * hullScale, max_hp: base.hp * hullScale, speed: base.speed * (1 + wave * 0.012) * (boss ? 0.82 : 1) * context.DIFFICULTIES[difficulty].speed,
|
||||||
|
reward: base.reward, shield: shield > 0, shield_hp: shield, max_shield_hp: shield,
|
||||||
|
shield_resistance: base.shieldResistance, boss };
|
||||||
|
const state = {
|
||||||
|
wave,
|
||||||
|
balance: { towers: context.TOWERS, enemies: context.ENEMIES },
|
||||||
|
towers: [{ id: 1, type: towerType, x: 540, y: 220, owner: 0, level: 1, cooldown: 0, angle: 0 }],
|
||||||
|
enemies: [testedEnemy],
|
||||||
|
shots: [], credits: [0, 0], reactorLives: 30, gameOver: false, waveActive: true, spawnRemaining: 0
|
||||||
|
};
|
||||||
|
const tick = 0.1;
|
||||||
|
let elapsed = 0;
|
||||||
|
for (; elapsed < 90 && state.enemies.length; elapsed += tick) {
|
||||||
|
context.updateEnemies(state, tick);
|
||||||
|
context.updateTowers(state, tick);
|
||||||
|
}
|
||||||
|
const alive = state.enemies.length > 0;
|
||||||
|
const breached = state.reactorLives < 30;
|
||||||
|
const remainingHp = Math.max(0, testedEnemy.hp);
|
||||||
|
return { alive, killed: !alive && !breached && remainingHp <= 0, remainingHp, seconds: elapsed,
|
||||||
|
breached, damageTaken: base.hp * hullScale - remainingHp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
let evaluated = 0;
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
const towerData = context.TOWERS[tower];
|
||||||
|
const results = [];
|
||||||
|
for (let enemy = 0; enemy < context.ENEMIES.length; enemy++) {
|
||||||
|
const enemyData = context.ENEMIES[enemy];
|
||||||
|
if (towerData.targetClass !== "both" && towerData.targetClass !== enemyData.category) continue;
|
||||||
|
const regular = duel(tower, enemy);
|
||||||
|
const late = duel(tower, enemy, 15, false, 2);
|
||||||
|
evaluated++;
|
||||||
|
results.push(`${enemy}:${regular.killed ? `kill ${regular.seconds.toFixed(1)}s` : `leaks ${Math.round(regular.remainingHp)} HP`}/${late.killed ? `H W15 kill ${late.seconds.toFixed(1)}s` : `H W15 leaks ${Math.round(late.remainingHp)} HP`}`);
|
||||||
|
}
|
||||||
|
rows.push(`${towerData.targetClass.padEnd(6)} tower ${tower}: ${results.join(" | ")}`);
|
||||||
|
}
|
||||||
|
const bossResults = [];
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
if (context.TOWERS[tower].targetClass !== "air" && context.TOWERS[tower].targetClass !== "both") continue;
|
||||||
|
const result = duel(tower, 5, 5, true, 1);
|
||||||
|
bossResults.push(`tower ${tower}: ${result.killed ? `killed in ${result.seconds.toFixed(1)}s` : `reaches reactor with ${Math.round(result.remainingHp)} HP`}`);
|
||||||
|
}
|
||||||
|
assert.ok(evaluated >= 20, "the matrix must cover all valid tower/enemy matchups");
|
||||||
|
console.log("BALANCE DUEL RESULTS (one level-1 tower, one enemy, fixed path and no reinvestment)");
|
||||||
|
console.log(rows.join("\n"));
|
||||||
|
console.log("WAVE 5 FIRST BOSS (single-tower reference): " + bossResults.join(" | "));
|
||||||
|
console.log(`BALANCE_DUEL_TEST_OK matchups=${evaluated}`);
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const vm = require("vm");
|
||||||
|
const path = require("path");
|
||||||
|
const assert = require("assert");
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
||||||
|
const context = { console, Math, Date, JSON, isFinite };
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
|
||||||
|
function seedRandom(seed) {
|
||||||
|
let value = seed >>> 0;
|
||||||
|
return function () {
|
||||||
|
value = (value * 1664525 + 1013904223) >>> 0;
|
||||||
|
return value / 0x100000000;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePath(route) {
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < route.length - 1; i++) {
|
||||||
|
const [ax, ay] = route[i], [bx, by] = route[i + 1];
|
||||||
|
const length = Math.hypot(bx - ax, by - ay);
|
||||||
|
const steps = Math.max(1, Math.ceil(length / 40));
|
||||||
|
for (let step = 0; step < steps; step++) {
|
||||||
|
const ratio = step / steps;
|
||||||
|
samples.push([ax + (bx - ax) * ratio, ay + (by - ay) * ratio, length / steps]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSamples = [samplePath(context.PATHS[0]), samplePath(context.PATHS[1])];
|
||||||
|
const areaFactor = [1, 1, 1.65, 1.6, 1.9, 1.35, 1];
|
||||||
|
|
||||||
|
function nextWaveComposition(state, wave) {
|
||||||
|
const schedule = context.buildWaveSchedule(state, wave, context.wavePopulation(state, wave));
|
||||||
|
const counts = Array(context.ENEMIES.length).fill(0);
|
||||||
|
for (const entry of schedule) counts[entry.type] += 1;
|
||||||
|
return { schedule, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseTowerType(state, composition, fixedType) {
|
||||||
|
if (fixedType !== null) return fixedType;
|
||||||
|
let bestType = 1, bestScore = -1;
|
||||||
|
for (let towerType = 0; towerType < context.TOWERS.length; towerType++) {
|
||||||
|
const tower = context.TOWERS[towerType];
|
||||||
|
let score = 0;
|
||||||
|
for (let enemyType = 0; enemyType < context.ENEMIES.length; enemyType++) {
|
||||||
|
const count = composition.counts[enemyType];
|
||||||
|
if (!count) continue;
|
||||||
|
const enemy = context.ENEMIES[enemyType];
|
||||||
|
if (tower.targetClass !== "both" && tower.targetClass !== enemy.category) continue;
|
||||||
|
const damage = tower.damage * (tower.targetClass === "both" ? tower.bothScale || 1 : 1);
|
||||||
|
const exposedDps = damage / tower.cooldown;
|
||||||
|
const shieldChance = enemyType === 4 ? 1 : 0.25;
|
||||||
|
const shieldMultiplier = towerType === 1 || towerType === 5 ? 1 : 1 - shieldChance * (1 - (enemy.shieldResistance || 0.28));
|
||||||
|
const bossWeight = composition.schedule.some(entry => entry.type === enemyType && entry.boss) ? 1.2 : 1;
|
||||||
|
score += count * Math.sqrt(enemy.hp) * exposedDps * shieldMultiplier * areaFactor[towerType] * bossWeight;
|
||||||
|
}
|
||||||
|
score /= Math.max(1, tower.cost);
|
||||||
|
if (score > bestScore) { bestScore = score; bestType = towerType; }
|
||||||
|
}
|
||||||
|
return bestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePosition(state, owner, range) {
|
||||||
|
const xMin = owner === 0 ? 120 : 1520;
|
||||||
|
const xMax = owner === 0 ? 1440 : 2840;
|
||||||
|
let best = null, bestScore = -1;
|
||||||
|
for (let y = 120; y <= 680; y += 80) for (let x = xMin; x <= xMax; x += 80) {
|
||||||
|
if (!context.validBuildPosition(state, owner, x, y)) continue;
|
||||||
|
const rangeSq = range * range;
|
||||||
|
let score = 0;
|
||||||
|
for (const [px, py, weight] of pathSamples[owner]) {
|
||||||
|
const covered = state.towers.some(tower => tower.owner === owner &&
|
||||||
|
context.distanceSquared(tower.x, tower.y, px, py) <= context.towerRange(state, tower) ** 2);
|
||||||
|
if (!covered && context.distanceSquared(x, y, px, py) <= rangeSq) score += weight;
|
||||||
|
}
|
||||||
|
if (score > bestScore) { bestScore = score; best = [x, y]; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMission(difficulty, fixedType = null) {
|
||||||
|
let random = seedRandom(0xB45710 + difficulty * 7919 + (fixedType === null ? 0 : fixedType * 97));
|
||||||
|
context.Math = Object.create(Math);
|
||||||
|
context.Math.random = () => random();
|
||||||
|
const state = context.initialState({ difficulty, mode: "balance_test" });
|
||||||
|
state.started = true;
|
||||||
|
let wavesCompleted = 0, totalSpawned = 0, totalBreached = 0;
|
||||||
|
const trace = [];
|
||||||
|
const maxTicksPerWave = 24000;
|
||||||
|
|
||||||
|
for (let wave = 1; wave <= 15 && !state.gameOver; wave++) {
|
||||||
|
const composition = nextWaveComposition(state, wave);
|
||||||
|
for (let owner = 0; owner < 2; owner++) {
|
||||||
|
let guard = 32;
|
||||||
|
while (guard-- > 0) {
|
||||||
|
const towerType = chooseTowerType(state, composition, fixedType);
|
||||||
|
const tower = context.TOWERS[towerType];
|
||||||
|
if (state.credits[owner] < tower.cost) break;
|
||||||
|
const position = choosePosition(state, owner, tower.range);
|
||||||
|
if (!position) break;
|
||||||
|
state.credits[owner] -= tower.cost;
|
||||||
|
state.towers.push({ id: state.nextTowerId++, type: towerType, x: position[0], y: position[1], owner, level: 1, cooldown: 0.15, angle: owner === 0 ? 0 : Math.PI });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!context.startWave(state)) break;
|
||||||
|
const beforeSpawn = state.nextEnemyId;
|
||||||
|
let tick = 0;
|
||||||
|
while (state.waveActive && !state.gameOver && tick++ < maxTicksPerWave) {
|
||||||
|
context.updateWave(state, 0.1, { broadcastMessage() {} });
|
||||||
|
context.updateEnemies(state, 0.1);
|
||||||
|
context.updateTowers(state, 0.1);
|
||||||
|
}
|
||||||
|
totalSpawned += state.nextEnemyId - beforeSpawn;
|
||||||
|
totalBreached += Math.max(0, 30 - state.reactorLives);
|
||||||
|
trace.push({wave, lives: state.reactorLives, spawned: state.nextEnemyId - beforeSpawn, towers: state.towers.length, credits: [...state.credits], enemyLeft: state.enemies.length, towerTypes: state.towers.map(t => t.type)});
|
||||||
|
if (tick >= maxTicksPerWave) break;
|
||||||
|
if (!state.gameOver) wavesCompleted++;
|
||||||
|
}
|
||||||
|
return { difficulty, fixedType, wavesCompleted, reactorLives: state.reactorLives, trace,
|
||||||
|
towers: state.towers.length, spawned: totalSpawned, remainingEnemies: state.enemies.length,
|
||||||
|
credits: state.credits.reduce((sum, value) => sum + value, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = [];
|
||||||
|
for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) {
|
||||||
|
baseline.push(runMission(difficulty));
|
||||||
|
}
|
||||||
|
const towerRuns = [];
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) {
|
||||||
|
towerRuns.push(runMission(difficulty, tower));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(baseline.length, 3);
|
||||||
|
assert.ok(towerRuns.length === context.TOWERS.length * 3);
|
||||||
|
assert.ok(baseline.every(result => result.spawned > 0), "mission simulations must use the real wave scheduler");
|
||||||
|
console.log("BALANCE MISSION RESULTS (Nakama wave/combat code, fixed-seed greedy build planner, level-1 towers)");
|
||||||
|
console.log("planner: " + baseline.map(r => `D${r.difficulty} ${r.wavesCompleted}/15 waves, reactor ${r.reactorLives}, ${r.towers} towers`).join(" | "));
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
const values = towerRuns.filter(result => result.fixedType === tower)
|
||||||
|
.map(r => `D${r.difficulty} ${r.wavesCompleted}/15, R${r.reactorLives}, ${r.towers}T`).join(" | ");
|
||||||
|
console.log(`tower ${tower} (${context.TOWERS[tower].targetClass}): ${values}`);
|
||||||
|
}
|
||||||
|
console.log(`BALANCE_MISSION_TEST_OK scenarios=${baseline.length + towerRuns.length}`);
|
||||||
@@ -22,6 +22,56 @@ assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage
|
|||||||
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
|
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[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.");
|
assert.strictEqual(context.TOWERS[6].targetClass, "air", "The Flak tower must only target air units.");
|
||||||
|
assert.strictEqual(context.TOWERS[6].damage, 22, "The air-only Flak tower should receive its focused damage tuning.");
|
||||||
|
assert.strictEqual(context.ENEMIES[5].hp, 620, "The first aerial boss base hull should use the reduced balance value.");
|
||||||
|
assert.strictEqual(context.initialState({ difficulty: 0 }).difficulty, 0, "Easy must not be coerced to the default difficulty.");
|
||||||
|
assert.strictEqual(context.initialState({ difficulty: 0 }).credits[0], context.DIFFICULTIES[0].startCredits);
|
||||||
|
const balanceStorage = new Map();
|
||||||
|
let balanceVersion = 0;
|
||||||
|
const balanceNk = {
|
||||||
|
storageRead(ops) { return ops.map(op => balanceStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
||||||
|
storageWrite(ops) {
|
||||||
|
for (const op of ops) {
|
||||||
|
balanceVersion += 1;
|
||||||
|
balanceStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: String(balanceVersion) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const serverBalance = JSON.parse(context.rpcGetGameBalance({}, {}, balanceNk, "{}"));
|
||||||
|
assert.strictEqual(serverBalance.config.towers[0].damage, 22, "The internal PHP page calls balance read through Nakama's HTTP-key server-to-server RPC context, which has no userId.");
|
||||||
|
const adminBalance = JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}"));
|
||||||
|
assert.strictEqual(adminBalance.config.towers[0].damage, 22);
|
||||||
|
const editedBalance = JSON.parse(JSON.stringify(adminBalance.config));
|
||||||
|
editedBalance.towers[0].damage = 31;
|
||||||
|
editedBalance.enemies[0].hp = 90;
|
||||||
|
editedBalance.admin_tower.range = 700;
|
||||||
|
editedBalance.admin_tower.cooldown = 0.12;
|
||||||
|
assert.throws(() => context.rpcSetGameBalance({ userId: "pilot-b" }, {}, balanceNk, JSON.stringify({ config: editedBalance })), /server-to-server/);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: editedBalance }))).accepted, true);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}")).config.towers[0].damage, 31);
|
||||||
|
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.enemies[0].hp, 90);
|
||||||
|
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.admin_tower.range, 700);
|
||||||
|
assert.throws(() => context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: { towers: [], enemies: [] } })), /invalid balance schema/);
|
||||||
|
const legacyStorage = new Map();
|
||||||
|
const legacyKey = `game_balance:${context.SYSTEM_USER_ID}:global`;
|
||||||
|
const legacyConfig = JSON.parse(JSON.stringify(context.defaultBalanceConfig()));
|
||||||
|
legacyConfig.schema = 1;
|
||||||
|
legacyConfig.towers[0].damage = 31;
|
||||||
|
legacyConfig.towers[6].damage = 20;
|
||||||
|
legacyConfig.enemies[5].hp = 740;
|
||||||
|
legacyStorage.set(legacyKey, { collection: "game_balance", key: "global", userId: context.SYSTEM_USER_ID,
|
||||||
|
value: { config: legacyConfig, updated_at: 1 }, permissionRead: 0, permissionWrite: 0, version: "7" });
|
||||||
|
const migrationNk = {
|
||||||
|
storageRead(ops) { return ops.map(op => legacyStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
||||||
|
storageWrite(ops) { for (const op of ops) legacyStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: "8" }); }
|
||||||
|
};
|
||||||
|
const migratedBalance = context.readBalanceConfig(migrationNk);
|
||||||
|
assert.strictEqual(migratedBalance.config.schema, 3);
|
||||||
|
assert.strictEqual(migratedBalance.config.towers[0].damage, 31, "Balance migrations must preserve unrelated admin tuning.");
|
||||||
|
assert.strictEqual(migratedBalance.config.towers[6].damage, 22, "Unchanged legacy Flak defaults should migrate to the new value.");
|
||||||
|
assert.strictEqual(migratedBalance.config.enemies[5].hp, 620, "Unchanged legacy boss HP should migrate to the new value.");
|
||||||
|
assert.strictEqual(migratedBalance.config.admin_tower.range, 650, "Older balance settings should receive the safer admin range.");
|
||||||
|
assert.strictEqual(legacyStorage.get(legacyKey).value.config.schema, 3, "Migrated balance should persist for future matches.");
|
||||||
const hostId = "11111111-1111-1111-1111-111111111111";
|
const hostId = "11111111-1111-1111-1111-111111111111";
|
||||||
const friendId = "22222222-2222-2222-2222-222222222222";
|
const friendId = "22222222-2222-2222-2222-222222222222";
|
||||||
const sentInvites = [];
|
const sentInvites = [];
|
||||||
@@ -49,6 +99,20 @@ assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, J
|
|||||||
assert.strictEqual(sentInvites.length, 1, "A rate-limited invitation must not send another notification.");
|
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({ 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.");
|
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 nameLookupNk = {
|
||||||
|
sqlQuery(_query, params) {
|
||||||
|
assert.strictEqual(params[0], "Emporex2", "Player search must use parameter binding.");
|
||||||
|
return [{ id: friendId, display_name: "Emporex2" }];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {}, nameLookupNk, JSON.stringify({ display_name: "Emporex2" }))),
|
||||||
|
{ found: true, user_id: friendId, display_name: "Emporex2" });
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
|
||||||
|
{ sqlQuery: () => [{ id: friendId, display_name: "Same" }, { id: "third", display_name: "Same" }] },
|
||||||
|
JSON.stringify({ display_name: "Same" }))).error, "ambiguous", "Duplicate display names must not invite the wrong account.");
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
|
||||||
|
{ sqlQuery: () => [{ id: hostId, display_name: "Self" }] },
|
||||||
|
JSON.stringify({ display_name: "Self" }))).error, "not_found");
|
||||||
const flakState = {
|
const flakState = {
|
||||||
towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }],
|
towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }],
|
||||||
enemies: [
|
enemies: [
|
||||||
@@ -167,6 +231,36 @@ assert.strictEqual(privateStats.platforms.android, 1);
|
|||||||
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
|
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
|
||||||
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
|
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
|
||||||
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
|
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
|
||||||
|
const metricSource = "guest_0123456789abcdef0123456789abcdef";
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "play_start", source: metricSource, sequence: 1, at: Date.now() }))), { accepted: true, duplicate: false });
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: false });
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: true });
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "tower_build", tower_type: 7, source: metricSource, sequence: 3 }))).accepted, false, "Admin towers must not enter real tower statistics.");
|
||||||
|
context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.16" }));
|
||||||
|
const metricActivity = metricStorage.get("player_activity:pilot-metric-a:summary").value;
|
||||||
|
assert.strictEqual(metricActivity.tower_builds[1], 1, "Repeated telemetry must not double-count towers.");
|
||||||
|
assert.ok(metricActivity.last_played > 0, "Game sessions should track when the player last played.");
|
||||||
|
assert.strictEqual(context.isInternalTestUsername("godot_a_2323883"), true);
|
||||||
|
assert.strictEqual(context.isInternalTestUsername("social_b_2289087"), true);
|
||||||
|
assert.strictEqual(context.isInternalTestUsername("Emporex"), false);
|
||||||
|
const directoryNk = {
|
||||||
|
sqlQuery(sql) {
|
||||||
|
if (sql.includes("count(*) AS total")) return [{ total: 7, active_today: 3 }];
|
||||||
|
if (sql.includes("WHERE id=$1::uuid")) return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000 }];
|
||||||
|
return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000, last_seen: 2000, last_played: 3000, tower_build_total: 2 }];
|
||||||
|
},
|
||||||
|
storageRead() { return [{ value: { tower_builds: [0, 2, 0, 0, 0, 0, 0], tower_build_total: 2, last_seen: 2000, last_played: 3000 } }]; }
|
||||||
|
};
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcListRegisteredPlayers({}, logger, directoryNk, "{}")).total, 7);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcGetRegisteredPlayer({}, logger, directoryNk,
|
||||||
|
JSON.stringify({ user_id: "11111111-1111-1111-1111-111111111111" }))).player.tower_builds[1], 2);
|
||||||
|
assert.throws(() => context.rpcListRegisteredPlayers({ userId: "pilot-a" }, logger, directoryNk, "{}"), /server-to-server/);
|
||||||
|
nk.storageRead = metricNk.storageRead.bind(metricNk);
|
||||||
|
nk.storageWrite = metricNk.storageWrite.bind(metricNk);
|
||||||
|
|
||||||
const promoStorage = new Map();
|
const promoStorage = new Map();
|
||||||
let promoVersion = 0;
|
let promoVersion = 0;
|
||||||
@@ -312,17 +406,57 @@ state = context.matchLoop({}, logger, nk, dispatcher, 5, state, [
|
|||||||
assert.strictEqual(state.towers.length, 2);
|
assert.strictEqual(state.towers.length, 2);
|
||||||
assert.strictEqual(state.credits[0], 310);
|
assert.strictEqual(state.credits[0], 310);
|
||||||
assert.strictEqual(state.credits[1], 250);
|
assert.strictEqual(state.credits[1], 250);
|
||||||
|
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_builds[0], 1, "Accepted online tower builds should be counted on the server.");
|
||||||
|
assert.strictEqual(metricStorage.get("player_activity:pilot-b:summary").value.tower_builds[1], 1);
|
||||||
|
|
||||||
state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
|
state = context.matchLoop({}, logger, nk, dispatcher, 51, state,
|
||||||
|
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 }),
|
||||||
|
message(presenceB, 15, { password: "incorrect" }),
|
||||||
|
message(presenceB, 16, { amount: 500 })]).state;
|
||||||
|
assert.strictEqual(state.towers.length, 2, "Admin tower builds must be rejected before developer authorization.");
|
||||||
|
assert.strictEqual(state.credits[1], 250, "Unauthorized player must not be able to adjust test energy.");
|
||||||
|
assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "Wrong developer password must not authorize the player.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 52, state,
|
||||||
|
[message(presenceB, 15, { password: "Spring.3" })]).state;
|
||||||
|
assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "A non-admin account must not gain developer access even with the password.");
|
||||||
|
assert.strictEqual(context.DEV_ADMIN_USER_IDS["pilot-a"], undefined);
|
||||||
|
assert.strictEqual(context.DEV_ADMIN_USER_IDS["6605c44c-17a2-4cc4-bf3e-e772fd6229b0"], true);
|
||||||
|
context.DEV_ADMIN_USER_IDS["pilot-a"] = true;
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 53, state,
|
||||||
|
[message(presenceA, 15, { password: "Spring.3" })]).state;
|
||||||
|
assert.strictEqual(state.devAuthorized["pilot-a"], true, "Correct developer password should authorize this player for the match.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 54, state,
|
||||||
|
[message(presenceA, 16, { amount: 500 })]).state;
|
||||||
|
assert.strictEqual(state.credits[0], 810, "Authorized test energy addition should change server credits by 500.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 55, state,
|
||||||
|
[message(presenceA, 16, { amount: -500 })]).state;
|
||||||
|
assert.strictEqual(state.credits[0], 310, "Authorized test energy removal should change server credits by 500.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 56, state,
|
||||||
|
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 })]).state;
|
||||||
|
assert.ok(state.towers.some(tower => tower.type === 7 && tower.owner === 0), "Authorized player may build the admin turret anywhere on the co-op map.");
|
||||||
|
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_build_total, 1, "Admin towers are excluded from player statistics.");
|
||||||
|
const adminCombatState = {
|
||||||
|
balance: state.balance,
|
||||||
|
towers: [{ id: 99, type: 7, x: 2200, y: 600, owner: 0, level: 1, cooldown: 0, angle: 0 }],
|
||||||
|
enemies: [
|
||||||
|
{ id: 99, type: 8, x: 2250, y: 600, route_id: 1, path_index: 1, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 },
|
||||||
|
{ id: 100, type: 8, x: 2250, y: 610, route_id: 0, path_index: 1, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 }
|
||||||
|
],
|
||||||
|
shots: [], credits: [0, 0]
|
||||||
|
};
|
||||||
|
context.updateTowers(adminCombatState, 0.1);
|
||||||
|
assert.deepStrictEqual(adminCombatState.enemies.map(enemy => enemy.id), [100], "The admin turret must one-shot shielded enemies only on its own route.");
|
||||||
|
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 57, state, [message(presenceA, 13, {})]).state;
|
||||||
assert.strictEqual(state.wave, 1);
|
assert.strictEqual(state.wave, 1);
|
||||||
assert.strictEqual(state.waveActive, true);
|
assert.strictEqual(state.waveActive, true);
|
||||||
assert.strictEqual(context.snapshot(state, 6).wave_types_remaining.length, state.spawnRemaining);
|
assert.strictEqual(context.snapshot(state, 56).wave_types_remaining.length, state.spawnRemaining);
|
||||||
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
|
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
|
||||||
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
|
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
|
||||||
for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
|
for (let tick = 57; tick < 80; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
|
||||||
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
|
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
|
||||||
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
|
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
|
||||||
const compactSnapshot = context.snapshot(state, 29);
|
const compactSnapshot = context.snapshot(state, 79);
|
||||||
const untrimmedSnapshot = { ...compactSnapshot, towers: state.towers, enemies: state.enemies };
|
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(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.towers.every(tower => !Object.hasOwn(tower, "cooldown")));
|
||||||
@@ -330,18 +464,84 @@ assert.ok(compactSnapshot.enemies.every(enemy => !Object.hasOwn(enemy, "shield_r
|
|||||||
assert.ok(state.towers.every(tower => Object.hasOwn(tower, "cooldown")), "Network serialization must not alter simulation state.");
|
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);
|
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;
|
state = context.matchLoop({}, logger, nk, dispatcher, 80, state, [message(presenceA, 11, { tower_id: towerA.id })]).state;
|
||||||
assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
|
assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
|
||||||
state = context.matchLoop({}, logger, nk, dispatcher, 31, state, [message(presenceA, 12, { tower_id: towerA.id })]).state;
|
state = context.matchLoop({}, logger, nk, dispatcher, 81, state, [message(presenceA, 12, { tower_id: towerA.id })]).state;
|
||||||
assert.ok(!state.towers.some(tower => tower.id === towerA.id));
|
assert.ok(!state.towers.some(tower => tower.id === towerA.id));
|
||||||
|
|
||||||
state = context.matchLeave({}, logger, nk, dispatcher, 32, state, [presenceB]).state;
|
state = context.matchLeave({}, logger, nk, dispatcher, 82, state, [presenceB]).state;
|
||||||
assert.strictEqual(state.started, true);
|
assert.strictEqual(state.started, true);
|
||||||
assert.strictEqual(state.pausedForReconnect, true);
|
assert.strictEqual(state.pausedForReconnect, true);
|
||||||
assert.strictEqual(context.snapshot(state, 32).players[1].connected, false);
|
assert.strictEqual(context.snapshot(state, 82).players[1].connected, false);
|
||||||
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 33, state, presenceB, {}).accept, true);
|
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 83, state, presenceB, {}).accept, true);
|
||||||
state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).state;
|
state = context.matchJoin({}, logger, nk, dispatcher, 83, state, [presenceB]).state;
|
||||||
assert.strictEqual(state.pausedForReconnect, false);
|
assert.strictEqual(state.pausedForReconnect, false);
|
||||||
assert.strictEqual(state.slots["pilot-b"], 1);
|
assert.strictEqual(state.slots["pilot-b"], 1);
|
||||||
|
|
||||||
|
const bossState = context.initialState({ difficulty: 1 });
|
||||||
|
bossState.wave = 5;
|
||||||
|
bossState.waveSchedule = [{ type: 5, boss: true }];
|
||||||
|
const originalRandom = context.Math.random;
|
||||||
|
context.Math.random = () => 0;
|
||||||
|
context.spawnEnemy(bossState);
|
||||||
|
context.Math.random = originalRandom;
|
||||||
|
assert.ok(bossState.enemies[0].shield, "The first boss shield test needs a shielded ship.");
|
||||||
|
assert.ok(Math.abs(bossState.enemies[0].max_hp -
|
||||||
|
context.ENEMIES[5].hp * (1 + 4 * 0.20 + 2 * 0.09) * 3.25 * context.DIFFICULTIES[1].hp * 0.5,
|
||||||
|
) < 0.0001, "Wave-five mothership hull should be reduced by 50%.");
|
||||||
|
assert.ok(Math.abs(bossState.enemies[0].max_shield_hp - bossState.enemies[0].max_hp * 0.55) < 0.0001,
|
||||||
|
"The wave-five shield remains at its already-halved absolute strength while hull is reduced.");
|
||||||
|
const laterBossState = context.initialState({ difficulty: 1 });
|
||||||
|
laterBossState.wave = 10;
|
||||||
|
laterBossState.waveSchedule = [{ type: 8, boss: true }];
|
||||||
|
context.Math.random = () => 0;
|
||||||
|
context.spawnEnemy(laterBossState);
|
||||||
|
context.Math.random = originalRandom;
|
||||||
|
assert.strictEqual(laterBossState.enemies[0].max_shield_hp, laterBossState.enemies[0].max_hp * 0.55,
|
||||||
|
"Later bosses retain their original shield balance.");
|
||||||
|
|
||||||
|
const flakAimState = {
|
||||||
|
towers: [{ id: 1, type: 6, x: 0, y: 0, owner: 0, level: 1, cooldown: 1, angle: 0 }],
|
||||||
|
enemies: [{ id: 1, type: 0, x: 100, y: 0, hp: 100, shield: false }],
|
||||||
|
shots: []
|
||||||
|
};
|
||||||
|
context.updateTowers(flakAimState, 0.1);
|
||||||
|
assert.ok(Math.abs(flakAimState.towers[0].angle - Math.PI) < 0.0001,
|
||||||
|
"Flak visualization must include the 180-degree sprite orientation correction.");
|
||||||
|
|
||||||
|
const diagnosticStore = new Map();
|
||||||
|
const diagnosticNk = {
|
||||||
|
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
|
||||||
|
storageRead(ops) { return ops.map(op => diagnosticStore.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
||||||
|
storageWrite(ops) { for (const op of ops) diagnosticStore.set(`${op.collection}:${op.userId}:${op.key}`, { key: op.key, value: op.value }); },
|
||||||
|
storageList(userId, collection) {
|
||||||
|
return { objects: [...diagnosticStore.entries()].filter(([key]) => key.startsWith(`${collection}:${userId}:`)).map(([, object]) => object), cursor: "" };
|
||||||
|
},
|
||||||
|
storageDelete(ops) { for (const op of ops) diagnosticStore.delete(`${op.collection}:${op.userId}:${op.key}`); }
|
||||||
|
};
|
||||||
|
const diagnosticMatchId = "sample-match.123";
|
||||||
|
let diagState = context.initialState({ difficulty: 1 });
|
||||||
|
diagState = context.matchJoin({ matchId: diagnosticMatchId }, logger, diagnosticNk, dispatcher, 1, diagState,
|
||||||
|
[{ userId: hostId, username: "Host" }, { userId: friendId, username: "Guest" }]).state;
|
||||||
|
const report = { match_id: diagnosticMatchId, version: "0.26", records: [
|
||||||
|
{ event: "window", utc: "2026-10-10T13:00:00", snapshots: 90, gap_p95_ms: 103, player_name: "must_not_store" },
|
||||||
|
{ event: "end", utc: "2026-10-10T13:00:01", reason: "left_match" }
|
||||||
|
] };
|
||||||
|
const firstReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, JSON.stringify(report)));
|
||||||
|
const secondReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: friendId }, logger, diagnosticNk, JSON.stringify(report)));
|
||||||
|
assert.strictEqual(firstReport.round_id, secondReport.round_id, "Both players need one shared round ID.");
|
||||||
|
assert.strictEqual(firstReport.slot, 0);
|
||||||
|
assert.strictEqual(secondReport.slot, 1);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: "stranger" }, logger, diagnosticNk, JSON.stringify(report))).error,
|
||||||
|
"not_participant", "A third account cannot attach data to the round.");
|
||||||
|
const listedReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk, "{}")).reports;
|
||||||
|
assert.strictEqual(listedReports.length, 2);
|
||||||
|
assert.ok(!JSON.stringify(listedReports).includes("must_not_store"), "The server must discard arbitrary client strings.");
|
||||||
|
const pairReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk,
|
||||||
|
JSON.stringify({ round_id: firstReport.round_id }))).reports;
|
||||||
|
assert.strictEqual(pairReports.length, 2);
|
||||||
|
assert.strictEqual(pairReports[0].round_id, pairReports[1].round_id);
|
||||||
|
assert.ok(!JSON.stringify(pairReports).includes("must_not_store"), "Detailed reports must also omit unapproved strings.");
|
||||||
|
assert.throws(() => context.rpcListOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, "{}"), /server-to-server/);
|
||||||
|
|
||||||
console.log("VOID_BASTION_SERVER_SIM_OK");
|
console.log("VOID_BASTION_SERVER_SIM_OK");
|
||||||
@@ -32,7 +32,7 @@ func _run() -> void:
|
|||||||
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
|
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
|
||||||
var info_data = JSON.parse_string(info.payload)
|
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 is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.")
|
||||||
assert(info_data.get("version") == "0.25", "Der Server muss die aktuelle Runtime 0.25 geladen haben: %s" % info_data)
|
assert(info_data.get("version") == "0.27", "Der Server muss die aktuelle Runtime 0.27 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_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)
|
var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ func _run() -> void:
|
|||||||
game.multiplayer_manager.matchmaking_state = "hosting"
|
game.multiplayer_manager.matchmaking_state = "hosting"
|
||||||
game.multiplayer_manager.private_room_code = "ABC123"
|
game.multiplayer_manager.private_room_code = "ABC123"
|
||||||
assert(game._can_invite_friends())
|
assert(game._can_invite_friends())
|
||||||
|
game.multiplayer_manager.matchmaking_state = "lobby"
|
||||||
|
assert(game._can_invite_friends(), "Im privaten Warteraum muss EINLADEN sichtbar bleiben.")
|
||||||
game.multiplayer_manager.matchmaking_state = "offline"
|
game.multiplayer_manager.matchmaking_state = "offline"
|
||||||
assert(not game._can_invite_friends(), "Ein alter Raumcode darf keine Einladung mehr ermöglichen.")
|
assert(not game._can_invite_friends(), "Ein alter Raumcode darf keine Einladung mehr ermöglichen.")
|
||||||
game.coop_online_mode = true
|
game.coop_online_mode = true
|
||||||
@@ -66,6 +68,15 @@ func _run() -> void:
|
|||||||
online_manager._flush_pending_snapshots()
|
online_manager._flush_pending_snapshots()
|
||||||
assert(received.size() == 1 and received[0].tick == 7)
|
assert(received.size() == 1 and received[0].tick == 7)
|
||||||
assert(received[0].shots.size() == online_manager.MAX_PENDING_SNAPSHOTS)
|
assert(received[0].shots.size() == online_manager.MAX_PENDING_SNAPSHOTS)
|
||||||
|
var diagnostic_bundle: String = online_manager.get_diagnostic_bundle()
|
||||||
|
assert(diagnostic_bundle.contains("\"event\":\"window\""), "Die Online-Diagnose muss einen kopierbaren Messbericht erzeugen.")
|
||||||
|
assert(diagnostic_bundle.contains("\"queue_dropped\":3"), "Verworfene Zustände im Rückstau müssen erfasst werden.")
|
||||||
|
online_manager._finish_match_diagnostics("test_finished")
|
||||||
|
assert(online_manager.diagnostic_match_key.is_empty())
|
||||||
|
assert(online_manager.diagnostic_finished_match_key == "test-match".sha256_text().substr(0, 12))
|
||||||
|
online_manager._on_match_state({"match_id": "test-match", "op_code": online_manager.OP_SNAPSHOT,
|
||||||
|
"data": JSON.stringify({"tick": 8, "shots": []})})
|
||||||
|
assert(online_manager.diagnostic_file == null, "Nach dem Rundenende darf kein zweiter Diagnosebericht geöffnet werden.")
|
||||||
game._select_map(0)
|
game._select_map(0)
|
||||||
game._prepare_draw_geometry()
|
game._prepare_draw_geometry()
|
||||||
assert(not game.draw_geometry_dirty and not game.road_tile_cache.is_empty())
|
assert(not game.draw_geometry_dirty and not game.road_tile_cache.is_empty())
|
||||||
@@ -79,5 +90,19 @@ func _run() -> void:
|
|||||||
game._prepare_draw_geometry()
|
game._prepare_draw_geometry()
|
||||||
var partner_cells: Array = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner)
|
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.")
|
assert(not partner_cells.is_empty(), "Die gespiegelte Koop-Seite braucht eigene Baufelder.")
|
||||||
|
assert(partner_cells.all(func(cell: Dictionary) -> bool: return cell.rect.get_center().x > game.COOP_MIRROR_X),
|
||||||
|
"Für Spieler 1 muss nur die rechte Seite als Partnerseite markiert sein.")
|
||||||
|
game.coop_online_mode = true
|
||||||
|
game.local_player_slot = 1
|
||||||
|
game.draw_geometry_dirty = true
|
||||||
|
game._prepare_draw_geometry()
|
||||||
|
partner_cells = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner)
|
||||||
|
assert(not partner_cells.is_empty() and partner_cells.all(func(cell: Dictionary) -> bool: return cell.rect.get_center().x < game.COOP_MIRROR_X),
|
||||||
|
"Für Spieler 2 muss nur die linke Seite als Partnerseite markiert sein.")
|
||||||
|
game.camera_offset.x = game.COOP_WORLD_WIDTH - game.SCREEN.x
|
||||||
|
game._toggle_coop_side()
|
||||||
|
assert(game.status_text == "PARTNER-SEITE", "Der Seitenwechsel muss Spieler 2 links als Partnerseite benennen.")
|
||||||
|
game._toggle_coop_side()
|
||||||
|
assert(game.status_text == "DEINE SEITE", "Der Seitenwechsel muss Spieler 2 rechts als eigene Seite benennen.")
|
||||||
print("VOID_BASTION_ONLINE_SNAPSHOT_OK")
|
print("VOID_BASTION_ONLINE_SNAPSHOT_OK")
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
@@ -378,6 +378,7 @@ func _run() -> void:
|
|||||||
var boss: Dictionary = game.get("enemies")[-1]
|
var boss: Dictionary = game.get("enemies")[-1]
|
||||||
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")
|
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")
|
||||||
assert(boss.type == 5, "Das Kommandoschiff sollte die schwere Belagerer-Klasse verwenden.")
|
assert(boss.type == 5, "Das Kommandoschiff sollte die schwere Belagerer-Klasse verwenden.")
|
||||||
|
assert(is_equal_approx(float(boss.max_hp), float(boss.base_max_hp)), "Die Hülle des ersten Bosses muss im neuen Testbalancing halbiert sein.")
|
||||||
game.call("_start_game")
|
game.call("_start_game")
|
||||||
var crystals_before: int = game.get("core_crystals")
|
var crystals_before: int = game.get("core_crystals")
|
||||||
game.set("wave", 15)
|
game.set("wave", 15)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://kwjokxtwfr0u
|
||||||
Reference in new issue
Block a user