docs: fix plan issues — RPC relay, lambda order, class_name, INTERACT_DISTANCE step

This commit is contained in:
2026-04-14 21:46:02 +02:00
parent 1cac787a8b
commit 530169cb79
2 changed files with 49 additions and 43 deletions

View File

@@ -4,7 +4,7 @@
**Goal:** Add a d20 roller to the player HUD; results are broadcast to all connected peers; DM sees a DC input field in the Regiepult.
**Architecture:** Two new RPCs in `network_manager.gd` handle roll broadcast. Player-side UI lives in a CanvasLayer added dynamically by `tavern.gd`. DM-side DC display is added to `dm_view.tscn`/`dm_view.gd`. No new Autoload. Roll log is local per client (no sync needed beyond the broadcast).
**Architecture:** Two new RPCs in `network_manager.gd` handle roll broadcast using the same server-relay pattern as overlay: player sends to server via `rpc_id(1)`, server re-broadcasts to all via `_relay_roll.rpc()`. Player-side UI lives in a CanvasLayer added dynamically by `tavern.gd`. DM-side DC display is added to `dm_view.tscn`/`dm_view.gd`. No new Autoload. Roll log is local per client (no sync needed beyond the broadcast).
**Tech Stack:** Godot 4.x GDScript, MultiplayerAPI RPC, CanvasLayer UI
@@ -16,13 +16,14 @@
- `NetworkManager.my_id` is the local peer ID
- `tavern.gd._ready()` already skips PlayerController for DM — dice UI must also skip for DM
- `dm_view.tscn` SidePanel has `OverlayScroll` — DC section goes below it
- Roll flow: Player rolls locally → calls `broadcast_roll.rpc()` → all peers receive → update local log
- Roll flow: Player rolls locally → `broadcast_roll.rpc_id(1, ...)` → server receives → `_relay_roll.rpc()` → all peers receive + emit `roll_received` signal → update local log
- In ENet star topology, clients cannot directly address all peers — all broadcasts must go through the server. Same pattern as `request_set_overlay``set_overlay`.
## Files
| File | Action |
|------|--------|
| `scripts/network_manager.gd` | Add `broadcast_roll` RPC |
| `scripts/network_manager.gd` | Add `broadcast_roll` RPC (client→server) and `_relay_roll` RPC (server→all) |
| `scripts/tavern.gd` | Add dice CanvasLayer + roll logic |
| `scenes/dm_view.tscn` | Add DC section to SidePanel |
| `scripts/dm_view.gd` | Handle incoming rolls, update DC display |
@@ -34,30 +35,31 @@
**Files:**
- Modify: `scripts/network_manager.gd` — append one RPC
The roll is trusted from the client (this is a cooperative game, not a competitive one). No server validation needed — simpler and fewer round trips.
The roll is trusted from the client (cooperative game, not competitive). Uses the same server-relay pattern as overlays: client → server → server broadcasts to all.
- [ ] **Step 1: Add RPC at end of network_manager.gd**
```gdscript
@rpc("any_peer", "call_local", "reliable")
func broadcast_roll(roller_peer_id: int, d20_result: int, modifier: int) -> void:
pass # Handled by whoever connects to this signal via _notification or direct call
```
Wait — GDScript doesn't have signal-from-RPC natively. Instead, emit a signal:
- [ ] **Step 1: Add signal and two RPCs at end of network_manager.gd**
Add the signal near the other signal declarations at the top of the file:
```gdscript
signal roll_received(roller_peer_id: int, player_name: String, d20_result: int, modifier: int, total: int)
```
@rpc("any_peer", "call_local", "reliable")
Add two RPCs at the end of the file:
```gdscript
# Client calls this on server only (rpc_id(1, ...))
@rpc("any_peer", "call_remote", "reliable")
func broadcast_roll(roller_peer_id: int, d20_result: int, modifier: int) -> void:
if not multiplayer.is_server(): return
_relay_roll.rpc(roller_peer_id, d20_result, modifier)
# Server broadcasts to all (including itself via call_local)
@rpc("authority", "call_local", "reliable")
func _relay_roll(roller_peer_id: int, d20_result: int, modifier: int) -> void:
var player_name: String = players.get(roller_peer_id, {}).get("name", "???")
var total := d20_result + modifier
roll_received.emit(roller_peer_id, player_name, d20_result, modifier, total)
```
Add `signal roll_received(...)` near the other signal declarations at the top of the file.
- [ ] **Step 2: Open Godot, check for parse errors**
Project → Reload. No errors in NetworkManager.
@@ -119,15 +121,17 @@ The dice panel is a CanvasLayer added programmatically in `tavern.gd`. DM client
mod_label.text = " Mod:"
hbox.add_child(mod_label)
# Declare mod_display BEFORE the lambdas that capture it
var mod_display := Label.new()
mod_display.text = "0"
mod_display.custom_minimum_size = Vector2(30, 0)
mod_display.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var mod_minus := Button.new()
mod_minus.text = "-"
mod_minus.pressed.connect(func() -> void: _modifier -= 1; _update_mod_display(mod_display))
hbox.add_child(mod_minus)
var mod_display := Label.new()
mod_display.text = "0"
mod_display.custom_minimum_size = Vector2(30, 0)
mod_display.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hbox.add_child(mod_display)
var mod_plus := Button.new()
@@ -142,7 +146,7 @@ The dice panel is a CanvasLayer added programmatically in `tavern.gd`. DM client
func _on_roll_pressed() -> void:
var d20 := randi() % 20 + 1
NetworkManager.broadcast_roll.rpc(NetworkManager.my_id, d20, _modifier)
NetworkManager.broadcast_roll.rpc_id(1, NetworkManager.my_id, d20, _modifier)
func _on_roll_received(roller_peer_id: int, player_name: String, d20_result: int, modifier: int, total: int) -> void:
var mod_str := " %+d" % modifier if modifier != 0 else ""
@@ -156,12 +160,14 @@ The dice panel is a CanvasLayer added programmatically in `tavern.gd`. DM client
- [ ] **Step 2: Call _setup_dice_ui() in _ready() for players only**
In the existing `_ready()`, after `_spawn_player(room_index)` and before the timer:
In the existing `_ready()`, add at the very end — after the timer block. The DM already returns early at the top of `_ready()` (before `_spawn_player` is reached), so this call is DM-safe without any extra guard:
```gdscript
_setup_dice_ui()
```
**Important:** do not add this inside the `if role == "dm": return` block and do not add it before the timer. Place it as the last statement in `_ready()`.
- [ ] **Step 3: Open Godot, check for parse errors**
- [ ] **Step 4: Run as player client, click "d20 Würfeln"**

