# Lobby + Rollen — Design Spec

**Datum:** 2026-04-13  
**Status:** Abgenommen  
**Projekt:** Ruf der Pilze (`ruf-der-pilze/`)

---

## Kontext

Das Multiplayer-Grundgerüst steht (ENet, NetworkManager, RPC verifiziert). Dieser Schritt fügt die Lobby hinzu: Spieler geben Namen und Rolle ein, sehen wer verbunden ist, und der DM startet das Spiel. Ohne Lobby können keine Spieler-Identitäten (Name, Rolle) über das Netzwerk bekannt gemacht werden — was alle Folge-Features blockiert.

---

## Entscheidungen

| Entscheidung | Wahl | Begründung |
|---|---|---|
| UI | Lobby-Szene mit Join-Formular + Warteraum | Notwendig für Spieler die keine CLI kennen |
| 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-Client sendet `request_start_game` → Server ruft `start_game.rpc()` | Client darf `authority`-RPCs nicht direkt rufen |

---

## Dateistruktur

```
ruf-der-pilze/
├── scripts/
│   ├── 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 starten den Server oder verbinden sich. Die Lobby-Szene wird als erste Szene in `main.tscn` geladen.

---

## NetworkManager-Erweiterungen

### Neue Variable

```gdscript
var players: Dictionary = {}  # peer_id (int) → {name: String, role: String}
```

### Neue Signale

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

Server bereinigt lokal und broadcastet den Disconnect an alle anderen Clients:

```gdscript
func _on_peer_disconnected(id: int) -> void:
    peers.erase(id)
    players.erase(id)
    peer_disconnected.emit(id)
    player_left.emit(id)
    _broadcast_player_left.rpc(id)  # andere Clients informieren
    print("[Server] Peer getrennt: %d" % id)
```

### Neues RPC: `_broadcast_player_left` (Server → alle anderen Clients)

```gdscript
# call_remote: Server hat bereits lokal gelöscht; nur Clients führen aus
@rpc("authority", "call_remote", "reliable")
func _broadcast_player_left(peer_id: int) -> void:
    players.erase(peer_id)
    player_left.emit(peer_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
    # 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
# 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)
    # 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_local", "reliable")
func start_game() -> void:
    game_started.emit()
```

---

## Lobby-Szene

### Node-Struktur (`lobby.tscn`)

```
Control (lobby.tscn, script: lobby.gd)
├── JoinPanel (VBoxContainer)
│   ├── Label ("Ruf der Pilze")
│   ├── LineEdit (Name, placeholder: "Dein Name")
│   ├── OptionButton (Optionen: "Spieler", "DM")
│   └── Button ("Beitreten")
└── WaitPanel (VBoxContainer)
    ├── Label ("Warte auf Spieler...")
    ├── ItemList (zeigt alle verbundenen Spieler)
    └── Button ("Spiel starten")  ← nur sichtbar wenn lokaler Spieler DM ist
```

### Lobby-Logik (`lobby.gd`)

```gdscript
extends Control

var _local_role: String = ""

func _ready() -> void:
    $WaitPanel.visible = false
    NetworkManager.connected_to_server.connect(_on_connected)
    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:
    var name := $JoinPanel/LineEdit.text.strip_edges()
    if name.is_empty():
        return
    _local_role = "dm" if $JoinPanel/OptionButton.selected == 1 else "player"
    NetworkManager.join_server("127.0.0.1", 4242)

func _on_connected() -> void:
    var name := $JoinPanel/LineEdit.text.strip_edges()
    NetworkManager.register.rpc_id(1, name, _local_role)
    $JoinPanel.visible = false
    $WaitPanel.visible = true
    $WaitPanel/Button.visible = (_local_role == "dm")

func _on_connection_failed() -> void:
    push_error("[Lobby] Verbindung fehlgeschlagen")

func _on_player_joined(_peer_id: int, _player_name: String, _role: String) -> void:
    _rebuild_player_list()

func _on_player_left(_peer_id: int) -> void:
    _rebuild_player_list()

func _rebuild_player_list() -> void:
    $WaitPanel/ItemList.clear()
    for p in NetworkManager.players.values():
        $WaitPanel/ItemList.add_item("%s (%s)" % [p.name, p.role])

func _on_start_pressed() -> void:
    NetworkManager.request_start_game.rpc_id(1)

func _on_game_started() -> void:
    # Platzhalter: Szenen-Wechsel kommt im nächsten Schritt
    print("[Lobby] Spiel gestartet!")
```

---

## Bekannte Einschränkungen (bewusste TODOs)

- **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" → 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 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

---

## Nächster Schritt

Szenen-Wechsel nach Spielstart: `lobby.tscn` wird durch die erste Spielszene (Eingangshalle oder Refektorium) ersetzt.
