docs: add Lobby + Rollen implementation plan

This commit is contained in:
2026-04-13 05:00:02 +02:00
parent c95604280a
commit 3894a4550d

View File

@@ -0,0 +1,468 @@
# Lobby + Rollen — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Spieler verbinden sich über eine UI-Lobby, geben Namen und Rolle (Spieler/DM) ein, sehen alle anderen im Warteraum, und der DM startet das Spiel.
**Architecture:** `NetworkManager` (Autoload) wird um `players`-Dict, Lobby-RPCs und -Signale erweitert. `lobby.tscn` enthält ein Join-Panel und ein Wait-Panel. `main.tscn` lädt `lobby.tscn` als Child. Alle Spielerdaten leben in `NetworkManager.players` für Zugriff durch spätere Szenen.
**Tech Stack:** Godot 4.6.2, GDScript, ENetMultiplayerPeer, Godot MultiplayerAPI, Godot Control-Nodes
**Spec:** `docs/plans/2026-04-13-lobby-rollen-design.md`
**Arbeitsverzeichnis:** Alle Bash-Befehle aus `/home/mpuchstein/Dev/Godot/DnD_Anna_OneShot/` (Repo-Root), sofern nicht anders angegeben.
---
## Dateiübersicht
| Datei | Aktion | Verantwortlichkeit |
|---|---|---|
| `ruf-der-pilze/scripts/network_manager.gd` | Erweitern | `players`-Dict, neue Signale, neue RPCs |
| `ruf-der-pilze/scripts/lobby.gd` | Erstellen | Lobby-UI-Logik (Join-Flow, Warteraum) |
| `ruf-der-pilze/scenes/lobby.tscn` | Erstellen | Node-Struktur: JoinPanel + WaitPanel |
| `ruf-der-pilze/scenes/main.tscn` | Erweitern | `lobby.tscn` als Child-Szene einbinden |
---
### Task 1: NetworkManager erweitern — Variablen + Signale
**Files:**
- Modify: `ruf-der-pilze/scripts/network_manager.gd`
- [ ] **Step 1: `players`-Dict und neue Signale hinzufügen**
Direkt nach Zeile 9 (`var my_id: int = 0`) einfügen:
```gdscript
var players: Dictionary = {} # peer_id (int) → {name: String, role: String}
signal player_joined(peer_id: int, player_name: String, role: String)
signal player_left(peer_id: int)
signal player_list_synced()
signal game_started()
```
- [ ] **Step 2: `_on_peer_disconnected` anpassen**
Aktuelle Zeilen 4245 ersetzen durch:
```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)
```
- [ ] **Step 3: Datei prüfen**
```bash
head -20 ruf-der-pilze/scripts/network_manager.gd
```
Erwartete Ausgabe: `players: Dictionary`, alle vier neuen Signale sichtbar.
- [ ] **Step 4: Commit**
```bash
git add ruf-der-pilze/scripts/network_manager.gd
git commit -m "net: add players dict and lobby signals to NetworkManager"
```
---
### Task 2: NetworkManager erweitern — Lobby-RPCs
**Files:**
- Modify: `ruf-der-pilze/scripts/network_manager.gd`
- [ ] **Step 1: Sechs neue RPC-Funktionen ans Ende der Datei anhängen**
```gdscript
@rpc("any_peer", "call_remote", "reliable")
func register(player_name: String, role: String) -> void:
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)
@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)
@rpc("authority", "call_remote", "reliable")
func sync_players(player_list: Dictionary) -> void:
# Nur Dict befüllen — kein Signal per Spieler emittieren.
# Lobby-UI rebuilt die Liste einmalig via player_list_synced.
players = player_list
player_list_synced.emit()
@rpc("authority", "call_remote", "reliable")
func kick(reason: String) -> void:
push_error("[Client] Verbindung abgelehnt: %s" % reason)
multiplayer.multiplayer_peer.close.call_deferred()
@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()
@rpc("authority", "call_local", "reliable")
func start_game() -> void:
game_started.emit()
```
- [ ] **Step 2: Einrückung prüfen (Tabs, nicht Spaces)**
```bash
cat -A ruf-der-pilze/scripts/network_manager.gd | grep -c "^\^I"
```
Alle eingerückten Zeilen müssen mit `^I` (Tab) beginnen. Keine Spaces.
- [ ] **Step 3: Commit**
```bash
git add ruf-der-pilze/scripts/network_manager.gd
git commit -m "net: add register/kick/sync/start_game RPCs to NetworkManager"
```
---
### Task 3: lobby.tscn erstellen
**Files:**
- Create: `ruf-der-pilze/scenes/lobby.tscn`
- [ ] **Step 1: `lobby.tscn` als Textdatei erstellen**
Erstelle `ruf-der-pilze/scenes/lobby.tscn` mit folgendem Inhalt:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1"]
[node name="Lobby" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")
[node name="JoinPanel" type="VBoxContainer" parent="."]
offset_left = 300.0
offset_top = 200.0
offset_right = 700.0
offset_bottom = 500.0
[node name="Title" type="Label" parent="JoinPanel"]
text = "Ruf der Pilze"
[node name="NameInput" type="LineEdit" parent="JoinPanel"]
placeholder_text = "Dein Name"
[node name="RoleOption" type="OptionButton" parent="JoinPanel"]
[node name="JoinButton" type="Button" parent="JoinPanel"]
text = "Beitreten"
[node name="WaitPanel" type="VBoxContainer" parent="."]
visible = false
offset_left = 300.0
offset_top = 200.0
offset_right = 700.0
offset_bottom = 500.0
[node name="WaitLabel" type="Label" parent="WaitPanel"]
text = "Warte auf Spieler..."
[node name="PlayerList" type="ItemList" parent="WaitPanel"]
custom_minimum_size = Vector2(0, 150)
[node name="StartButton" type="Button" parent="WaitPanel"]
text = "Spiel starten"
visible = false
```
**Hinweis zu RoleOption:** Die Optionen "Spieler" und "DM" werden in `lobby.gd` per Code hinzugefügt (`add_item()`), nicht in der `.tscn`.
- [ ] **Step 2: Datei prüfen**
```bash
cat ruf-der-pilze/scenes/lobby.tscn
```
Alle Nodes vorhanden: `Lobby`, `JoinPanel`, `NameInput`, `RoleOption`, `JoinButton`, `WaitPanel`, `WaitLabel`, `PlayerList`, `StartButton`.
- [ ] **Step 3: Commit**
```bash
git add ruf-der-pilze/scenes/lobby.tscn
git commit -m "net: add lobby.tscn with join and wait panels"
```
---
### Task 4: lobby.gd erstellen
**Files:**
- Create: `ruf-der-pilze/scripts/lobby.gd`
- [ ] **Step 1: `lobby.gd` erstellen**
Erstelle `ruf-der-pilze/scripts/lobby.gd` (Tabs für Einrückung):
```gdscript
extends Control
var _local_role: String = ""
func _ready() -> void:
$JoinPanel/RoleOption.add_item("Spieler")
$JoinPanel/RoleOption.add_item("DM")
$JoinPanel/JoinButton.pressed.connect(_on_join_pressed)
$WaitPanel/StartButton.pressed.connect(_on_start_pressed)
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 player_name := $JoinPanel/NameInput.text.strip_edges()
if player_name.is_empty():
return
_local_role = "dm" if $JoinPanel/RoleOption.selected == 1 else "player"
NetworkManager.join_server("127.0.0.1", 4242)
func _on_connected() -> void:
var player_name := $JoinPanel/NameInput.text.strip_edges()
NetworkManager.register.rpc_id(1, player_name, _local_role)
$JoinPanel.visible = false
$WaitPanel.visible = true
$WaitPanel/StartButton.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/PlayerList.clear()
for p in NetworkManager.players.values():
$WaitPanel/PlayerList.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 Feature
print("[Lobby] Spiel gestartet!")
```
- [ ] **Step 2: Tab-Einrückung prüfen**
```bash
cat -A ruf-der-pilze/scripts/lobby.gd | head -30
```
Alle Einrückungen erscheinen als `^I`.
- [ ] **Step 3: Commit**
```bash
git add ruf-der-pilze/scripts/lobby.gd
git commit -m "net: add lobby.gd with join flow and wait room logic"
```
---
### Task 5: main.tscn erweitern — Lobby einbinden
**Files:**
- Modify: `ruf-der-pilze/scenes/main.tscn`
- [ ] **Step 1: `lobby.tscn` als instanziierte Child-Szene in `main.tscn` eintragen**
Aktueller Inhalt von `main.tscn`:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[node name="Main" type="Node"]
script = ExtResource("1")
```
Ersetzen durch:
```
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/lobby.tscn" id="2"]
[node name="Main" type="Node"]
script = ExtResource("1")
[node name="Lobby" parent="." instance=ExtResource("2")]
```
- [ ] **Step 2: Prüfen**
```bash
cat ruf-der-pilze/scenes/main.tscn
```
`load_steps=3`, beide ext_resources sichtbar, `Lobby`-Node als Child.
- [ ] **Step 3: Commit**
```bash
git add ruf-der-pilze/scenes/main.tscn
git commit -m "net: add lobby scene instance to main.tscn"
```
---
### Task 6: Integration testen
**Files:** keine Änderungen — nur Verifikation
- [ ] **Step 1: Server headless starten**
```bash
# aus ruf-der-pilze/
godot --headless -- --server
```
Erwartete Ausgabe:
```
[Server] Gestartet auf Port 4242
```
- [ ] **Step 2: Client 1 verbinden (Godot Editor, F5)**
Im Godot Editor F5 drücken. Die Lobby-UI erscheint:
- JoinPanel mit Namensfeld, Rollenauswahl ("Spieler" / "DM"), "Beitreten"-Button
Name eingeben (z.B. "Klaus"), Rolle "Spieler", "Beitreten" klicken.
Erwartetes Ergebnis:
- JoinPanel verschwindet, WaitPanel erscheint
- PlayerList zeigt: "Klaus (player)"
- "Spiel starten" Button ist **nicht** sichtbar
Server-Terminal:
```
[Server] Peer verbunden: XXXXXX
```
- [ ] **Step 3: Client 2 verbinden (zweiter Godot-Prozess)**
Zweiten Godot-Prozess starten (z.B. über Terminal):
```bash
# aus ruf-der-pilze/
godot
```
Name "Marie", Rolle "DM", "Beitreten".
Erwartetes Ergebnis:
- Beide Clients zeigen in der PlayerList: "Klaus (player)" und "Marie (dm)"
- Nur Marie sieht "Spiel starten"
- [ ] **Step 4: DM-Duplikat testen (Kick)**
Dritten Client starten, Rolle "DM" wählen, "Beitreten".
Erwartetes Ergebnis:
- `push_error` im Output: `[Client] Verbindung abgelehnt: Es ist bereits ein DM verbunden.`
- Verbindung wird geschlossen
- [ ] **Step 5: Spiel starten**
DM ("Marie") klickt "Spiel starten".
Erwartetes Ergebnis (alle Clients):
```
[Lobby] Spiel gestartet!
```
- [ ] **Step 6: Disconnect testen**
Einen Client schließen → andere Clients' PlayerList aktualisiert sich.
- [ ] **Step 7: STATUS.md updaten**
`/home/mpuchstein/Dev/Godot/DnD_Anna_OneShot/docs/STATUS.md` aktualisieren:
```markdown
### ✅ Abgeschlossen
- MCP-Addon eingerichtet
- Projektstruktur angelegt
- CLAUDE.md mit vollständiger Spielkonzept-Dokumentation
- **Multiplayer Grundgerüst** — ENet Server/Client, NetworkManager Autoload, welcome RPC
- **Lobby + Rollen** — Join-UI, Warteraum, Spieler/DM-Rollen, sync, kick, Spiel-Start-Signal
### ⏳ Als nächstes
- **Szenen-Wechsel nach Spielstart** — lobby.tscn → Spielszene (Eingangshalle)
Plan: noch zu erstellen
```
- [ ] **Step 8: Pläne archivieren + Commit**
```bash
mkdir -p docs/plans/archive
mv docs/plans/2026-04-13-lobby-rollen-design.md docs/plans/archive/
mv docs/plans/2026-04-13-lobby-rollen-plan.md docs/plans/archive/
git add docs/
git commit -m "docs: mark Lobby + Rollen as complete, archive plans, update STATUS"
```
---
## Verifikations-Checkliste
- [ ] Server startet: `[Server] Gestartet auf Port 4242`
- [ ] Client verbindet: JoinPanel → WaitPanel Übergang funktioniert
- [ ] PlayerList zeigt alle verbundenen Spieler (Name + Rolle)
- [ ] Zweiter DM wird gekickt mit Fehlermeldung
- [ ] Nur DM sieht "Spiel starten" Button
- [ ] Alle Clients loggen `[Lobby] Spiel gestartet!` wenn DM startet
- [ ] Disconnect entfernt Spieler aus der Liste anderer Clients
- [ ] Kein Parser-Fehler in Godot Output