diff --git a/docs/plans/2026-04-13-lobby-rollen-design.md b/docs/plans/2026-04-13-lobby-rollen-design.md index 4cd2017..1553e8f 100644 --- a/docs/plans/2026-04-13-lobby-rollen-design.md +++ b/docs/plans/2026-04-13-lobby-rollen-design.md @@ -20,7 +20,7 @@ Das Multiplayer-Grundgerüst steht (ENet, NetworkManager, RPC verifiziert). Dies | Datenhaltung | `NetworkManager.players` Dict | Zentraler Zugriff für alle späteren Szenen | | DM-Limit | Genau 1 DM | Zweiter DM-Versuch wird gekickt | | Szenen-Struktur | Eine `lobby.tscn` mit zwei Panels (Join / Warten) | Einfacher als zwei separate Szenen | -| Spiel starten | DM-Button → `start_game` RPC | Platzhalter; Szenen-Wechsel kommt im nächsten Schritt | +| Spiel starten | DM-Client sendet `request_start_game` → Server ruft `start_game.rpc()` | Client darf `authority`-RPCs nicht direkt rufen | --- @@ -29,13 +29,13 @@ Das Multiplayer-Grundgerüst steht (ENet, NetworkManager, RPC verifiziert). Dies ``` ruf-der-pilze/ ├── scripts/ -│ ├── network_manager.gd ← erweitern: players, register/player_joined/kick/start_game RPCs +│ ├── network_manager.gd ← erweitern: players, neue RPCs + Signale │ └── lobby.gd ← neu: Lobby-UI-Logik └── scenes/ └── lobby.tscn ← neu: Join-Panel + Wait-Panel ``` -`main.tscn` / `main.gd` bleiben unverändert — sie verbinden sich mit dem Server. Die Lobby-Szene wird als Child zu `main.tscn` hinzugefügt oder direkt als Hauptszene geladen (TBD beim Implementieren, je nachdem was sauberer ist). +`main.tscn` / `main.gd` bleiben unverändert — sie starten den Server oder verbinden sich. Die Lobby-Szene wird als erste Szene in `main.tscn` geladen. --- @@ -52,62 +52,99 @@ var players: Dictionary = {} # peer_id (int) → {name: String, role: String} ```gdscript signal player_joined(peer_id: int, player_name: String, role: String) signal player_left(peer_id: int) +signal player_list_synced() signal game_started() ``` +### Anpassung: `_on_peer_disconnected` + +Beide Dicts bereinigen und beide Signale emittieren: + +```gdscript +func _on_peer_disconnected(id: int) -> void: + peers.erase(id) + players.erase(id) + peer_disconnected.emit(id) + player_left.emit(id) + print("[Server] Peer getrennt: %d" % id) +``` + ### Neues RPC: `register` (Client → Server) ```gdscript @rpc("any_peer", "call_remote", "reliable") func register(player_name: String, role: String) -> void: + # Nur der Server soll diese Logik ausführen + if not multiplayer.is_server(): + return var sender_id := multiplayer.get_remote_sender_id() if role == "dm": for p in players.values(): if p.role == "dm": kick.rpc_id(sender_id, "Es ist bereits ein DM verbunden.") return - players[sender_id] = {name = player_name, role = role} - _broadcast_player_joined(sender_id, player_name, role) + # Erst bestehende Spielerliste an neuen Client senden (noch ohne den neuen Spieler) + sync_players.rpc_id(sender_id, players) + # Dann neuen Spieler an alle broadcasten (inkl. Server selbst durch call_local) + _broadcast_player_joined.rpc(sender_id, player_name, role) ``` ### Neues RPC: `_broadcast_player_joined` (Server → alle) ```gdscript -@rpc("authority", "call_remote", "reliable") +# call_local: Server updated sein eigenes players-Dict mit +@rpc("authority", "call_local", "reliable") func _broadcast_player_joined(peer_id: int, player_name: String, role: String) -> void: players[peer_id] = {name = player_name, role = role} player_joined.emit(peer_id, player_name, role) ``` +### Neues RPC: `sync_players` (Server → ein Client) + +Sendet dem neu beigetretenen Client die vollständige Spielerliste aller bereits verbundenen Spieler: + +```gdscript +@rpc("authority", "call_remote", "reliable") +func sync_players(player_list: Dictionary) -> void: + # Nur Dict befüllen — kein Signal emittieren. + # Die Lobby-UI rebuilt die Liste einmalig nach Empfang über player_list_synced. + players = player_list + player_list_synced.emit() +``` + ### Neues RPC: `kick` (Server → ein Client) ```gdscript @rpc("authority", "call_remote", "reliable") func kick(reason: String) -> void: push_error("[Client] Verbindung abgelehnt: %s" % reason) - multiplayer.multiplayer_peer.close() + # call_deferred: ENet kann den RPC vollständig verarbeiten bevor die Verbindung getrennt wird + multiplayer.multiplayer_peer.close.call_deferred() +``` + +### Neues RPC: `request_start_game` (DM-Client → Server) + +DM-Clients dürfen `authority`-RPCs nicht direkt aufrufen. Stattdessen sendet der DM eine Anfrage: + +```gdscript +@rpc("any_peer", "call_remote", "reliable") +func request_start_game() -> void: + if not multiplayer.is_server(): + return + var requester_id := multiplayer.get_remote_sender_id() + if players.get(requester_id, {}).get("role", "") != "dm": + return + start_game.rpc() ``` ### Neues RPC: `start_game` (Server → alle) ```gdscript -@rpc("authority", "call_remote", "reliable") +@rpc("authority", "call_local", "reliable") func start_game() -> void: game_started.emit() ``` -### Anpassung: `_on_peer_disconnected` - -Beim Disconnect den Spieler aus `players` entfernen und `player_left` emittieren: - -```gdscript -func _on_peer_disconnected(id: int) -> void: - players.erase(id) - peer_disconnected.emit(id) - player_left.emit(id) - print("[Server] Peer getrennt: %d" % id) -``` - --- ## Lobby-Szene @@ -123,7 +160,7 @@ Control (lobby.tscn, script: lobby.gd) │ └── Button ("Beitreten") └── WaitPanel (VBoxContainer) ├── Label ("Warte auf Spieler...") - ├── ItemList (zeigt alle verbundenen Spieler mit Name + Rolle) + ├── ItemList (zeigt alle verbundenen Spieler) └── Button ("Spiel starten") ← nur sichtbar wenn lokaler Spieler DM ist ``` @@ -140,6 +177,7 @@ func _ready() -> void: NetworkManager.connection_failed.connect(_on_connection_failed) NetworkManager.player_joined.connect(_on_player_joined) NetworkManager.player_left.connect(_on_player_left) + NetworkManager.player_list_synced.connect(_rebuild_player_list) NetworkManager.game_started.connect(_on_game_started) func _on_join_pressed() -> void: @@ -157,14 +195,12 @@ func _on_connected() -> void: $WaitPanel/Button.visible = (_local_role == "dm") func _on_connection_failed() -> void: - # Fehlermeldung anzeigen (z.B. Label einblenden) push_error("[Lobby] Verbindung fehlgeschlagen") -func _on_player_joined(peer_id: int, player_name: String, role: String) -> void: - $WaitPanel/ItemList.add_item("%s (%s)" % [player_name, role]) +func _on_player_joined(_peer_id: int, _player_name: String, _role: String) -> void: + _rebuild_player_list() -func _on_player_left(peer_id: int) -> void: - # ItemList neu aufbauen aus NetworkManager.players +func _on_player_left(_peer_id: int) -> void: _rebuild_player_list() func _rebuild_player_list() -> void: @@ -173,7 +209,7 @@ func _rebuild_player_list() -> void: $WaitPanel/ItemList.add_item("%s (%s)" % [p.name, p.role]) func _on_start_pressed() -> void: - NetworkManager.start_game.rpc() + NetworkManager.request_start_game.rpc_id(1) func _on_game_started() -> void: # Platzhalter: Szenen-Wechsel kommt im nächsten Schritt @@ -182,21 +218,22 @@ func _on_game_started() -> void: --- -## Wichtige Einschränkungen +## Bekannte Einschränkungen (bewusste TODOs) -- **IP hardcodiert** auf `127.0.0.1` — wird später konfigurierbar (z.B. als CLI-Argument oder Textfeld in der UI) -- **`start_game` → Szenen-Wechsel** ist Platzhalter (`print`) — wird im nächsten Feature implementiert +- **IP hardcodiert** auf `127.0.0.1` — wird später konfigurierbar +- **`game_started` → Szenen-Wechsel** ist Platzhalter (`print`) — kommt im nächsten Feature - **Keine Reconnect-Logik** — bei Verbindungsabbruch muss neu gestartet werden +- **Signal-Cleanup** in `lobby.gd` bei Szenen-Wechsel noch nicht implementiert (unkritisch solange Lobby nur einmal geladen wird) --- ## Verifikation 1. Server headless starten: `godot --headless -- --server` -2. Client 1 (Editor F5): Name "Klaus", Rolle "Spieler" → Join-Panel verschwindet, Warteraum erscheint, ItemList zeigt "Klaus (player)" +2. Client 1 (Editor F5): Name "Klaus", Rolle "Spieler" → WaitPanel erscheint, ItemList zeigt "Klaus (player)" 3. Client 2 (zweiter Prozess): Name "Marie", Rolle "DM" → beide ItemLists zeigen "Klaus (player)" und "Marie (dm)"; nur Marie sieht "Spiel starten" -4. Client 3 versucht mit Rolle "DM" → wird gekickt, `push_error` erscheint, Verbindung wird geschlossen -5. DM klickt "Spiel starten" → alle Clients loggen "[Lobby] Spiel gestartet!" +4. Client 3 versucht Rolle "DM" → `push_error` erscheint, Verbindung wird geschlossen +5. Client 2 (DM) klickt "Spiel starten" → alle Clients loggen "[Lobby] Spiel gestartet!" 6. Client disconnectet → andere Clients' ItemList wird aktualisiert ---