From 530169cb795a90e0e1df1d5f9cef7645832d07b7 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Tue, 14 Apr 2026 21:46:02 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20fix=20plan=20issues=20=E2=80=94=20RPC?= =?UTF-8?q?=20relay,=20lambda=20order,=20class=5Fname,=20INTERACT=5FDISTAN?= =?UTF-8?q?CE=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-04-14-dice-roller.md | 50 ++++++++++++--------- docs/plans/2026-04-14-player-interaction.md | 42 ++++++++--------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/docs/plans/2026-04-14-dice-roller.md b/docs/plans/2026-04-14-dice-roller.md index 37e8a8b..1d25a9a 100644 --- a/docs/plans/2026-04-14-dice-roller.md +++ b/docs/plans/2026-04-14-dice-roller.md @@ -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"** diff --git a/docs/plans/2026-04-14-player-interaction.md b/docs/plans/2026-04-14-player-interaction.md index d17d8c0..5cfa9ad 100644 --- a/docs/plans/2026-04-14-player-interaction.md +++ b/docs/plans/2026-04-14-player-interaction.md @@ -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**