Add ready lobby and reconnect support
This commit is contained in:
1 parent
19b1a67773
commit
32594c7f81
8 files changed
+212
-33
No files matched your search
+4
-2
@@ -1,4 +1,4 @@
|
||||
# Void Bastion Online 0.20
|
||||
# Void Bastion Online 0.21
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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`
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
- wartende, lokal gespeicherte Highscore-Einträge für die spätere sichere Serverübertragung
|
||||
- eigener Multiplayerbereich mit schneller Spielersuche sowie privaten Koop-Räumen mit sechsstelligen Raumcodes
|
||||
- gemeinsame Online-Lobby mit Spielernamen, festen Verteidigungsseiten und manuellem Bereit-Status für beide Piloten
|
||||
- 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
|
||||
- getrennte Ressourcenmodelle für beide Koop-Spieler sowie gemeinsam geteilte Reaktorleben
|
||||
- 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
|
||||
|
||||
> **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.
|
||||
- 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.
|
||||
- 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.
|
||||
- In der anschließenden Lobby werden beide Pilotennamen und Verteidigungsseiten angezeigt. Die Partie startet erst, wenn beide **Bereit** gewählt haben.
|
||||
|
||||
## 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
|
||||
5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden
|
||||
6. Private Raumcodes und die autoritative Koop-Synchronisierung auf echten Android-, iOS- und Windows-Geräten testen
|
||||
7. Freundesliste, 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
|
||||
|
||||
## Godot Agent Tools MCP
|
||||
|
||||
@@ -137,6 +137,7 @@ var friend_match_button := Rect2(475, 665, 300, 64)
|
||||
var private_match_button := Rect2(825, 665, 300, 64)
|
||||
var ai_match_button := Rect2(1175, 665, 300, 64)
|
||||
var cancel_search_button := Rect2(625, 650, 350, 64)
|
||||
var lobby_ready_button := Rect2(625, 580, 350, 58)
|
||||
var room_code_input_rect := Rect2(510, 340, 580, 90)
|
||||
var join_room_button := Rect2(625, 525, 350, 64)
|
||||
var coop_side_button := Rect2(920, 18, 140, 54)
|
||||
@@ -444,7 +445,9 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
|
||||
queue_redraw()
|
||||
return
|
||||
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()
|
||||
game_mode = "multiplayer_menu"
|
||||
queue_redraw()
|
||||
@@ -1068,6 +1071,8 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
|
||||
if not coop_online_mode or snapshot.is_empty():
|
||||
return
|
||||
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))
|
||||
wave = int(snapshot.get("wave", wave))
|
||||
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(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, 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:
|
||||
@@ -1535,6 +1540,10 @@ func _draw_matchmaking() -> void:
|
||||
_draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a"))
|
||||
var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"]
|
||||
draw_string(font, Vector2(0, 210), "PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff"))
|
||||
var in_lobby: bool = multiplayer_manager.player_slot >= 0 and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"]
|
||||
if in_lobby:
|
||||
_draw_online_lobby_players(font)
|
||||
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"))
|
||||
@@ -1543,6 +1552,8 @@ func _draw_matchmaking() -> void:
|
||||
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)
|
||||
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"))
|
||||
var online_text := "ONLINE • SUCHE NACH EINEM MITPILOTEN"
|
||||
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_color = Color("ff8e8e")
|
||||
elif multiplayer_manager.matchmaking_state == "matched":
|
||||
online_text = "MITPILOT GEFUNDEN • KOOP-SITZUNG BEREIT"
|
||||
online_text = "MITPILOT GEFUNDEN • WÄHLE BEREIT"
|
||||
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":
|
||||
online_text = "PRIVATER RAUM WIRD ERSTELLT ."
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.76))
|
||||
|
||||
+27
-1
@@ -13,6 +13,9 @@ var matchmaking_state := "idle"
|
||||
var matchmaking_mode := ""
|
||||
var matchmaking_difficulty := 1
|
||||
var private_room_code := ""
|
||||
var local_ready := false
|
||||
var lobby_players: Array = [null, null]
|
||||
var reconnect_seconds := 0
|
||||
var online_match = null
|
||||
var online_mode := false
|
||||
var player_slot := -1
|
||||
@@ -28,6 +31,9 @@ func reset_coop_session(start_credits := COOP_START_CREDITS, reactor_lives := CO
|
||||
player_slot = -1
|
||||
latest_snapshot = {}
|
||||
private_room_code = ""
|
||||
local_ready = false
|
||||
lobby_players = [null, null]
|
||||
reconnect_seconds = 0
|
||||
|
||||
|
||||
func spend_player_credits(player_index: int, amount: int) -> bool:
|
||||
@@ -109,10 +115,21 @@ func cancel_search() -> void:
|
||||
online_manager.cancel_matchmaking()
|
||||
matchmaking_mode = ""
|
||||
private_room_code = ""
|
||||
local_ready = false
|
||||
lobby_players = [null, null]
|
||||
matchmaking_state = "idle"
|
||||
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:
|
||||
if matchmaking_state == "idle" or matchmaking_mode != mode:
|
||||
return
|
||||
@@ -172,13 +189,17 @@ func _on_online_matchmaking_state_changed(value: String) -> void:
|
||||
|
||||
func _on_online_match_found(match_data) -> void:
|
||||
online_match = match_data
|
||||
online_mode = true
|
||||
online_mode = false
|
||||
matchmaking_state = "matched"
|
||||
state_changed.emit(matchmaking_state)
|
||||
|
||||
|
||||
func _on_online_snapshot(snapshot: Dictionary) -> void:
|
||||
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", [])
|
||||
if credits is Array and credits.size() >= PLAYER_COUNT:
|
||||
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))
|
||||
if resolved_slot >= 0 and resolved_slot != player_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:
|
||||
online_mode = true
|
||||
online_game_ready.emit(player_slot)
|
||||
|
||||
+62
-7
@@ -32,6 +32,9 @@ var current_host := ""
|
||||
var current_match = null
|
||||
var matchmaker_ticket := ""
|
||||
var private_room_code := ""
|
||||
var lobby_ready := false
|
||||
var intentional_disconnect := false
|
||||
var reconnect_in_progress := false
|
||||
var account_manager: AccountManager
|
||||
var auto_connect_enabled := true
|
||||
|
||||
@@ -83,21 +86,27 @@ func _authenticate_on(host: String, port: int, scheme: String) -> bool:
|
||||
|
||||
func _after_authentication() -> void:
|
||||
socket = Nakama.create_socket_from(client)
|
||||
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
||||
socket.received_match_state.connect(_on_match_state)
|
||||
socket.closed.connect(_on_socket_closed)
|
||||
_bind_socket_signals()
|
||||
var result = await socket.connect_async(session, true, 5)
|
||||
if result == null or result.is_exception():
|
||||
last_error = "Echtzeitverbindung konnte nicht aufgebaut werden"
|
||||
socket = null
|
||||
_set_connection_state("authenticated")
|
||||
else:
|
||||
intentional_disconnect = false
|
||||
_set_connection_state("online")
|
||||
authenticated.emit(session.user_id, session.username)
|
||||
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:
|
||||
intentional_disconnect = true
|
||||
if socket:
|
||||
socket.close()
|
||||
socket = null
|
||||
@@ -105,6 +114,7 @@ func disconnect_from_server() -> void:
|
||||
current_match = null
|
||||
matchmaker_ticket = ""
|
||||
private_room_code = ""
|
||||
lobby_ready = false
|
||||
_set_connection_state("offline")
|
||||
|
||||
|
||||
@@ -119,7 +129,7 @@ func submit_run(record: Dictionary) -> bool:
|
||||
if not is_authenticated():
|
||||
return false
|
||||
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 parsed := _parse_rpc_result(result)
|
||||
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:
|
||||
intentional_disconnect = false
|
||||
if not is_online() or socket == null:
|
||||
matchmaking_state_changed.emit("offline")
|
||||
return false
|
||||
@@ -178,6 +189,7 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
|
||||
|
||||
|
||||
func cancel_matchmaking() -> void:
|
||||
intentional_disconnect = true
|
||||
if socket and not matchmaker_ticket.is_empty():
|
||||
await socket.remove_matchmaker_async(matchmaker_ticket)
|
||||
matchmaker_ticket = ""
|
||||
@@ -189,6 +201,7 @@ func cancel_matchmaking() -> void:
|
||||
|
||||
|
||||
func create_private_room(difficulty := 1) -> bool:
|
||||
intentional_disconnect = false
|
||||
if not is_online() or socket == null:
|
||||
matchmaking_state_changed.emit("offline")
|
||||
return false
|
||||
@@ -209,11 +222,13 @@ func create_private_room(difficulty := 1) -> bool:
|
||||
return false
|
||||
private_room_created.emit(private_room_code)
|
||||
matchmaking_state_changed.emit("hosting")
|
||||
await send_match_command(OP_READY, {})
|
||||
lobby_ready = false
|
||||
await set_lobby_ready(false)
|
||||
return true
|
||||
|
||||
|
||||
func join_private_room(room_code: String) -> bool:
|
||||
intentional_disconnect = false
|
||||
if not is_online() or socket == null:
|
||||
matchmaking_state_changed.emit("offline")
|
||||
return false
|
||||
@@ -233,11 +248,13 @@ func join_private_room(room_code: String) -> bool:
|
||||
matchmaking_state_changed.emit("room_not_found")
|
||||
return false
|
||||
matchmaking_state_changed.emit("matched")
|
||||
await send_match_command(OP_READY, {})
|
||||
lobby_ready = false
|
||||
await set_lobby_ready(false)
|
||||
return true
|
||||
|
||||
|
||||
func _on_matchmaker_matched(matched) -> void:
|
||||
intentional_disconnect = false
|
||||
matchmaker_ticket = ""
|
||||
current_match = await socket.join_matched_async(matched)
|
||||
if current_match == null or current_match.is_exception():
|
||||
@@ -245,7 +262,14 @@ func _on_matchmaker_matched(matched) -> void:
|
||||
return
|
||||
matchmaking_state_changed.emit("matched")
|
||||
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:
|
||||
@@ -276,6 +300,7 @@ func leave_current_match() -> void:
|
||||
await socket.leave_match_async(current_match.match_id)
|
||||
current_match = null
|
||||
private_room_code = ""
|
||||
lobby_ready = false
|
||||
|
||||
|
||||
func _on_match_state(data) -> void:
|
||||
@@ -291,8 +316,38 @@ func _on_match_state(data) -> 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":
|
||||
_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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
var GAME_VERSION = "0.20";
|
||||
var GAME_VERSION = "0.21";
|
||||
var MAP_COUNT = 3;
|
||||
var DIFFICULTY_COUNT = 3;
|
||||
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
||||
@@ -97,24 +97,37 @@ function matchmakerMatched(ctx, logger, nk, matches) {
|
||||
function initialState(params) {
|
||||
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
|
||||
var credits = DIFFICULTIES[difficulty].startCredits;
|
||||
return {presences:{},slots:{},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 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) {
|
||||
var used={},ids=Object.keys(state.slots);
|
||||
for (var i=0;i<ids.length;i++) used[state.slots[ids[i]]]=true;
|
||||
return used[0] ? 1 : 0;
|
||||
}
|
||||
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 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) {
|
||||
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;
|
||||
var rejoined=false;
|
||||
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;
|
||||
dispatcher.matchLabelUpdate(matchLabel(state));
|
||||
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};
|
||||
}
|
||||
function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
|
||||
for (var i=0;i<presences.length;i++) delete state.presences[presences[i].userId];
|
||||
state.started=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
|
||||
for (var i=0;i<presences.length;i++) { delete state.presences[presences[i].userId]; state.ready[presences[i].userId]=false; }
|
||||
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 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_SELL) changed=handleSell(state,slot,payload);
|
||||
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});
|
||||
}
|
||||
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; }
|
||||
} else state.emptyTicks = 0;
|
||||
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);
|
||||
return {state:state};
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
|
||||
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, 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-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");
|
||||
|
||||
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)) };
|
||||
}
|
||||
|
||||
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(presenceB, 10, { tower_type: 1, x: 2760, y: 200 })
|
||||
]).state;
|
||||
@@ -61,10 +68,10 @@ assert.strictEqual(state.towers.length, 2);
|
||||
assert.strictEqual(state.credits[0], 310);
|
||||
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.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(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;
|
||||
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");
|
||||
@@ -32,9 +32,9 @@ func _run() -> void:
|
||||
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
|
||||
var info_data = JSON.parse_string(info.payload)
|
||||
assert(info_data is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.")
|
||||
assert(info_data.get("version") == "0.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)
|
||||
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)
|
||||
@@ -62,6 +62,10 @@ func _run() -> void:
|
||||
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(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:
|
||||
if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)):
|
||||
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)
|
||||
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.")
|
||||
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:
|
||||
if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)):
|
||||
break
|
||||
|
||||
Reference in new issue
Block a user