Collisions for Area Change Trigger

This commit is contained in:
torsten 2026-02-15 14:51:00 +01:00
parent 721f005ff3
commit 1eeca653f5
5 changed files with 74 additions and 70 deletions

View file

@ -1,7 +1,3 @@
# Projektübersicht: weltenauge
Dieses Dokument dient als zentrale Wissensbasis für das Godot-Projekt "weltenauge". Es soll dem KI-Assistenten und den Entwicklern helfen, den Überblick über die Architektur und die Ziele des Projekts zu behalten.
## 1. Projektziel & Vision
"weltenauge" ist ein von Neverwinter Nights (NWN) inspiriertes Multiplayer-Rollenspiel. Das Kernstück ist eine robuste Client/Server-Architektur, die in einer einzigen Godot-Codebase umgesetzt wird.
@ -12,8 +8,8 @@ Dieses Dokument dient als zentrale Wissensbasis für das Godot-Projekt "weltenau
- **Eine Codebase:** Client und Server sind Teil desselben Godot-Projekts.
- **Start-Logik:**
- Im normalen Modus startet das Spiel mit einem Menü, das "Host" und "Beitreten" anbietet.
- Im Headless-Modus (`--headless`) startet das Programm automatisch als dedizierter Server (Host).
- Im normalen Modus startet das Spiel mit einem Menü, das "Host" und "Beitreten" anbietet.
- Im Headless-Modus (`--headless`) startet das Programm automatisch als dedizierter Server (Host).
- **Verantwortlichkeiten:** Der Server (Host) ist die Autorität für den Spielzustand. Clients empfangen und visualisieren den Zustand.
### Dynamisches Area-Management
@ -83,17 +79,17 @@ Dieser Abschnitt beschreibt die Verantwortlichkeiten der zentralen Skripte im Pr
### `/systems/network/network_manager.gd` (Autoload: `NetworkManager`)
- **Rolle:** Das Herzstück der gesamten Anwendung.
- **Verantwortlichkeit:**
- **Startlogik:** Prüft in `_ready()` selbst, ob das Spiel headless gestartet wurde und ruft dann `host_game()` auf.
- **Verbindungs-Management:** Startet den Server (`host_game`) oder verbindet als Client (`join_game`).
- **Area-Management:** Lädt und verwaltet die Spielwelten (Areas). Erstellt für jede Area auf dem Server einen eigenen `SubViewport` mit `own_world_3d = true`, um die Physikwelten zu trennen.
- **Spieler-Spawning:** Koordiniert den komplexen Prozess des Spieler-Spawns. Weist den Client an, eine Szene zu laden (`client_load_area`), wartet auf dessen Bereitschaftsmeldung (`client_ready_to_spawn`) und instanziiert dann den Spieler-Avatar serverseitig in der korrekten Sub-Welt.
- **Startlogik:** Prüft in `_ready()` selbst, ob das Spiel headless gestartet wurde und ruft dann `host_game()` auf.
- **Verbindungs-Management:** Startet den Server (`host_game`) oder verbindet als Client (`join_game`).
- **Area-Management:** Lädt und verwaltet die Spielwelten (Areas). Erstellt für jede Area auf dem Server einen eigenen `SubViewport` mit `own_world_3d = true`, um die Physikwelten zu trennen.
- **Spieler-Spawning:** Koordiniert den komplexen Prozess des Spieler-Spawns. Weist den Client an, eine Szene zu laden (`client_load_area`), wartet auf dessen Bereitschaftsmeldung (`client_ready_to_spawn`) und instanziiert dann den Spieler-Avatar serverseitig in der korrekten Sub-Welt.
### `/systems/player/player_controller.gd`
- **Rolle:** Steuerungslogik für den Spieler-Avatar.
- **Verantwortlichkeit:** Dieses Skript läuft auf dem Server und allen Clients, verhält sich aber je nach Autorität unterschiedlich:
- **Client (mit Autorität):** Fängt Mauseklicks ab (`_input`), berechnet die Zielposition auf dem Boden und sendet einen `request_move` RPC an den Server.
- **Server:** Empfängt `request_move`, berechnet den Pfad (aktuell nur direktes Ziel) und bewegt den `CharacterBody3D` mittels `move_and_slide()` im `_physics_process`. Die resultierende Position und Rotation werden in `sync_position` und `sync_rotation` gespeichert.
- **Client (ohne Autorität):** Liest im `_process` kontinuierlich `sync_position` und `sync_rotation` aus und setzt die `global_position` und `rotation` des Avatars hart auf diese Werte, um ihn mit dem Server synchron zu halten.
- **Client (mit Autorität):** Fängt Mauseklicks ab (`_input`), berechnet die Zielposition auf dem Boden und sendet einen `request_move` RPC an den Server.
- **Server:** Empfängt `request_move`, berechnet den Pfad (aktuell nur direktes Ziel) und bewegt den `CharacterBody3D` mittels `move_and_slide()` im `_physics_process`. Die resultierende Position und Rotation werden in `sync_position` und `sync_rotation` gespeichert.
- **Client (ohne Autorität):** Liest im `_process` kontinuierlich `sync_position` und `sync_rotation` aus und setzt die `global_position` und `rotation` des Avatars hart auf diese Werte, um ihn mit dem Server synchron zu halten.
### `/systems/player/camera_logic.gd`
- **Rolle:** Kamera-Steuerung.
@ -116,19 +112,18 @@ Die Konfiguration des `MultiplayerSynchronizer` für wiederverwendbare Szenen (w
2. **Ort der Konfiguration: `_ready()`:** Die Logik zur Erstellung der `SceneReplicationConfig` gehört in die `_ready()`-funktion des Skripts, dessen Eigenschaften synchronisiert werden sollen (z.B. `player_controller.gd`).
3. **Korrekte Eigenschafts-Syntax:** Der Pfad zu einer Eigenschaft, die sich direkt auf dem `root_path`-Node des Synchronizers befindet (standardmäßig sein Parent), **muss mit einem Doppelpunkt beginnen**.
- **Korrekt:** `config.add_property(":sync_position")`
- **Falsch:** `config.add_property("sync_position")` oder `config.add_property("..:sync_position")`
- **Korrekt:** `config.add_property(":sync_position")`
- **Falsch:** `config.add_property("sync_position")` oder `config.add_property("..:sync_position")`
4. **Timing im Spawner sicherstellen (`async`/`await`):** Der Code, der den Node instanziiert und dem Baum hinzufügt (z.B. `_spawn_player_in_world` im `NetworkManager`), muss nach dem `add_child()`-Aufruf explizit auf den nächsten Frame warten, bevor er den Synchronizer des neuen Nodes benutzt. Dies stellt sicher, dass dessen `_ready()`-Funktion bereits ausgeführt wurde.
```gdscript
# Im NetworkManager
async func _spawn_player_in_world(id, viewport_node):
# ...
viewport_node.add_child(player)
await get_tree().process_frame # Garantiert, dass _ready() auf player läuft
# Jetzt ist die Benutzung des Synchronizers sicher
var sync = player.get_node("MultiplayerSynchronizer")
sync.set_visibility_for(id, true)
# ...
```
```gdscript
# Im NetworkManager
async func _spawn_player_in_world(id, viewport_node):
# ...
viewport_node.add_child(player)
await get_tree().process_frame # Garantiert, dass _ready() auf player läuft
# Jetzt ist die Benutzung des Synchronizers sicher
var sync = player.get_node("MultiplayerSynchronizer")
sync.set_visibility_for(id, true)
# ...
```

View file

@ -18,6 +18,9 @@ albedo_color = Color(9.3854965e-07, 0.4579985, 0.32070312, 1)
[sub_resource type="PlaneMesh" id="PlaneMesh_0iae2"]
material = SubResource("StandardMaterial3D_30klc")
[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_1yg6u"]
data = PackedVector3Array(1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, 1, -1, 0, -1, 1, 0, -1)
[node name="keuwin_grasland" type="Node3D" unique_id=1953380732]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1262732162]
@ -35,6 +38,11 @@ environment = SubResource("Environment_8r0t0")
transform = Transform3D(10, 0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0)
mesh = SubResource("PlaneMesh_0iae2")
[node name="StaticBody3D" type="StaticBody3D" parent="Architecture/MeshInstance3D" unique_id=209028061]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Architecture/MeshInstance3D/StaticBody3D" unique_id=1119853048]
shape = SubResource("ConcavePolygonShape3D_1yg6u")
[node name="Navigation" type="Node3D" parent="." unique_id=1472582667]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01873529, 0)

