diff --git a/ONLINE_SETUP.md b/ONLINE_SETUP.md index a83a70e..636531c 100644 --- a/ONLINE_SETUP.md +++ b/ONLINE_SETUP.md @@ -1,4 +1,4 @@ -# Void Bastion Online 0.19 +# Void Bastion Online 0.20 ## Fertiger Stand @@ -22,9 +22,9 @@ Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`. ## 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 @@ -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` - 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. diff --git a/README.md b/README.md index d5b9012..ac57b32 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 - 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 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 - 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 - 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 @@ -102,7 +102,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. ## 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. - 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. - 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. ## 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 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. Autoritativen Koop-Spielserver, Räume, Freundeseinladungen und Spielersuche anbinden -7. Die gespiegelte Koop-Arena spielbar machen und anschließend über echte Geräte synchron 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 8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion ## Godot Agent Tools MCP diff --git a/main.gd b/main.gd index f86eaea..30685bc 100644 --- a/main.gd +++ b/main.gd @@ -137,7 +137,10 @@ 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 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 room_code_input := "" @onready var ads_manager = $AdsManager @onready var account_manager = $AccountManager @@ -366,6 +369,20 @@ func _update_effects(delta: float) -> 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"]: _toggle_pause() return @@ -409,13 +426,23 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: elif quick_match_button.has_point(position): _open_matchmaking("quick") elif friend_match_button.has_point(position): - _open_matchmaking("friend") + _open_room_code_entry() elif private_match_button.has_point(position): - _open_matchmaking("private") + _open_private_room() elif ai_match_button.has_point(position): _start_coop_ai_game() queue_redraw() 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 cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): multiplayer_manager.cancel_search() @@ -607,6 +634,41 @@ func _open_matchmaking(mode: String) -> void: 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: coop_online_mode = false credits = int(_difficulty().start_credits) @@ -1111,7 +1173,7 @@ func _load_progress() -> 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_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06)) if game_mode == "menu": @@ -1120,6 +1182,8 @@ func _draw() -> void: _draw_profile() elif game_mode == "multiplayer_menu": _draw_multiplayer_menu() + elif game_mode == "room_code_entry": + _draw_room_code_entry() else: _draw_matchmaking() 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(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(friend_match_button, "MIT FREUND SPIELEN", false, Color("254f75")) - _draw_button(private_match_button, "PRIVATES SPIEL", false, Color("493f67")) + _draw_button(friend_match_button, "RAUM BEITRETEN", false, Color("254f75")) + _draw_button(private_match_button, "RAUM ERSTELLEN", false, Color("493f67")) _draw_button(ai_match_button, "MIT KI-PARTNER", false, Color("6b4b28")) var service_text := "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT" if online_manager.is_online() else "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT" var service_color := Color("83ffc8") if online_manager.is_online() else Color("ffcf7a") draw_string(font, Vector2(0, 770), service_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color) +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: var points := PackedVector2Array([Vector2(205, 300), Vector2(505, 300), Vector2(505, 385), Vector2(720, 385)]) if mirrored: @@ -1453,13 +1533,17 @@ func _draw_matchmaking() -> 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, 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 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", "friend": "FREUND EINLADEN", "private": "PRIVATEN RAUM ERSTELLEN"} - draw_string(font, Vector2(0, 500), mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) - _draw_button(cancel_search_button, "SUCHE ABBRECHEN", false, Color("5c3342")) + 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") + 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_color := Color("83ffc8") if multiplayer_manager.matchmaking_state == "waiting_for_service": @@ -1471,6 +1555,18 @@ func _draw_matchmaking() -> void: elif multiplayer_manager.matchmaking_state == "matched": online_text = "MITPILOT GEFUNDEN • KOOP-SITZUNG BEREIT" 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_string(font, Vector2(0, 548), online_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color) diff --git a/multiplayer_manager.gd b/multiplayer_manager.gd index 0fa5efc..8b9fcf0 100644 --- a/multiplayer_manager.gd +++ b/multiplayer_manager.gd @@ -12,6 +12,7 @@ const COOP_REACTOR_LIVES := 30 var matchmaking_state := "idle" var matchmaking_mode := "" var matchmaking_difficulty := 1 +var private_room_code := "" var online_match = null var online_mode := false var player_slot := -1 @@ -26,6 +27,7 @@ func reset_coop_session(start_credits := COOP_START_CREDITS, reactor_lives := CO online_mode = false player_slot = -1 latest_snapshot = {} + private_room_code = "" 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) +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: var online_manager = get_parent().get_node_or_null("OnlineManager") if online_manager: online_manager.cancel_matchmaking() matchmaking_mode = "" + private_room_code = "" matchmaking_state = "idle" state_changed.emit(matchmaking_state) @@ -102,10 +130,43 @@ func _begin_online_search(mode: String) -> void: 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: if matchmaking_state == "idle": return 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) diff --git a/online_manager.gd b/online_manager.gd index f55f4d0..c128b15 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -9,6 +9,7 @@ signal matchmaking_state_changed(state: String) signal match_found(match_data) signal match_snapshot_received(snapshot: Dictionary) signal match_event_received(event: Dictionary) +signal private_room_created(room_code: String) const SERVER_KEY := "voidbastion-public-v1" const PRODUCTION_HOST := "api.void-bastion.gergames.de" @@ -30,6 +31,7 @@ var last_error := "" var current_host := "" var current_match = null var matchmaker_ticket := "" +var private_room_code := "" var account_manager: AccountManager var auto_connect_enabled := true @@ -102,6 +104,7 @@ func disconnect_from_server() -> void: session = null current_match = null matchmaker_ticket = "" + private_room_code = "" _set_connection_state("offline") @@ -178,9 +181,62 @@ func cancel_matchmaking() -> void: if socket and not matchmaker_ticket.is_empty(): await socket.remove_matchmaker_async(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") +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: matchmaker_ticket = "" current_match = await socket.join_matched_async(matched) @@ -219,6 +275,7 @@ func leave_current_match() -> void: if socket and current_match != null: await socket.leave_match_async(current_match.match_id) current_match = null + private_room_code = "" func _on_match_state(data) -> void: diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index 8ac705e..fd30d2d 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -3,6 +3,8 @@ var MAP_COUNT = 3; var DIFFICULTY_COUNT = 3; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; 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 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; @@ -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}); } +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) { 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)))); @@ -54,9 +97,10 @@ 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,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 firstFreeSlot(state) { 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) { for (var i=0;i= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; } + } else state.emptyTicks = 0; for (var i=0;i=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick); 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 InitModule(ctx,logger,nk,initializer) { for (var mode=0;mode 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.slots["pilot-a"], 0); 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) { return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) }; diff --git a/tests/online_integration_test.gd b/tests/online_integration_test.gd index 0cd0a4f..68a9588 100644 --- a/tests/online_integration_test.gd +++ b/tests/online_integration_test.gd @@ -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.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) 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) @@ -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(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(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_b.close() print("VOID_BASTION_ONLINE_INTEGRATION_OK")