docs: fix Lobby + Rollen spec (RPC guards, sync order, UI rebuild)

This commit is contained in:
2026-04-13 04:56:32 +02:00
parent 3c29481a95
commit c95604280a

View File

@@ -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
---