Add private multiplayer room codes

This commit is contained in:
Emporex committed 2026-10-04 04:10:20 +02:00
1 parent 7362ca0f02
commit 19b1a67773
8 files changed
+341 -24

No files matched your search

+4 -4
View File
@@ -1,4 +1,4 @@
# Void Bastion Online 0.19 # Void Bastion Online 0.20
## Fertiger Stand ## Fertiger Stand
@@ -22,9 +22,9 @@ Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`.
## Multiplayer ## Multiplayer
Die Godot-Anbindung öffnet nach der Anmeldung eine WSS/WS-Echtzeitverbindung. Die schnelle Spielersuche vermittelt exakt zwei Spieler mit derselben Suchart in eine serverautoritative Koop-Sitzung. Der Matchserver verwaltet Teilnehmer, gemeinsame Sitzungsdaten und die Weiterleitung zulässiger Spielnachrichten. Die Godot-Anbindung öffnet nach der Anmeldung eine WSS/WS-Echtzeitverbindung. Die schnelle Spielersuche vermittelt exakt zwei passende Spieler. Alternativ erstellt ein Spieler einen privaten Raum mit einem sechsstelligen Code, dem der zweite Spieler gezielt beitritt.
Der lokale KI-Koop bleibt unabhängig davon spielbar. Die vollständige Synchronisation von Türmen, Gegnern und gemeinsamem Reaktor zwischen zwei sichtbaren Spielclients ist der nächste Gameplay-Schritt; Konto, Transport, Vermittlung und Server-Match sind bereits funktionsfähig. Der autoritative Matchserver verwaltet Spielerplätze, getrennte Energie, Türme, Gegner, Wellen und die gemeinsamen Reaktorleben. Beide Clients erhalten denselben geprüften Spielzustand. Der lokale KI-Koop bleibt unabhängig davon spielbar.
## Adressen ## Adressen
@@ -41,4 +41,4 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag `
- echter Onlinetest im Heimnetz: `godot --headless --path . --script res://tests/online_integration_test.gd` - echter Onlinetest im Heimnetz: `godot --headless --path . --script res://tests/online_integration_test.gd`
- Produktionstest: `VOID_BASTION_TEST_HOST=api.void-bastion.gergames.de VOID_BASTION_TEST_SCHEME=https VOID_BASTION_TEST_PORT=443 godot --headless --path . --script res://tests/online_integration_test.gd` - Produktionstest: `VOID_BASTION_TEST_HOST=api.void-bastion.gergames.de VOID_BASTION_TEST_SCHEME=https VOID_BASTION_TEST_PORT=443 godot --headless --path . --script res://tests/online_integration_test.gd`
Der Onlinetest prüft zwei Gastanmeldungen, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, Matchmaking und den Beitritt beider Clients zur selben autoritativen Partie. Der Onlinetest prüft zwei Gastanmeldungen, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, schnelles Matchmaking, autoritative Spielzustände sowie Erstellen und Beitreten eines privaten Raums per Code.
+7 -6
View File
@@ -1,4 +1,4 @@
# Void Bastion – spielbarer Prototyp 0.19 # Void Bastion – spielbarer Prototyp 0.20
Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt. Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt.
@@ -39,10 +39,10 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
- dauerhaftes lokales Gastprofil mit stabiler Spieler-ID als Grundlage für Apple-, Google- und Windows-Anmeldung - dauerhaftes lokales Gastprofil mit stabiler Spieler-ID als Grundlage für Apple-, Google- und Windows-Anmeldung
- eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte - eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte
- wartende, lokal gespeicherte Highscore-Einträge für die spätere sichere Serverübertragung - wartende, lokal gespeicherte Highscore-Einträge für die spätere sichere Serverübertragung
- eigener Multiplayerbereich mit schnellem Spiel, Freundeseinladung und privatem Raum als vorbereitete Sucharten - eigener Multiplayerbereich mit schneller Spielersuche sowie privaten Koop-Räumen mit sechsstelligen Raumcodes
- Vorschau einer symmetrisch gespiegelten Zwei-Spieler-Arena mit gemeinsamem Reaktor - Vorschau einer symmetrisch gespiegelten Zwei-Spieler-Arena mit gemeinsamem Reaktor
- getrennte Ressourcenmodelle für beide Koop-Spieler sowie gemeinsam geteilte Reaktorleben - getrennte Ressourcenmodelle für beide Koop-Spieler sowie gemeinsam geteilte Reaktorleben
- sichtbarer, abbrechbarer Vorbereitungszustand für die spätere servergestützte Spielersuche - sichtbarer und jederzeit abbrechbarer Wartezustand für schnelle und private Online-Partien
- bereits spielbarer lokaler Koop-Modus mit einem KI-Partner auf der gespiegelten Kartenhälfte - bereits spielbarer lokaler Koop-Modus mit einem KI-Partner auf der gespiegelten Kartenhälfte
- nahezu verdoppelte Koop-Weltbreite: Beide Spieler erhalten eine vollständige Kartenhälfte in ursprünglicher Größe - nahezu verdoppelte Koop-Weltbreite: Beide Spieler erhalten eine vollständige Kartenhälfte in ursprünglicher Größe
- Spiegelachse direkt durch den vorhandenen Kartenreaktor, sodass Hintergrundreaktor, gemeinsamer Zielpunkt und Laufwege wieder zusammenpassen - Spiegelachse direkt durch den vorhandenen Kartenreaktor, sodass Hintergrundreaktor, gemeinsamer Zielpunkt und Laufwege wieder zusammenpassen
@@ -102,7 +102,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
## Steuerung ## Steuerung
> **Online-Stand 0.19:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets und das serverautoritative Zwei-Spieler-Matchmaking sind eingebaut und gegen Unraid getestet. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let’s-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`. > **Online-Stand 0.20:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop und private Räume per Raumcode sind eingebaut. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let’s-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`.
- Turm unten auswählen und auf eine freie Plattformfläche klicken oder tippen. - Turm unten auswählen und auf eine freie Plattformfläche klicken oder tippen.
- Am PC folgt der Reichweitenkreis vor dem Bau dem Mauszeiger. Auf Mobilgeräten markiert der erste Tipp den Bauplatz samt Reichweite; der zweite Tipp auf dasselbe Feld bestätigt den Bau. - Am PC folgt der Reichweitenkreis vor dem Bau dem Mauszeiger. Auf Mobilgeräten markiert der erste Tipp den Bauplatz samt Reichweite; der zweite Tipp auf dasselbe Feld bestätigt den Bau.
@@ -117,6 +117,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
- `P`, Escape oder die Pause-Schaltfläche pausiert das Spiel. - `P`, Escape oder die Pause-Schaltfläche pausiert das Spiel.
- Nach Welle 15 kann die erfolgreiche Mission beendet oder freiwillig im Endlosmodus fortgesetzt werden. - Nach Welle 15 kann die erfolgreiche Mission beendet oder freiwillig im Endlosmodus fortgesetzt werden.
- Unter **Multiplayer → Mit KI-Partner** startet die gespiegelte Koop-Arena sofort. Du baust auf der vollständigen linken Karte, die KI auf deren vollständiger Spiegelung; beide Seiten verteidigen denselben Reaktor. Mit **KI >** und **< ICH** wird ohne Verkleinerung zwischen beiden Seiten gewechselt. - Unter **Multiplayer → Mit KI-Partner** startet die gespiegelte Koop-Arena sofort. Du baust auf der vollständigen linken Karte, die KI auf deren vollständiger Spiegelung; beide Seiten verteidigen denselben Reaktor. Mit **KI >** und **< ICH** wird ohne Verkleinerung zwischen beiden Seiten gewechselt.
- Unter **Multiplayer → Raum erstellen** wird ein privater Koop-Raum angelegt und ein sechsstelliger Code angezeigt. Der Mitspieler wählt **Raum beitreten** und gibt diesen Code ein.
## Nächste Produktionsschritte ## Nächste Produktionsschritte
@@ -125,8 +126,8 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
3. Kampagnenfortschritt und zusätzliche Bossvarianten 3. Kampagnenfortschritt und zusätzliche Bossvarianten
4. Freischaltmenü für neue Türme und Karten mit den bereits gespeicherten Kernkristallen 4. Freischaltmenü für neue Türme und Karten mit den bereits gespeicherten Kernkristallen
5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden 5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden
6. Autoritativen Koop-Spielserver, Räume, Freundeseinladungen und Spielersuche anbinden 6. Private Raumcodes und die autoritative Koop-Synchronisierung auf echten Android-, iOS- und Windows-Geräten testen
7. Die gespiegelte Koop-Arena spielbar machen und anschließend über echte Geräte synchron testen 7. Freundesliste, Einladungen und Wiederverbindung nach kurzzeitigem Netzverlust ergänzen
8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion 8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion
## Godot Agent Tools MCP ## Godot Agent Tools MCP
+105 -9
View File
@@ -137,7 +137,10 @@ var friend_match_button := Rect2(475, 665, 300, 64)
var private_match_button := Rect2(825, 665, 300, 64) var private_match_button := Rect2(825, 665, 300, 64)
var ai_match_button := Rect2(1175, 665, 300, 64) var ai_match_button := Rect2(1175, 665, 300, 64)
var cancel_search_button := Rect2(625, 650, 350, 64) var cancel_search_button := Rect2(625, 650, 350, 64)
var room_code_input_rect := Rect2(510, 340, 580, 90)
var join_room_button := Rect2(625, 525, 350, 64)
var coop_side_button := Rect2(920, 18, 140, 54) var coop_side_button := Rect2(920, 18, 140, 54)
var room_code_input := ""
@onready var ads_manager = $AdsManager @onready var ads_manager = $AdsManager
@onready var account_manager = $AccountManager @onready var account_manager = $AccountManager
@@ -366,6 +369,20 @@ func _update_effects(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if game_mode == "room_code_entry" and event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
_hide_virtual_keyboard()
game_mode = "multiplayer_menu"
elif event.keycode == KEY_BACKSPACE:
room_code_input = room_code_input.left(maxi(0, room_code_input.length() - 1))
elif event.keycode == KEY_ENTER:
_join_entered_room()
elif event.unicode > 0 and room_code_input.length() < 6:
var character := char(event.unicode).to_upper()
if "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".contains(character):
room_code_input += character
queue_redraw()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_P, KEY_ESCAPE] and game_mode in ["playing", "paused"]: if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_P, KEY_ESCAPE] and game_mode in ["playing", "paused"]:
_toggle_pause() _toggle_pause()
return return
@@ -409,13 +426,23 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
elif quick_match_button.has_point(position): elif quick_match_button.has_point(position):
_open_matchmaking("quick") _open_matchmaking("quick")
elif friend_match_button.has_point(position): elif friend_match_button.has_point(position):
_open_matchmaking("friend") _open_room_code_entry()
elif private_match_button.has_point(position): elif private_match_button.has_point(position):
_open_matchmaking("private") _open_private_room()
elif ai_match_button.has_point(position): elif ai_match_button.has_point(position):
_start_coop_ai_game() _start_coop_ai_game()
queue_redraw() queue_redraw()
return return
if game_mode == "room_code_entry":
if multiplayer_back_button.has_point(position):
_hide_virtual_keyboard()
game_mode = "multiplayer_menu"
elif room_code_input_rect.has_point(position):
_show_room_code_keyboard()
elif join_room_button.has_point(position):
_join_entered_room()
queue_redraw()
return
if game_mode == "matchmaking": if game_mode == "matchmaking":
if cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): if cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position):
multiplayer_manager.cancel_search() multiplayer_manager.cancel_search()
@@ -607,6 +634,41 @@ func _open_matchmaking(mode: String) -> void:
queue_redraw() queue_redraw()
func _open_private_room() -> void:
multiplayer_manager.reset_coop_session(int(_difficulty().start_credits), 30)
multiplayer_manager.create_private_room(selected_difficulty)
game_mode = "matchmaking"
queue_redraw()
func _open_room_code_entry() -> void:
room_code_input = ""
game_mode = "room_code_entry"
_show_room_code_keyboard()
queue_redraw()
func _join_entered_room() -> void:
if room_code_input.length() != 6:
_show_status("DER RAUMCODE MUSS SECHS ZEICHEN HABEN")
return
_hide_virtual_keyboard()
multiplayer_manager.reset_coop_session(int(_difficulty().start_credits), 30)
multiplayer_manager.join_private_room(room_code_input)
game_mode = "matchmaking"
queue_redraw()
func _show_room_code_keyboard() -> void:
if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD):
DisplayServer.virtual_keyboard_show(room_code_input, room_code_input_rect, DisplayServer.KEYBOARD_TYPE_DEFAULT, 6, room_code_input.length(), room_code_input.length())
func _hide_virtual_keyboard() -> void:
if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD):
DisplayServer.virtual_keyboard_hide()
func _start_game() -> void: func _start_game() -> void:
coop_online_mode = false coop_online_mode = false
credits = int(_difficulty().start_credits) credits = int(_difficulty().start_credits)
@@ -1111,7 +1173,7 @@ func _load_progress() -> void:
func _draw() -> void: func _draw() -> void:
if game_mode in ["menu", "profile", "multiplayer_menu", "matchmaking"]: if game_mode in ["menu", "profile", "multiplayer_menu", "room_code_entry", "matchmaking"]:
draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, SCREEN), false, Color("f3f6fb")) draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, SCREEN), false, Color("f3f6fb"))
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06)) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06))
if game_mode == "menu": if game_mode == "menu":
@@ -1120,6 +1182,8 @@ func _draw() -> void:
_draw_profile() _draw_profile()
elif game_mode == "multiplayer_menu": elif game_mode == "multiplayer_menu":
_draw_multiplayer_menu() _draw_multiplayer_menu()
elif game_mode == "room_code_entry":
_draw_room_code_entry()
else: else:
_draw_matchmaking() _draw_matchmaking()
return return
@@ -1426,14 +1490,30 @@ func _draw_multiplayer_menu() -> void:
draw_string(font, Vector2(945, 225), "MITSPIELER • ENERGIE %d" % multiplayer_manager.player_credits[1], HORIZONTAL_ALIGNMENT_RIGHT, 440, 17, Color("ffcf7a")) draw_string(font, Vector2(945, 225), "MITSPIELER • ENERGIE %d" % multiplayer_manager.player_credits[1], HORIZONTAL_ALIGNMENT_RIGHT, 440, 17, Color("ffcf7a"))
draw_string(font, Vector2(570, 555), "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN" % multiplayer_manager.shared_reactor_lives, HORIZONTAL_ALIGNMENT_CENTER, 460, 16, Color.WHITE) draw_string(font, Vector2(570, 555), "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN" % multiplayer_manager.shared_reactor_lives, HORIZONTAL_ALIGNMENT_CENTER, 460, 16, Color.WHITE)
_draw_button(quick_match_button, "SCHNELLES SPIEL", false, Color("185f68")) _draw_button(quick_match_button, "SCHNELLES SPIEL", false, Color("185f68"))
_draw_button(friend_match_button, "MIT FREUND SPIELEN", false, Color("254f75")) _draw_button(friend_match_button, "RAUM BEITRETEN", false, Color("254f75"))
_draw_button(private_match_button, "PRIVATES SPIEL", false, Color("493f67")) _draw_button(private_match_button, "RAUM ERSTELLEN", false, Color("493f67"))
_draw_button(ai_match_button, "MIT KI-PARTNER", false, Color("6b4b28")) _draw_button(ai_match_button, "MIT KI-PARTNER", false, Color("6b4b28"))
var service_text := "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT" if online_manager.is_online() else "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT" var service_text := "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT" if online_manager.is_online() else "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT"
var service_color := Color("83ffc8") if online_manager.is_online() else Color("ffcf7a") var service_color := Color("83ffc8") if online_manager.is_online() else Color("ffcf7a")
draw_string(font, Vector2(0, 770), service_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color) draw_string(font, Vector2(0, 770), service_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color)
func _draw_room_code_entry() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88))
_draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a"))
draw_string(font, Vector2(0, 205), "PRIVATEN RAUM BEITRETEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff"))
draw_string(font, Vector2(0, 260), "GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3"))
draw_rect(room_code_input_rect, Color(0.01, 0.035, 0.065, 0.98), true)
draw_rect(room_code_input_rect, Color("65eaff") if room_code_input.length() == 6 else Color("39788e"), false, 3.0)
var shown_code := room_code_input
if shown_code.is_empty():
shown_code = "------"
draw_string(font, Vector2(room_code_input_rect.position.x, room_code_input_rect.position.y + 61), " ".join(shown_code.split("")), HORIZONTAL_ALIGNMENT_CENTER, room_code_input_rect.size.x, 38, Color("8ffaff") if not room_code_input.is_empty() else Color("688293"))
draw_string(font, Vector2(0, 470), "BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("9fdce9"))
_draw_button(join_room_button, "RAUM BEITRETEN", room_code_input.length() == 6, Color("185f68") if room_code_input.length() == 6 else Color("26394a"))
func _draw_coop_lane_preview(mirrored: bool) -> void: func _draw_coop_lane_preview(mirrored: bool) -> void:
var points := PackedVector2Array([Vector2(205, 300), Vector2(505, 300), Vector2(505, 385), Vector2(720, 385)]) var points := PackedVector2Array([Vector2(205, 300), Vector2(505, 300), Vector2(505, 385), Vector2(720, 385)])
if mirrored: if mirrored:
@@ -1453,13 +1533,17 @@ func _draw_matchmaking() -> void:
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88)) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88))
_draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a"))
draw_string(font, Vector2(0, 210), "MITSPIELERSUCHE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"]
draw_string(font, Vector2(0, 210), "PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff"))
var pulse := 56.0 + sin(elapsed * 3.0) * 8.0 var pulse := 56.0 + sin(elapsed * 3.0) * 8.0
draw_arc(Vector2(800, 390), pulse, -PI * 0.2, PI * 1.45, 52, Color("65eaff"), 5.0) draw_arc(Vector2(800, 390), pulse, -PI * 0.2, PI * 1.45, 52, Color("65eaff"), 5.0)
draw_circle(Vector2(800, 390), 22.0, Color("8ffaff")) draw_circle(Vector2(800, 390), 22.0, Color("8ffaff"))
var mode_labels := {"quick": "SCHNELLES SPIEL", "friend": "FREUND EINLADEN", "private": "PRIVATEN RAUM ERSTELLEN"} var mode_labels := {"quick": "SCHNELLES SPIEL", "private_host": "RAUM ERSTELLEN", "private_join": "RAUM BEITRETEN"}
draw_string(font, Vector2(0, 500), mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) var mode_text: String = mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL")
_draw_button(cancel_search_button, "SUCHE ABBRECHEN", false, Color("5c3342")) if not multiplayer_manager.private_room_code.is_empty():
mode_text = "RAUMCODE %s" % " ".join(multiplayer_manager.private_room_code.split(""))
draw_string(font, Vector2(0, 500), mode_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE)
_draw_button(cancel_search_button, "ZURÜCK" if multiplayer_manager.matchmaking_state == "room_not_found" else "SUCHE ABBRECHEN", false, Color("5c3342"))
var online_text := "ONLINE • SUCHE NACH EINEM MITPILOTEN" var online_text := "ONLINE • SUCHE NACH EINEM MITPILOTEN"
var online_color := Color("83ffc8") var online_color := Color("83ffc8")
if multiplayer_manager.matchmaking_state == "waiting_for_service": if multiplayer_manager.matchmaking_state == "waiting_for_service":
@@ -1471,6 +1555,18 @@ func _draw_matchmaking() -> void:
elif multiplayer_manager.matchmaking_state == "matched": elif multiplayer_manager.matchmaking_state == "matched":
online_text = "MITPILOT GEFUNDEN • KOOP-SITZUNG BEREIT" online_text = "MITPILOT GEFUNDEN • KOOP-SITZUNG BEREIT"
online_color = Color("8ffaff") online_color = Color("8ffaff")
elif multiplayer_manager.matchmaking_state == "creating_room":
online_text = "PRIVATER RAUM WIRD ERSTELLT ."
online_color = Color("ffcf7a")
elif multiplayer_manager.matchmaking_state == "hosting":
online_text = "TEILE DEN RAUMCODE MIT DEINEM MITPILOTEN • WARTE AUF BEITRITT"
online_color = Color("83ffc8")
elif multiplayer_manager.matchmaking_state == "joining_room":
online_text = "PRIVATER RAUM WIRD GESUCHT ."
online_color = Color("ffcf7a")
elif multiplayer_manager.matchmaking_state == "room_not_found":
online_text = "RAUM NICHT GEFUNDEN ODER BEREITS VOLL"
online_color = Color("ff8e8e")
draw_rect(Rect2(180, 520, 1240, 44), Color(0.005, 0.015, 0.035, 0.98), true) draw_rect(Rect2(180, 520, 1240, 44), Color(0.005, 0.015, 0.035, 0.98), true)
draw_string(font, Vector2(0, 548), online_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color) draw_string(font, Vector2(0, 548), online_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color)
+61
View File
@@ -12,6 +12,7 @@ const COOP_REACTOR_LIVES := 30
var matchmaking_state := "idle" var matchmaking_state := "idle"
var matchmaking_mode := "" var matchmaking_mode := ""
var matchmaking_difficulty := 1 var matchmaking_difficulty := 1
var private_room_code := ""
var online_match = null var online_match = null
var online_mode := false var online_mode := false
var player_slot := -1 var player_slot := -1
@@ -26,6 +27,7 @@ func reset_coop_session(start_credits := COOP_START_CREDITS, reactor_lives := CO
online_mode = false online_mode = false
player_slot = -1 player_slot = -1
latest_snapshot = {} latest_snapshot = {}
private_room_code = ""
func spend_player_credits(player_index: int, amount: int) -> bool: func spend_player_credits(player_index: int, amount: int) -> bool:
@@ -76,11 +78,37 @@ func begin_search(mode := "quick", difficulty := 1) -> void:
call_deferred("_begin_online_search", mode) call_deferred("_begin_online_search", mode)
func create_private_room(difficulty := 1) -> void:
matchmaking_mode = "private_host"
matchmaking_difficulty = clampi(difficulty, 0, 2)
private_room_code = ""
online_match = null
online_mode = false
player_slot = -1
latest_snapshot = {}
matchmaking_state = "waiting_for_service"
state_changed.emit(matchmaking_state)
call_deferred("_create_private_room_online")
func join_private_room(room_code: String) -> void:
matchmaking_mode = "private_join"
private_room_code = room_code.to_upper().strip_edges()
online_match = null
online_mode = false
player_slot = -1
latest_snapshot = {}
matchmaking_state = "waiting_for_service"
state_changed.emit(matchmaking_state)
call_deferred("_join_private_room_online")
func cancel_search() -> void: func cancel_search() -> void:
var online_manager = get_parent().get_node_or_null("OnlineManager") var online_manager = get_parent().get_node_or_null("OnlineManager")
if online_manager: if online_manager:
online_manager.cancel_matchmaking() online_manager.cancel_matchmaking()
matchmaking_mode = "" matchmaking_mode = ""
private_room_code = ""
matchmaking_state = "idle" matchmaking_state = "idle"
state_changed.emit(matchmaking_state) state_changed.emit(matchmaking_state)
@@ -102,10 +130,43 @@ func _begin_online_search(mode: String) -> void:
state_changed.emit(matchmaking_state) state_changed.emit(matchmaking_state)
func _ensure_online_manager():
var online_manager = get_parent().get_node_or_null("OnlineManager")
if not online_manager:
matchmaking_state = "offline"
state_changed.emit(matchmaking_state)
return null
if not online_manager.is_authenticated():
await online_manager.connect_and_authenticate()
return online_manager
func _create_private_room_online() -> void:
var online_manager = await _ensure_online_manager()
if not online_manager or matchmaking_mode != "private_host":
return
var created: bool = await online_manager.create_private_room(matchmaking_difficulty)
private_room_code = online_manager.private_room_code if created else ""
matchmaking_state = "hosting" if created else online_manager.connection_state
state_changed.emit(matchmaking_state)
func _join_private_room_online() -> void:
var online_manager = await _ensure_online_manager()
if not online_manager or matchmaking_mode != "private_join":
return
var joined: bool = await online_manager.join_private_room(private_room_code)
matchmaking_state = "matched" if joined else "room_not_found"
state_changed.emit(matchmaking_state)
func _on_online_matchmaking_state_changed(value: String) -> void: func _on_online_matchmaking_state_changed(value: String) -> void:
if matchmaking_state == "idle": if matchmaking_state == "idle":
return return
matchmaking_state = value matchmaking_state = value
var online_manager = get_parent().get_node_or_null("OnlineManager")
if online_manager and not online_manager.private_room_code.is_empty():
private_room_code = online_manager.private_room_code
state_changed.emit(matchmaking_state) state_changed.emit(matchmaking_state)
+57
View File
@@ -9,6 +9,7 @@ signal matchmaking_state_changed(state: String)
signal match_found(match_data) signal match_found(match_data)
signal match_snapshot_received(snapshot: Dictionary) signal match_snapshot_received(snapshot: Dictionary)
signal match_event_received(event: Dictionary) signal match_event_received(event: Dictionary)
signal private_room_created(room_code: String)
const SERVER_KEY := "voidbastion-public-v1" const SERVER_KEY := "voidbastion-public-v1"
const PRODUCTION_HOST := "api.void-bastion.gergames.de" const PRODUCTION_HOST := "api.void-bastion.gergames.de"
@@ -30,6 +31,7 @@ var last_error := ""
var current_host := "" var current_host := ""
var current_match = null var current_match = null
var matchmaker_ticket := "" var matchmaker_ticket := ""
var private_room_code := ""
var account_manager: AccountManager var account_manager: AccountManager
var auto_connect_enabled := true var auto_connect_enabled := true
@@ -102,6 +104,7 @@ func disconnect_from_server() -> void:
session = null session = null
current_match = null current_match = null
matchmaker_ticket = "" matchmaker_ticket = ""
private_room_code = ""
_set_connection_state("offline") _set_connection_state("offline")
@@ -178,9 +181,62 @@ func cancel_matchmaking() -> void:
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():
await socket.leave_match_async(current_match.match_id)
current_match = null
private_room_code = ""
matchmaking_state_changed.emit("idle") matchmaking_state_changed.emit("idle")
func create_private_room(difficulty := 1) -> bool:
if not is_online() or socket == null:
matchmaking_state_changed.emit("offline")
return false
matchmaking_state_changed.emit("creating_room")
var result = await client.rpc_async(session, "create_private_room", JSON.stringify({"difficulty": clampi(difficulty, 0, 2)}))
var data := _parse_rpc_result(result)
var match_id := String(data.get("match_id", ""))
private_room_code = String(data.get("room_code", ""))
if match_id.is_empty() or private_room_code.length() != 6:
private_room_code = ""
matchmaking_state_changed.emit("error")
return false
current_match = await socket.join_match_async(match_id)
if current_match == null or current_match.is_exception():
current_match = null
private_room_code = ""
matchmaking_state_changed.emit("error")
return false
private_room_created.emit(private_room_code)
matchmaking_state_changed.emit("hosting")
await send_match_command(OP_READY, {})
return true
func join_private_room(room_code: String) -> bool:
if not is_online() or socket == null:
matchmaking_state_changed.emit("offline")
return false
private_room_code = room_code.to_upper().strip_edges()
matchmaking_state_changed.emit("joining_room")
var result = await client.rpc_async(session, "join_private_room", JSON.stringify({"room_code": private_room_code}))
var data := _parse_rpc_result(result)
var match_id := String(data.get("match_id", ""))
if not bool(data.get("found", false)) or match_id.is_empty():
private_room_code = ""
matchmaking_state_changed.emit("room_not_found")
return false
current_match = await socket.join_match_async(match_id)
if current_match == null or current_match.is_exception():
current_match = null
private_room_code = ""
matchmaking_state_changed.emit("room_not_found")
return false
matchmaking_state_changed.emit("matched")
await send_match_command(OP_READY, {})
return true
func _on_matchmaker_matched(matched) -> void: func _on_matchmaker_matched(matched) -> void:
matchmaker_ticket = "" matchmaker_ticket = ""
current_match = await socket.join_matched_async(matched) current_match = await socket.join_matched_async(matched)
@@ -219,6 +275,7 @@ func leave_current_match() -> void:
if socket and current_match != null: if socket and current_match != null:
await socket.leave_match_async(current_match.match_id) await socket.leave_match_async(current_match.match_id)
current_match = null current_match = null
private_room_code = ""
func _on_match_state(data) -> void: func _on_match_state(data) -> void:
+56 -4
View File
@@ -3,6 +3,8 @@ var MAP_COUNT = 3;
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"];
var MATCH_MODULE = "void_bastion_coop"; var MATCH_MODULE = "void_bastion_coop";
var ROOM_COLLECTION = "private_rooms";
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 TICK_RATE = 10, SNAPSHOT_TICKS = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480; var TICK_RATE = 10, SNAPSHOT_TICKS = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758; var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758;
@@ -46,6 +48,47 @@ function rpcSubmitRun(ctx, logger, nk, payload) {
return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave}); return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave});
} }
function normalizeRoomCode(value) {
return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6);
}
function generateRoomCode() {
var alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", code = "";
for (var i = 0; i < 6; i++) code += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
return code;
}
function privateRoomRecord(nk, code) {
var records = nk.storageRead([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID}]);
return records.length ? records[0] : null;
}
function deletePrivateRoomRecord(nk, code) {
if (code) nk.storageDelete([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID}]);
}
function rpcCreatePrivateRoom(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var difficulty = Math.max(0, Math.min(2, Math.floor(Number(request.difficulty || 1))));
var code = "";
for (var attempt = 0; attempt < 24; attempt++) {
var candidate = generateRoomCode();
if (!privateRoomRecord(nk, candidate)) { code = candidate; break; }
}
if (!code) throw new Error("room code allocation failed");
var matchId = nk.matchCreate(MATCH_MODULE, {difficulty:difficulty,mode:"private",roomCode:code,hostUserId:ctx.userId});
nk.storageWrite([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID,value:{match_id:matchId,host_user_id:ctx.userId,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
return JSON.stringify({created:true,room_code:code,match_id:matchId});
}
function rpcJoinPrivateRoom(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var code = normalizeRoomCode(request.room_code);
if (code.length !== 6) return JSON.stringify({found:false,error:"invalid_room_code"});
var record = privateRoomRecord(nk, code);
if (!record || !record.value || !record.value.match_id) return JSON.stringify({found:false,error:"room_not_found"});
return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)});
}
function matchmakerMatched(ctx, logger, nk, matches) { function matchmakerMatched(ctx, logger, nk, matches) {
var difficulty = 1; var difficulty = 1;
if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty)))); if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty))));
@@ -54,9 +97,10 @@ function matchmakerMatched(ctx, logger, nk, matches) {
function initialState(params) { function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1)))); var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits; var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},started:false,gameOver:false,difficulty:difficulty,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0}; return {presences:{},slots:{},started:false,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
} }
function matchInit(ctx,logger,nk,params) { return {state:initialState(params||{}),tickRate:TICK_RATE,label:JSON.stringify({game:"void_bastion",mode:"coop",open:true})}; } 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 matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:Object.keys(state.presences).length<2}; } function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:Object.keys(state.presences).length<2}; }
function firstFreeSlot(state) { function firstFreeSlot(state) {
var used={},ids=Object.keys(state.slots); var used={},ids=Object.keys(state.slots);
@@ -71,13 +115,15 @@ function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.ty
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) { function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
for (var i=0;i<presences.length;i++) { var p=presences[i]; state.presences[p.userId]=p; if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state); } for (var i=0;i<presences.length;i++) { var p=presences[i]; state.presences[p.userId]=p; if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state); }
state.started=Object.keys(state.presences).length===2; state.started=Object.keys(state.presences).length===2;
state.emptyTicks=0;
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);
return {state:state}; return {state:state};
} }
function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) { function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
for (var i=0;i<presences.length;i++) delete state.presences[presences[i].userId]; for (var i=0;i<presences.length;i++) delete state.presences[presences[i].userId];
state.started=false; broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state}; state.started=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{}); 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 towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
@@ -174,18 +220,24 @@ function handleCommand(nk,dispatcher,state,message) {
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) {
if (!Object.keys(state.presences).length) {
state.emptyTicks += 1;
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
} else state.emptyTicks = 0;
for (var i=0;i<messages.length;i++) handleCommand(nk,dispatcher,state,messages[i]); for (var i=0;i<messages.length;i++) handleCommand(nk,dispatcher,state,messages[i]);
if (state.started&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); } if (state.started&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); }
if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick); if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick);
return {state:state}; return {state:state};
} }
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { 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 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("submit_run",rpcSubmitRun); initializer.registerRpc("submit_run",rpcSubmitRun);
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
initializer.registerMatchmakerMatched(matchmakerMatched); initializer.registerMatchmakerMatched(matchmakerMatched);
initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal}); initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal});
logger.info("Void Bastion online runtime %s loaded",GAME_VERSION); logger.info("Void Bastion online runtime %s loaded",GAME_VERSION);
+23
View File
@@ -12,6 +12,11 @@ const sent = [];
const dispatcher = { const dispatcher = {
broadcastMessage(opCode, data) { broadcastMessage(opCode, data) {
sent.push({ opCode, data: JSON.parse(data) }); sent.push({ opCode, data: JSON.parse(data) });
},
matchLabelUpdate(label) {
let data = label;
try { data = JSON.parse(label); } catch (error) {}
sent.push({ opCode: "label", data });
} }
}; };
const nk = { binaryToString: data => Buffer.from(data).toString("utf8") }; const nk = { binaryToString: data => Buffer.from(data).toString("utf8") };
@@ -25,6 +30,24 @@ state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).sta
assert.strictEqual(state.started, true); assert.strictEqual(state.started, true);
assert.strictEqual(state.slots["pilot-a"], 0); assert.strictEqual(state.slots["pilot-a"], 0);
assert.strictEqual(state.slots["pilot-b"], 1); assert.strictEqual(state.slots["pilot-b"], 1);
assert.ok(sent.some(entry => entry.opCode === "label" && entry.data.open === false));
assert.strictEqual(context.normalizeRoomCode(" ab-c12! "), "ABC12");
const privateMatch = context.matchInit({}, logger, nk, { difficulty: 2, mode: "private", roomCode: "AB23CD", hostUserId: "pilot-a" });
assert.strictEqual(privateMatch.label, "AB23CD");
assert.strictEqual(privateMatch.state.difficulty, 2);
const roomStorage = new Map();
const nkRooms = {
storageRead(ops) { return ops.map(op => roomStorage.get(op.key)).filter(Boolean); },
storageWrite(ops) { for (const op of ops) roomStorage.set(op.key, { value: op.value }); },
storageDelete(ops) { for (const op of ops) roomStorage.delete(op.key); },
matchCreate() { return "private-match-id"; }
};
const createdRoom = JSON.parse(context.rpcCreatePrivateRoom({ userId: "pilot-a" }, logger, nkRooms, JSON.stringify({ difficulty: 2 })));
assert.strictEqual(createdRoom.room_code.length, 6);
const joinedRoom = JSON.parse(context.rpcJoinPrivateRoom({ userId: "pilot-b" }, logger, nkRooms, JSON.stringify({ room_code: createdRoom.room_code.toLowerCase() })));
assert.strictEqual(joinedRoom.match_id, "private-match-id");
function message(sender, opCode, payload) { function message(sender, opCode, payload) {
return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) }; return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) };
+28 -1
View File
@@ -34,7 +34,7 @@ func _run() -> void:
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.20", "Der Server muss die aktuelle Runtime 0.20 geladen haben: %s" % info_data) assert(info_data.get("version") == "0.20", "Der Server muss die aktuelle Runtime 0.20 geladen haben: %s" % info_data)
var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.19-integration"}) var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.20-integration"})
var submitted = await client_a.rpc_async(session_a, "submit_run", run_payload) var submitted = await client_a.rpc_async(session_a, "submit_run", run_payload)
assert(not submitted.is_exception(), "Ein plausibles Ergebnis muss serverseitig angenommen werden.") assert(not submitted.is_exception(), "Ein plausibles Ergebnis muss serverseitig angenommen werden.")
var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", null, null, 10) var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", null, null, 10)
@@ -83,6 +83,33 @@ func _run() -> void:
assert((snapshot_a.get("towers", []) as Array).size() == 2, "Beide autorisierten Bauaufträge müssen serverseitig sichtbar sein.") assert((snapshot_a.get("towers", []) as Array).size() == 2, "Beide autorisierten Bauaufträge müssen serverseitig sichtbar sein.")
assert(JSON.stringify(snapshot_a.get("towers", [])) == JSON.stringify(snapshot_b.get("towers", [])), "Beide Clients müssen denselben Turmzustand erhalten.") assert(JSON.stringify(snapshot_a.get("towers", [])) == JSON.stringify(snapshot_b.get("towers", [])), "Beide Clients müssen denselben Turmzustand erhalten.")
assert(int(snapshot_a.get("reactor_lives", 0)) == int(snapshot_b.get("reactor_lives", -1)), "Beide Clients müssen dieselben gemeinsamen Reaktorleben sehen.") assert(int(snapshot_a.get("reactor_lives", 0)) == int(snapshot_b.get("reactor_lives", -1)), "Beide Clients müssen dieselben gemeinsamen Reaktorleben sehen.")
assert(not (await socket_a.leave_match_async(match_a.match_id)).is_exception(), "Spieler A muss die schnelle Partie verlassen können.")
assert(not (await socket_b.leave_match_async(match_b.match_id)).is_exception(), "Spieler B muss die schnelle Partie verlassen können.")
await create_timer(0.2).timeout
snapshot_a = {}
snapshot_b = {}
var created_room = await client_a.rpc_async(session_a, "create_private_room", JSON.stringify({"difficulty": 2}))
assert(not created_room.is_exception(), "Spieler A muss einen privaten Raum erstellen können.")
var created_data = JSON.parse_string(created_room.payload)
assert(created_data is Dictionary, "Der private Raum muss gültige Daten liefern.")
var room_code := String(created_data.get("room_code", ""))
var private_match_id := String(created_data.get("match_id", ""))
assert(room_code.length() == 6 and not private_match_id.is_empty(), "Der Server muss einen sechsstelligen Raumcode und eine Partie liefern.")
var private_match_a = await socket_a.join_match_async(private_match_id)
assert(not private_match_a.is_exception(), "Der Gastgeber muss seinem privaten Raum beitreten können.")
var found_room = await client_b.rpc_async(session_b, "join_private_room", JSON.stringify({"room_code": room_code.to_lower()}))
assert(not found_room.is_exception(), "Der Mitspieler muss den Raumcode unabhängig von Groß-/Kleinschreibung finden können.")
var found_data = JSON.parse_string(found_room.payload)
assert(found_data is Dictionary and String(found_data.get("match_id", "")) == private_match_id, "Der Raumcode muss genau zur erstellten Partie führen: %s" % found_data)
var private_match_b = await socket_b.join_match_async(private_match_id)
assert(not private_match_b.is_exception(), "Der Mitspieler muss dem privaten Raum beitreten können.")
for attempt in 100:
if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)):
break
await create_timer(0.05).timeout
assert(bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)), "Der private Raum muss mit zwei Spielern die gemeinsame Partie starten.")
assert(int(snapshot_a.get("difficulty", -1)) == 2 and int(snapshot_b.get("difficulty", -1)) == 2, "Die private Partie muss den Schwierigkeitsgrad des Gastgebers übernehmen.")
assert(private_match_a.match_id == private_match_b.match_id, "Beide Spieler müssen über den Raumcode dieselbe private Partie erhalten.")
socket_a.close() socket_a.close()
socket_b.close() socket_b.close()
print("VOID_BASTION_ONLINE_INTEGRATION_OK") print("VOID_BASTION_ONLINE_INTEGRATION_OK")