View file

@ -21,6 +21,9 @@ albedo_color = Color(0.1800012, 0.004964735, 0.2305904, 1)
[sub_resource type="PlaneMesh" id="PlaneMesh_0iae2"]
material = SubResource("StandardMaterial3D_30klc")
[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_0hy1c"]
data = PackedVector3Array(1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, 1, -1, 0, -1, 1, 0, -1)
[node name="halle_der_ahnen" type="Node3D" unique_id=1953380732]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1262732162]
@ -38,6 +41,11 @@ environment = SubResource("Environment_8r0t0")
transform = Transform3D(10, 0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0)
mesh = SubResource("PlaneMesh_0iae2")
[node name="StaticBody3D" type="StaticBody3D" parent="Architecture/MeshInstance3D" unique_id=1395982254]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Architecture/MeshInstance3D/StaticBody3D" unique_id=521000493]
shape = SubResource("ConcavePolygonShape3D_0hy1c")
[node name="Navigation" type="Node3D" parent="." unique_id=1472582667]
[node name="SpawnPoints" type="Node3D" parent="." unique_id=438053421]

View file

@ -54,7 +54,7 @@ func _on_player_disconnected(id):
func change_player_area(id: int, area_key: String):
if not multiplayer.is_server(): return
print("Server is loading ", area_key)
var path = area_paths[area_key
var path = area_paths[area_key]
var container_name = area_key + "_container"
var viewport_name = area_key + "_viewport"
@ -111,7 +111,7 @@ func client_ready_to_spawn():
if not multiplayer.is_server(): return
if player_worlds.has(id):
var viewport_node = player_worlds[id
var viewport_node = player_worlds[id]
print("Client ", id, " ist bereit. Spawne jetzt.")
_spawn_player_in_world(id, viewport_node)
@ -152,7 +152,8 @@ func client_load_area(area_key):
# Kamera-Setup nur beim ersten Laden oder falls sie gelöscht wurde
if not multiplayer.is_server() and local_camera == null:
if not multiplayer.is_server() and not is_instance_valid(local_camera):
print("Setting up camera")
setup_client_camera(viewport_node)
# Jetzt können wir dem Server ok fürs Spawnen geben
@ -225,11 +226,3 @@ func handle_area_change_request(area_key: String):
# 2. Bestehende Funktion für den Wechsel aufrufen
change_player_area(id, area_key)
## The following script is for a Portal node.
## File: portal.gd
extends Area3D
## Definiert, wohin dieses Portal führt.
## Dieser Wert muss einem Schlüssel in NetworkManager.area_paths entsprechen.
@export var target_area_key: String = ""

View file

@ -76,53 +76,53 @@ func request_move(target: Vector3):
path = [target]
target_position = target
# ERSETZT die alte _input(event)-Funktion
func _input(event):
# Ist dieser Avatar nicht der des lokalen Spielers? Wenn ja, sofort abbrechen.
if name.to_int() != multiplayer.get_unique_id(): return
# NUR der Client, der diesen Spieler kontrolliert, darf klicken
#if not is_multiplayer_authority(): return
if DisplayServer.get_name() == "headless": return # Server ignoriert Klicks
if multiplayer.is_server(): return # Kein Input vom Server
if event is InputEventMouseButton and event.pressed:
print("Click")
var target = _get_click_floor_pos()
if target != Vector3.INF:
print("Request move to target {0} by user {1}".format([target, name]))
# RPC an den Server (ID 1)
request_move.rpc_id(1, target)
# Wir reagieren nur auf den Klick der linken Maustaste
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
var click_result = _get_click_result()
if click_result: # Prüfen, ob der Strahl überhaupt etwas getroffen hat
var collider = click_result.collider
# PRIORITÄT 1: Haben wir ein Portal angeklickt?
# Wir prüfen, ob das getroffene Objekt gültig ist und zur Gruppe "portals" gehört.
if is_instance_valid(collider) and collider.is_in_group("portals"):
print("Portal angeklickt. Fordere Area-Wechsel an zu: ", collider.target_area_key)
# Sende RPC direkt an den NetworkManager mit dem Ziel des Portals
NetworkManager.handle_area_change_request.rpc_id(1, collider.target_area_key)
return # Wichtig: Aktion ist beendet, nichts weiter tun.
# PRIORITÄT 2 (Fallback): Wir haben etwas anderes getroffen (vermutlich den Boden), also bewegen.
var target_position = click_result.position
request_move.rpc_id(1, target_position)
func _get_click_floor_pos():
# Wir brauchen die Kamera, die tatsächlich für diesen Viewport zuständig ist
# ERSETZT die alte _get_click_floor_pos()-Funktion
func _get_click_result():
var camera = get_viewport().get_camera_3d()
# DEBUG: Falls keine Kamera gefunden wird, wissen wir woran es liegt
if not camera:
print("Keine Kamera im Viewport von ", name, " gefunden!")
return Vector3.INF
if not camera: return null
var mouse_pos = get_viewport().get_mouse_position()
var space_state = get_world_3d().direct_space_state
var ray_origin = camera.project_ray_origin(mouse_pos)
var ray_dir = camera.project_ray_normal(mouse_pos)
# Da wir in einer isolierten World3D sind, müssen wir den Physics-State
# des Viewports direkt abfragen, statt nur gegen eine Plane zu prüfen:
var space_state = get_world_3d().direct_space_state
var query = PhysicsRayQueryParameters3D.create(ray_origin, ray_origin + ray_dir * 1000)
# Falls du Layer nutzt: query.collision_mask = 1
# WICHTIG: Schließe den eigenen Körper von der Abfrage aus, um nicht auf sich selbst zu klicken.
query.exclude = [self]
# NEU: Sage dem Raycast, dass er auch mit Area3D-Nodes kollidieren soll.
query.collide_with_areas = true
var result = space_state.intersect_ray(query)
if result:
return result.position
# Fallback auf Plane, falls kein Mesh getroffen wurde
var world_plane = Plane(Vector3.UP, 0)
var intersection = world_plane.intersects_ray(ray_origin, ray_dir)
return intersection if intersection else Vector3.INF
return result # Gibt das komplette Ergebnis-Dictionary zurück
func _process(_delta):