View File

@@ -99,6 +99,7 @@ Any object in the world that players can interact with (keys, switches, doors) g
# Base class for all interactable objects in the world.
# Attach to a StaticBody3D or Area3D that has a CollisionShape3D.
# Override interact() in subclasses.
class_name Interactable
extends Node3D
## Called when a player interacts with this object.
@@ -107,6 +108,8 @@ Any object in the world that players can interact with (keys, switches, doors) g
pass
```
The `class_name Interactable` declaration is required so subclasses can use `extends Interactable` instead of the fragile string path form.
- [ ] **Step 2: Commit**
```bash
@@ -135,7 +138,6 @@ This script is attached to the `CharacterBody3D` that `tavern.gd` creates. It ha
var _camera: Camera3D
var _raycast: RayCast3D
var _interact_label: Label
func _ready() -> void:
@@ -157,8 +159,6 @@ This script is attached to the `CharacterBody3D` that `tavern.gd` creates. It ha
velocity.z = move_dir.z * SPEED
move_and_slide()
_update_interact_hint()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
@@ -181,13 +181,6 @@ This script is attached to the `CharacterBody3D` that `tavern.gd` creates. It ha
var target := _raycast.get_collider()
if target != null and target.has_method("interact"):
target.interact(NetworkManager.my_id)
func _update_interact_hint() -> void:
if _interact_label == null:
return
_interact_label.visible = _raycast.is_colliding() and _raycast.get_collider() != null \
and _raycast.get_collider().has_method("interact")
```
- [ ] **Step 2: Open Godot, check for parse errors**
@@ -204,9 +197,18 @@ This script is attached to the `CharacterBody3D` that `tavern.gd` creates. It ha
## Task 4: Update tavern.gd _spawn_player() to use player_controller.gd
**Files:**
- Modify: `scripts/tavern.gd` — `_spawn_player()` method
- Modify: `scripts/tavern.gd` — `_spawn_player()` method only
- [ ] **Step 1: Replace _spawn_player() in tavern.gd**
**Important:** Only replace `_spawn_player()`. Do NOT modify `_ready()`. If this plan is implemented after the Dice Roller plan, `_ready()` will already contain a `_setup_dice_ui()` call that must be preserved.
- [ ] **Step 1: Add INTERACT_DISTANCE constant to tavern.gd**
At the top of `tavern.gd`, alongside existing constants, add:
```gdscript
const INTERACT_DISTANCE := 2.5
```
- [ ] **Step 2: Replace _spawn_player() in tavern.gd**
Current implementation creates `Node3D.new()`. Replace with:
@@ -250,24 +252,22 @@ This script is attached to the `CharacterBody3D` that `tavern.gd` creates. It ha
controller.global_rotation = spawn.global_rotation
```
Note: add `const INTERACT_DISTANCE := 2.5` constant at the top of `tavern.gd`.
- [ ] **Step 2: Verify _broadcast_camera_transform() still works**
- [ ] **Step 3: Verify _broadcast_camera_transform() still works**
The method calls `get_node_or_null("PlayerController") as Node3D`. `CharacterBody3D` extends `Node3D`, so the cast works. It also calls `controller.get_node_or_null("Camera3D")` — still correct since camera is still named `Camera3D`.
However, the camera is now at local position (0, 1.6, 0) inside the controller. The broadcast sends `cam.global_position` / `cam.global_rotation` — this is correct (global, not local).
The camera is now at local position (0, 1.6, 0) inside the controller. The broadcast sends `cam.global_position` / `cam.global_rotation` — correct (global, not local).
- [ ] **Step 3: Open Godot, check for parse errors**
- [ ] **Step 4: Open Godot, check for parse errors**
- [ ] **Step 4: Run as player client**
- [ ] **Step 5: Run as player client**
- WASD moves player
- Mouse look works
- Player doesn't fall through floor (capsule collision)
- Camera is at eye level (~1.6m)
- [ ] **Step 5: Commit**
- [ ] **Step 6: Commit**
```bash
git add ruf-der-pilze/scripts/tavern.gd
@@ -287,14 +287,14 @@ Add one interactable object in the tavern (e.g., the bar counter or a crate) to
- [ ] **Step 1: Create scripts/test_interactable.gd**
```gdscript
extends interactable
extends Interactable
func interact(peer_id: int) -> void:
var player_name: String = NetworkManager.players.get(peer_id, {}).get("name", "???")
print("[Interactable] %s hat interagiert (Peer %d)" % [player_name, peer_id])
```
Note: `extends interactable` — GDScript allows extending a script file directly. Or use `extends "res://scripts/interactable.gd"`.
`extends Interactable` works because `interactable.gd` declares `class_name Interactable` (Task 2).
- [ ] **Step 2: Add test object to tavern.tscn via Godot editor**