Add ready lobby and reconnect support

This commit is contained in:
Emporex committed 2026-10-04 04:31:52 +02:00
1 parent 19b1a67773
commit 32594c7f81
8 files changed
+215 -36

No files matched your search

+4 -2
View File
@@ -1,4 +1,4 @@
# Void Bastion Online 0.20 # Void Bastion Online 0.21
## Fertiger Stand ## Fertiger Stand
@@ -24,6 +24,8 @@ Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`.
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. 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.
Vor dem Start zeigt eine gemeinsame Lobby beide Pilotennamen und ihre zugewiesene Verteidigungsseite. Erst wenn beide Spieler ihren Bereit-Status bestätigt haben, beginnt die Partie. Bei einem kurzzeitigen Verbindungsabbruch bleibt der Spielerplatz 60 Sekunden reserviert, die Simulation pausiert und der Client versucht automatisch, demselben Match wieder beizutreten.
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. 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 +43,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, schnelles Matchmaking, autoritative Spielzustände sowie Erstellen und Beitreten eines privaten Raums per Code. Der Onlinetest prüft zwei Gastanmeldungen, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, schnelles Matchmaking, die Bereit-Lobby, autoritative Spielzustände sowie Erstellen, Verlassen und Wiederbeitreten eines privaten Raums per Code.
+6 -3
View File
@@ -1,4 +1,4 @@
# Void Bastion – spielbarer Prototyp 0.20 # Void Bastion – spielbarer Prototyp 0.21
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.
@@ -40,6 +40,8 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
- 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 schneller Spielersuche sowie privaten Koop-Räumen mit sechsstelligen Raumcodes - eigener Multiplayerbereich mit schneller Spielersuche sowie privaten Koop-Räumen mit sechsstelligen Raumcodes
- gemeinsame Online-Lobby mit Spielernamen, festen Verteidigungsseiten und manuellem Bereit-Status für beide Piloten
- reservierter Spielerplatz und automatische Wiederverbindung bei einem kurzzeitigen Netzverlust; die laufende Partie wartet bis zu 60 Sekunden
- 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 und jederzeit abbrechbarer Wartezustand für schnelle und private Online-Partien - sichtbarer und jederzeit abbrechbarer Wartezustand für schnelle und private Online-Partien
@@ -102,7 +104,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
## Steuerung ## Steuerung
> **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`. > **Online-Stand 0.21:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop, private Räume per Raumcode, eine Bereit-Lobby und Wiederverbindung sind eingebaut. 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.
@@ -118,6 +120,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
- 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. - 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.
- In der anschließenden Lobby werden beide Pilotennamen und Verteidigungsseiten angezeigt. Die Partie startet erst, wenn beide **Bereit** gewählt haben.
## Nächste Produktionsschritte ## Nächste Produktionsschritte
@@ -127,7 +130,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
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. Private Raumcodes und die autoritative Koop-Synchronisierung auf echten Android-, iOS- und Windows-Geräten testen 6. Private Raumcodes und die autoritative Koop-Synchronisierung auf echten Android-, iOS- und Windows-Geräten testen
7. Freundesliste, Einladungen und Wiederverbindung nach kurzzeitigem Netzverlust ergänzen 7. Freundesliste und direkte Einladungen 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
+44 -6
View File
@@ -137,6 +137,7 @@ 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 lobby_ready_button := Rect2(625, 580, 350, 58)
var room_code_input_rect := Rect2(510, 340, 580, 90) var room_code_input_rect := Rect2(510, 340, 580, 90)
var join_room_button := Rect2(625, 525, 350, 64) 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)
@@ -444,7 +445,9 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
queue_redraw() queue_redraw()
return return
if game_mode == "matchmaking": if game_mode == "matchmaking":
if cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): if lobby_ready_button.has_point(position) and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"]:
multiplayer_manager.toggle_lobby_ready()
elif cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position):
multiplayer_manager.cancel_search() multiplayer_manager.cancel_search()
game_mode = "multiplayer_menu" game_mode = "multiplayer_menu"
queue_redraw() queue_redraw()
@@ -1068,6 +1071,8 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
if not coop_online_mode or snapshot.is_empty(): if not coop_online_mode or snapshot.is_empty():
return return
selected_difficulty = clampi(int(snapshot.get("difficulty", selected_difficulty)), 0, DIFFICULTY_DATA.size() - 1) selected_difficulty = clampi(int(snapshot.get("difficulty", selected_difficulty)), 0, DIFFICULTY_DATA.size() - 1)
if bool(snapshot.get("connection_interrupted", false)):
_show_status("MITPILOT GETRENNT • WIEDERBEITRITT %d S" % int(snapshot.get("reconnect_seconds", 0)))
reactor_lives = int(snapshot.get("reactor_lives", reactor_lives)) reactor_lives = int(snapshot.get("reactor_lives", reactor_lives))
wave = int(snapshot.get("wave", wave)) wave = int(snapshot.get("wave", wave))
wave_active = bool(snapshot.get("wave_active", false)) wave_active = bool(snapshot.get("wave_active", false))
@@ -1467,7 +1472,7 @@ func _draw_menu() -> void:
_draw_button(start_button, "MISSION STARTEN", false, Color("18556f")) _draw_button(start_button, "MISSION STARTEN", false, Color("18556f"))
_draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75"))
draw_string(font, Vector2(0, 792), "BESTE WELLE %02d • KERNKRISTALLE %04d • KARTENBONUS %d" % [best_wave, core_crystals, int(_difficulty().crystals)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("86c8d8")) draw_string(font, Vector2(0, 792), "BESTE WELLE %02d • KERNKRISTALLE %04d • KARTENBONUS %d" % [best_wave, core_crystals, int(_difficulty().crystals)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("86c8d8"))
draw_string(font, Vector2(0, 860), "VERSION 0.20 • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) draw_string(font, Vector2(0, 860), "VERSION 0.21 • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293"))
func _draw_multiplayer_menu() -> void: func _draw_multiplayer_menu() -> void:
@@ -1535,14 +1540,20 @@ func _draw_matchmaking() -> void:
_draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a"))
var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"] var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"]
draw_string(font, Vector2(0, 210), "PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) draw_string(font, Vector2(0, 210), "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 in_lobby: bool = multiplayer_manager.player_slot >= 0 and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"]
draw_arc(Vector2(800, 390), pulse, -PI * 0.2, PI * 1.45, 52, Color("65eaff"), 5.0) if in_lobby:
draw_circle(Vector2(800, 390), 22.0, Color("8ffaff")) _draw_online_lobby_players(font)
else:
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_circle(Vector2(800, 390), 22.0, Color("8ffaff"))
var mode_labels := {"quick": "SCHNELLES SPIEL", "private_host": "RAUM ERSTELLEN", "private_join": "RAUM BEITRETEN"} var mode_labels := {"quick": "SCHNELLES SPIEL", "private_host": "RAUM ERSTELLEN", "private_join": "RAUM BEITRETEN"}
var mode_text: String = mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL") var mode_text: String = mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL")
if not multiplayer_manager.private_room_code.is_empty(): if not multiplayer_manager.private_room_code.is_empty():
mode_text = "RAUMCODE %s" % " ".join(multiplayer_manager.private_room_code.split("")) mode_text = "RAUMCODE %s" % " ".join(multiplayer_manager.private_room_code.split(""))
draw_string(font, Vector2(0, 500), mode_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) draw_string(font, Vector2(0, 500), mode_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE)
if in_lobby:
_draw_button(lobby_ready_button, "NICHT BEREIT" if multiplayer_manager.local_ready else "BEREIT", multiplayer_manager.local_ready, Color("8b6330") if multiplayer_manager.local_ready else Color("185f68"))
_draw_button(cancel_search_button, "ZURÜCK" if multiplayer_manager.matchmaking_state == "room_not_found" else "SUCHE ABBRECHEN", false, Color("5c3342")) _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")
@@ -1553,8 +1564,17 @@ func _draw_matchmaking() -> void:
online_text = "SERVER NOCH NICHT ERREICHBAR • BITTE SPÄTER ERNEUT VERSUCHEN" online_text = "SERVER NOCH NICHT ERREICHBAR • BITTE SPÄTER ERNEUT VERSUCHEN"
online_color = Color("ff8e8e") online_color = Color("ff8e8e")
elif multiplayer_manager.matchmaking_state == "matched": elif multiplayer_manager.matchmaking_state == "matched":
online_text = "MITPILOT GEFUNDEN • KOOP-SITZUNG BEREIT" online_text = "MITPILOT GEFUNDEN • WÄHLE BEREIT"
online_color = Color("8ffaff") online_color = Color("8ffaff")
elif multiplayer_manager.matchmaking_state in ["lobby", "reconnected"]:
online_text = "BEIDE SPIELER MÜSSEN BEREIT SEIN"
online_color = Color("8ffaff")
elif multiplayer_manager.matchmaking_state == "reconnecting":
online_text = "VERBINDUNG WIRD WIEDERHERGESTELLT ."
online_color = Color("ffcf7a")
elif multiplayer_manager.matchmaking_state == "connection_lost":
online_text = "VERBINDUNG VERLOREN • BITTE ZURÜCKKEHREN"
online_color = Color("ff8e8e")
elif multiplayer_manager.matchmaking_state == "creating_room": elif multiplayer_manager.matchmaking_state == "creating_room":
online_text = "PRIVATER RAUM WIRD ERSTELLT ." online_text = "PRIVATER RAUM WIRD ERSTELLT ."
online_color = Color("ffcf7a") online_color = Color("ffcf7a")
@@ -1571,6 +1591,24 @@ func _draw_matchmaking() -> void:
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)
func _draw_online_lobby_players(font: Font) -> void:
for slot in 2:
var rect := Rect2(270 + slot * 560, 285, 500, 130)
var player = multiplayer_manager.lobby_players[slot] if slot < multiplayer_manager.lobby_players.size() else null
var occupied: bool = player is Dictionary
var connected: bool = occupied and bool(player.get("connected", false))
var ready: bool = occupied and bool(player.get("ready", false))
var border := Color("5de8a8") if ready else (Color("65eaff") if connected else Color("425567"))
draw_rect(rect, Color(0.012, 0.035, 0.065, 0.96), true)
draw_rect(rect, border, false, 3.0)
var name := String(player.get("name", "PILOT")) if occupied else "WARTE AUF MITPILOTEN"
var side := "LINKE VERTEIDIGUNG" if slot == 0 else "RECHTE VERTEIDIGUNG"
var state := "BEREIT" if ready else ("NICHT BEREIT" if connected else "NICHT VERBUNDEN")
draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 43), name.to_upper(), HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 24, Color("e8fbff"))
draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 78), side, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 14, Color("8fcddd"))
draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 108), state, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 16, border)
func _draw_profile() -> void: func _draw_profile() -> 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.76)) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.76))
+27 -1
View File
@@ -13,6 +13,9 @@ var matchmaking_state := "idle"
var matchmaking_mode := "" var matchmaking_mode := ""
var matchmaking_difficulty := 1 var matchmaking_difficulty := 1
var private_room_code := "" var private_room_code := ""
var local_ready := false
var lobby_players: Array = [null, null]
var reconnect_seconds := 0
var online_match = null var online_match = null
var online_mode := false var online_mode := false
var player_slot := -1 var player_slot := -1
@@ -28,6 +31,9 @@ func reset_coop_session(start_credits := COOP_START_CREDITS, reactor_lives := CO
player_slot = -1 player_slot = -1
latest_snapshot = {} latest_snapshot = {}
private_room_code = "" private_room_code = ""
local_ready = false
lobby_players = [null, null]
reconnect_seconds = 0
func spend_player_credits(player_index: int, amount: int) -> bool: func spend_player_credits(player_index: int, amount: int) -> bool:
@@ -109,10 +115,21 @@ func cancel_search() -> void:
online_manager.cancel_matchmaking() online_manager.cancel_matchmaking()
matchmaking_mode = "" matchmaking_mode = ""
private_room_code = "" private_room_code = ""
local_ready = false
lobby_players = [null, null]
matchmaking_state = "idle" matchmaking_state = "idle"
state_changed.emit(matchmaking_state) state_changed.emit(matchmaking_state)
func toggle_lobby_ready() -> void:
if player_slot < 0 or matchmaking_state not in ["hosting", "matched", "lobby", "reconnected"]:
return
local_ready = not local_ready
var online_manager = get_parent().get_node_or_null("OnlineManager")
if online_manager:
await online_manager.set_lobby_ready(local_ready)
func _begin_online_search(mode: String) -> void: func _begin_online_search(mode: String) -> void:
if matchmaking_state == "idle" or matchmaking_mode != mode: if matchmaking_state == "idle" or matchmaking_mode != mode:
return return
@@ -172,13 +189,17 @@ func _on_online_matchmaking_state_changed(value: String) -> void:
func _on_online_match_found(match_data) -> void: func _on_online_match_found(match_data) -> void:
online_match = match_data online_match = match_data
online_mode = true online_mode = false
matchmaking_state = "matched" matchmaking_state = "matched"
state_changed.emit(matchmaking_state) state_changed.emit(matchmaking_state)
func _on_online_snapshot(snapshot: Dictionary) -> void: func _on_online_snapshot(snapshot: Dictionary) -> void:
latest_snapshot = snapshot latest_snapshot = snapshot
var players = snapshot.get("players", [])
if players is Array and players.size() >= PLAYER_COUNT:
lobby_players = players.duplicate(true)
reconnect_seconds = int(snapshot.get("reconnect_seconds", 0))
var credits = snapshot.get("credits", []) var credits = snapshot.get("credits", [])
if credits is Array and credits.size() >= PLAYER_COUNT: if credits is Array and credits.size() >= PLAYER_COUNT:
player_credits = [int(credits[0]), int(credits[1])] player_credits = [int(credits[0]), int(credits[1])]
@@ -189,6 +210,11 @@ func _on_online_snapshot(snapshot: Dictionary) -> void:
var resolved_slot := int(slots.get(online_manager.session.user_id, -1)) var resolved_slot := int(slots.get(online_manager.session.user_id, -1))
if resolved_slot >= 0 and resolved_slot != player_slot: if resolved_slot >= 0 and resolved_slot != player_slot:
player_slot = resolved_slot player_slot = resolved_slot
if player_slot >= 0 and player_slot < lobby_players.size() and lobby_players[player_slot] is Dictionary:
local_ready = bool(lobby_players[player_slot].get("ready", false))
if not bool(snapshot.get("started", false)) and player_slot >= 0 and matchmaking_state in ["hosting", "matched", "reconnected"]:
matchmaking_state = "lobby"
state_changed.emit(matchmaking_state)
if bool(snapshot.get("started", false)) and player_slot >= 0 and not online_mode: if bool(snapshot.get("started", false)) and player_slot >= 0 and not online_mode:
online_mode = true online_mode = true
online_game_ready.emit(player_slot) online_game_ready.emit(player_slot)
+62 -7
View File
@@ -32,6 +32,9 @@ var current_host := ""
var current_match = null var current_match = null
var matchmaker_ticket := "" var matchmaker_ticket := ""
var private_room_code := "" var private_room_code := ""
var lobby_ready := false
var intentional_disconnect := false
var reconnect_in_progress := false
var account_manager: AccountManager var account_manager: AccountManager
var auto_connect_enabled := true var auto_connect_enabled := true
@@ -83,21 +86,27 @@ func _authenticate_on(host: String, port: int, scheme: String) -> bool:
func _after_authentication() -> void: func _after_authentication() -> void:
socket = Nakama.create_socket_from(client) socket = Nakama.create_socket_from(client)
socket.received_matchmaker_matched.connect(_on_matchmaker_matched) _bind_socket_signals()
socket.received_match_state.connect(_on_match_state)
socket.closed.connect(_on_socket_closed)
var result = await socket.connect_async(session, true, 5) var result = await socket.connect_async(session, true, 5)
if result == null or result.is_exception(): if result == null or result.is_exception():
last_error = "Echtzeitverbindung konnte nicht aufgebaut werden" last_error = "Echtzeitverbindung konnte nicht aufgebaut werden"
socket = null socket = null
_set_connection_state("authenticated") _set_connection_state("authenticated")
else: else:
intentional_disconnect = false
_set_connection_state("online") _set_connection_state("online")
authenticated.emit(session.user_id, session.username) authenticated.emit(session.user_id, session.username)
await flush_pending_scores() await flush_pending_scores()
func _bind_socket_signals() -> void:
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
socket.received_match_state.connect(_on_match_state)
socket.closed.connect(_on_socket_closed)
func disconnect_from_server() -> void: func disconnect_from_server() -> void:
intentional_disconnect = true
if socket: if socket:
socket.close() socket.close()
socket = null socket = null
@@ -105,6 +114,7 @@ func disconnect_from_server() -> void:
current_match = null current_match = null
matchmaker_ticket = "" matchmaker_ticket = ""
private_room_code = "" private_room_code = ""
lobby_ready = false
_set_connection_state("offline") _set_connection_state("offline")
@@ -119,7 +129,7 @@ func submit_run(record: Dictionary) -> bool:
if not is_authenticated(): if not is_authenticated():
return false return false
var payload := record.duplicate(true) var payload := record.duplicate(true)
payload["client_version"] = "0.20" payload["client_version"] = "0.21"
var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload)) var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload))
var parsed := _parse_rpc_result(result) var parsed := _parse_rpc_result(result)
if parsed.is_empty() or not bool(parsed.get("accepted", false)): if parsed.is_empty() or not bool(parsed.get("accepted", false)):
@@ -160,6 +170,7 @@ func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limi
func begin_matchmaking(mode := "quick", difficulty := 1) -> bool: func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
intentional_disconnect = false
if not is_online() or socket == null: if not is_online() or socket == null:
matchmaking_state_changed.emit("offline") matchmaking_state_changed.emit("offline")
return false return false
@@ -178,6 +189,7 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
func cancel_matchmaking() -> void: func cancel_matchmaking() -> void:
intentional_disconnect = true
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 = ""
@@ -189,6 +201,7 @@ func cancel_matchmaking() -> void:
func create_private_room(difficulty := 1) -> bool: func create_private_room(difficulty := 1) -> bool:
intentional_disconnect = false
if not is_online() or socket == null: if not is_online() or socket == null:
matchmaking_state_changed.emit("offline") matchmaking_state_changed.emit("offline")
return false return false
@@ -209,11 +222,13 @@ func create_private_room(difficulty := 1) -> bool:
return false return false
private_room_created.emit(private_room_code) private_room_created.emit(private_room_code)
matchmaking_state_changed.emit("hosting") matchmaking_state_changed.emit("hosting")
await send_match_command(OP_READY, {}) lobby_ready = false
await set_lobby_ready(false)
return true return true
func join_private_room(room_code: String) -> bool: func join_private_room(room_code: String) -> bool:
intentional_disconnect = false
if not is_online() or socket == null: if not is_online() or socket == null:
matchmaking_state_changed.emit("offline") matchmaking_state_changed.emit("offline")
return false return false
@@ -233,11 +248,13 @@ func join_private_room(room_code: String) -> bool:
matchmaking_state_changed.emit("room_not_found") matchmaking_state_changed.emit("room_not_found")
return false return false
matchmaking_state_changed.emit("matched") matchmaking_state_changed.emit("matched")
await send_match_command(OP_READY, {}) lobby_ready = false
await set_lobby_ready(false)
return true return true
func _on_matchmaker_matched(matched) -> void: func _on_matchmaker_matched(matched) -> void:
intentional_disconnect = false
matchmaker_ticket = "" matchmaker_ticket = ""
current_match = await socket.join_matched_async(matched) current_match = await socket.join_matched_async(matched)
if current_match == null or current_match.is_exception(): if current_match == null or current_match.is_exception():
@@ -245,7 +262,14 @@ func _on_matchmaker_matched(matched) -> void:
return return
matchmaking_state_changed.emit("matched") matchmaking_state_changed.emit("matched")
match_found.emit(current_match) match_found.emit(current_match)
await send_match_command(OP_READY, {}) lobby_ready = false
await set_lobby_ready(false)
func set_lobby_ready(value: bool) -> bool:
lobby_ready = value
var player_name := account_manager.display_name if account_manager else session.username
return await send_match_command(OP_READY, {"ready": value, "name": player_name})
func send_match_command(op_code: int, payload: Dictionary) -> bool: func send_match_command(op_code: int, payload: Dictionary) -> bool:
@@ -276,6 +300,7 @@ func leave_current_match() -> void:
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 = "" private_room_code = ""
lobby_ready = false
func _on_match_state(data) -> void: func _on_match_state(data) -> void:
@@ -291,8 +316,38 @@ func _on_match_state(data) -> void:
func _on_socket_closed() -> void: func _on_socket_closed() -> void:
var reconnect_match_id := ""
if not intentional_disconnect and current_match != null:
reconnect_match_id = String(current_match.match_id)
current_match = null
if connection_state != "offline": if connection_state != "offline":
_set_connection_state("authenticated") _set_connection_state("authenticated")
if not reconnect_match_id.is_empty() and not reconnect_in_progress:
reconnect_in_progress = true
matchmaking_state_changed.emit("reconnecting")
_recover_match.call_deferred(reconnect_match_id)
func _recover_match(match_id: String) -> void:
for attempt in 5:
await get_tree().create_timer(1.5 + attempt).timeout
if intentional_disconnect or not is_authenticated():
break
socket = Nakama.create_socket_from(client)
_bind_socket_signals()
var connected = await socket.connect_async(session, true, 5)
if connected == null or connected.is_exception():
continue
var rejoined = await socket.join_match_async(match_id)
if rejoined != null and not rejoined.is_exception():
current_match = rejoined
intentional_disconnect = false
reconnect_in_progress = false
_set_connection_state("online")
matchmaking_state_changed.emit("reconnected")
return
reconnect_in_progress = false
matchmaking_state_changed.emit("connection_lost")
func _device_id() -> String: func _device_id() -> String:
+33 -10
View File
@@ -1,4 +1,4 @@
var GAME_VERSION = "0.20"; var GAME_VERSION = "0.21";
var MAP_COUNT = 3; 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"];
@@ -97,24 +97,37 @@ 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,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0}; return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
} }
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); } function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; } function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
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: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);
for (var i=0;i<ids.length;i++) used[state.slots[ids[i]]]=true; for (var i=0;i<ids.length;i++) used[state.slots[ids[i]]]=true;
return used[0] ? 1 : 0; return used[0] ? 1 : 0;
} }
function snapshot(state,tick) { function snapshot(state,tick) {
return {version:GAME_VERSION,tick:tick,started:state.started,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,towers:state.towers,enemies:state.enemies}; var lobby=[null,null],ids=Object.keys(state.slots);
for (var i=0;i<ids.length;i++) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
}
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,towers:state.towers,enemies:state.enemies};
} }
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; } function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); } function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
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); } var rejoined=false;
state.started=Object.keys(state.presences).length===2; for (var i=0;i<presences.length;i++) {
var p=presences[i];
if (state.slots[p.userId]!==undefined) rejoined=true;
state.presences[p.userId]=p;
if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state);
if (state.ready[p.userId]===undefined) state.ready[p.userId]=false;
state.players[p.userId]={name:String(p.username||"PILOT").substring(0,24)};
}
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;
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});
@@ -122,8 +135,9 @@ function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
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.ready[presences[i].userId]=false; }
state.started=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state}; if (state.started) { state.pausedForReconnect=true; state.reconnectDeadlineTick=tick+TICK_RATE*60; }
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 towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
@@ -216,7 +230,15 @@ function handleCommand(nk,dispatcher,state,message) {
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);
else if (message.opCode===OP_READY) changed=true; else if (message.opCode===OP_READY&&!state.started) {
state.ready[message.sender.userId]=payload.ready!==false;
var name=String(payload.name||message.sender.username||"PILOT").trim().substring(0,24);
state.players[message.sender.userId]={name:name||"PILOT"};
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;
if (allReady) { state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{}); }
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) {
@@ -225,7 +247,8 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; } if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
} else state.emptyTicks = 0; } 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.pausedForReconnect&&tick>=state.reconnectDeadlineTick) { state.gameOver=true; state.pausedForReconnect=false; state.waveActive=false; broadcastEvent(dispatcher,"partner_timeout",{}); }
if (state.started&&!state.pausedForReconnect&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); }
if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick); if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick);
return {state:state}; return {state:state};
} }
+21 -5
View File
@@ -27,10 +27,10 @@ const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
let state = context.matchInit({}, logger, nk, { difficulty: 1 }).state; let state = context.matchInit({}, logger, nk, { difficulty: 1 }).state;
state = context.matchJoin({}, logger, nk, dispatcher, 1, state, [presenceA]).state; state = context.matchJoin({}, logger, nk, dispatcher, 1, state, [presenceA]).state;
state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state; state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state;
assert.strictEqual(state.started, true); assert.strictEqual(state.started, false);
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.ok(sent.some(entry => entry.opCode === 1 && entry.data.players[0].name === "Pilot A"));
assert.strictEqual(context.normalizeRoomCode(" ab-c12! "), "ABC12"); assert.strictEqual(context.normalizeRoomCode(" ab-c12! "), "ABC12");
const privateMatch = context.matchInit({}, logger, nk, { difficulty: 2, mode: "private", roomCode: "AB23CD", hostUserId: "pilot-a" }); const privateMatch = context.matchInit({}, logger, nk, { difficulty: 2, mode: "private", roomCode: "AB23CD", hostUserId: "pilot-a" });
@@ -53,7 +53,14 @@ function message(sender, opCode, payload) {
return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) }; return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) };
} }
state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [ state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [message(presenceA, 14, { ready: true, name: "Kommandant A" })]).state;
assert.strictEqual(state.started, false);
state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceB, 14, { ready: true, name: "Kommandant B" })]).state;
assert.strictEqual(state.started, true);
assert.strictEqual(state.players["pilot-a"].name, "Kommandant A");
assert.ok(sent.some(entry => entry.opCode === "label" && entry.data.open === false));
state = context.matchLoop({}, logger, nk, dispatcher, 5, state, [
message(presenceA, 10, { tower_type: 0, x: 200, y: 200 }), message(presenceA, 10, { tower_type: 0, x: 200, y: 200 }),
message(presenceB, 10, { tower_type: 1, x: 2760, y: 200 }) message(presenceB, 10, { tower_type: 1, x: 2760, y: 200 })
]).state; ]).state;
@@ -61,10 +68,10 @@ 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);
state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceA, 13, {})]).state; state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
assert.strictEqual(state.wave, 1); assert.strictEqual(state.wave, 1);
assert.strictEqual(state.waveActive, true); assert.strictEqual(state.waveActive, true);
for (let tick = 5; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state; for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0); assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true)); assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
@@ -74,4 +81,13 @@ 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, 31, 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;
assert.strictEqual(state.started, true);
assert.strictEqual(state.pausedForReconnect, true);
assert.strictEqual(context.snapshot(state, 32).players[1].connected, false);
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 33, state, presenceB, {}).accept, true);
state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).state;
assert.strictEqual(state.pausedForReconnect, false);
assert.strictEqual(state.slots["pilot-b"], 1);
console.log("VOID_BASTION_SERVER_SIM_OK"); console.log("VOID_BASTION_SERVER_SIM_OK");
+18 -2
View File
@@ -32,9 +32,9 @@ 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.20", "Der Server muss die aktuelle Runtime 0.20 geladen haben: %s" % info_data) assert(info_data.get("version") == "0.21", "Der Server muss die aktuelle Runtime 0.21 geladen haben: %s" % info_data)
var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.20-integration"}) var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.21-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)
@@ -62,6 +62,10 @@ func _run() -> void:
var match_b = await socket_b.join_matched_async(matched_b) var match_b = await socket_b.join_matched_async(matched_b)
assert(not match_a.is_exception() and not match_b.is_exception(), "Beide Spieler müssen der autoritativen Koop-Partie beitreten können.") assert(not match_a.is_exception() and not match_b.is_exception(), "Beide Spieler müssen der autoritativen Koop-Partie beitreten können.")
assert(match_a.match_id == match_b.match_id, "Beide Spieler müssen dieselbe Koop-Partie erhalten.") assert(match_a.match_id == match_b.match_id, "Beide Spieler müssen dieselbe Koop-Partie erhalten.")
await create_timer(0.2).timeout
assert(not bool(snapshot_a.get("started", false)), "Die Partie darf vor der Bereitschaft beider Spieler nicht starten.")
await socket_a.send_match_state_async(match_a.match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT ALPHA"}))
await socket_b.send_match_state_async(match_b.match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT BETA"}))
for attempt in 100: for attempt in 100:
if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)): if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)):
break break
@@ -103,6 +107,18 @@ func _run() -> void:
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) 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) 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.") assert(not private_match_b.is_exception(), "Der Mitspieler muss dem privaten Raum beitreten können.")
await create_timer(0.2).timeout
assert(not bool(snapshot_a.get("started", false)), "Auch private Räume müssen auf die Bereitschaft beider Spieler warten.")
var lobby_players: Array = snapshot_a.get("players", [])
assert(lobby_players.size() == 2 and lobby_players[0] is Dictionary and lobby_players[1] is Dictionary, "Die Lobby muss beide Spielerplätze anzeigen.")
assert(not (await socket_b.leave_match_async(private_match_id)).is_exception(), "Ein Mitspieler muss die Lobby verlassen können.")
await create_timer(0.2).timeout
lobby_players = snapshot_a.get("players", [])
assert(lobby_players.size() == 2 and not bool(lobby_players[1].get("connected", true)), "Die Lobby muss einen getrennten Mitspieler erkennen.")
private_match_b = await socket_b.join_match_async(private_match_id)
assert(not private_match_b.is_exception(), "Der reservierte Spieler muss demselben Raum wieder beitreten können.")
await socket_a.send_match_state_async(private_match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT ALPHA"}))
await socket_b.send_match_state_async(private_match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT BETA"}))
for attempt in 100: for attempt in 100:
if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)): if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)):
break break