extends CharacterBody3D class_name PlaytestBody ## Lauf-Modus Spieler (Karte t_1cdc1a72, Physik-Neubau t_1da96275): ## erste Person, WASD + Maus, Shift = rennen, Space = springen, Gravitation. ## ## ECHTE PHYSIK — identisch zum Quest-3-Client (Sly-Forderung 20.10.: ## „Die Möglichkeiten des Map Editors sollen ABSOLUT identisch mit der ## Quest-3-Version sein“). Dieser Body ist ein CharacterBody3D mit EXAKT ## denselben Parametern wie client/scripts/world/testmap_player.gd ## (TestmapPlayer): Kapsel 0.35 × 1.8, WALK/SPRINT/JUMP/GRAVITY, ## Step-Up 0.45 m nach move_and_slide (RayCast-Kanten-Abtastung, 1:1 ## gespiegelt inkl. Decken-Guard). Die Kollider kommen von KolliderBau ## (Spiegel von svmap_world.gd) — dieselben Trimeshe wie im Spiel. ## ## Der alte AABB-Blocking-Code (vorgecachte Kisten, eigener step()-Solver ## ohne Physik-Server) ist ENTFERNT — es gibt genau EINEN Bewegungscode, ## denselben wie auf der Quest. ## ## Steuerung: _physics_process pollt die Tasten (oder die Autopilot-Haken ## auto_move_dir/auto_jump/auto_run für Suiten und spätere Automatisierung) ## und fährt dieselbe Logik wie der Client. ESC wird NICHT hier gefangen: ## main.gd behandelt Esc VOR der normalen Esc-Kette, solange der Lauf-Modus ## aktiv ist („Esc = zurück zum Editor“). ## ## Koordinaten-Konvention (wie Client): Body-Ursprung = KAPSEL-MITTE, ## Kamera sitzt EYE_HEIGHT über der Mitte (TestmapPlayer.EYE_HEIGHT). signal exit_requested # --- Parameter 1:1 aus client/scripts/world/testmap_player.gd --------------- const WALK_SPEED := 6.0 const SPRINT_SPEED := 14.0 const JUMP_SPEED := 4.5 const GRAVITY := 9.8 const EYE_HEIGHT := 0.65 # Kamera über der Kapsel-Mitte (Kapsel 1.8 m) const KILL_Y_DROP_M := -200.0 # Notfall: unter die Welt gefallen → Respawn const KAPSEL_RADIUS := 0.35 const KAPSEL_HEIGHT := 1.8 const HALF_H := 0.9 # Kapsel-Halbhöhe (Kapsel 1.8 m) — Füße = y − 0.9 const STEP_HEIGHT := 0.45 # = TestmapPlayer.STEP_HEIGHT (Karte t_f9adf97c) const STEP_PROBE_DIST := 0.7 const STEP_LIFT_EPS := 0.02 # --- Editor-seitige Konventionen (unverändert gegenüber dem alten Body) ----- const DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze const TELEPORT_REICHWEITE := 4000.0 ## Spielbare Parameter var mouse_sens: float = DEFAULT_MOUSE_SENS ## Map-Dokument (nur LESEND — Terrain-Raster für Spawn/HUD/Notfall) var doc: Dictionary = {} ## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung ## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Noclip — der Tester ## kommt auch über Wände/Hügel schnell ans Ziel. var fly_mode := false ## Autopilot-Haken (wie TestmapPlayer.auto_move_dir, Karte t_f9adf97c): ## nicht-leer = DIESER Welt-Vektor ist der Bewegungswunsch (Tastatur aus). var auto_move_dir := Vector3.ZERO ## Autopilot-Ergänzung für Suiten: Sprung/Run erzwingen ohne Tastatur. var auto_jump := false var auto_run := false ## Suite-Hook: nicht-leer = komplettes Input-Dictionary überschreibt ALLES ## (gleiche Form wie der alte step()-Input: fwd/back/left/right/run/jump). var auto_input: Dictionary = {} ## Bewegungszustand (Kompatibilität zur alten Oberfläche) var yaw_deg: float = 0.0 # Gierwinkel Blickrichtung (Grad) var pitch_deg: float = 0.0 # Nickwinkel Blickrichtung (Grad) var on_floor: bool = false ## Kinder: die Spiel-Kamera (Augen) — PlaytestMode hängt sie an und macht current var look_cam: Camera3D ## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain() ## gefüllt (Spawn/HUD/jump_to_spawn; die PHYSIK läuft über den Trimesh-Kollider). var heights: PackedFloat32Array = PackedFloat32Array() var res: int = 0 var size_x: float = 0.0 var size_z: float = 0.0 var hmin: float = 0.0 var hmax: float = 0.0 var _spawn_pos := Vector3.ZERO func _ready() -> void: _spawn_pos = global_position var cs := CollisionShape3D.new() var capsule := CapsuleShape3D.new() capsule.radius = KAPSEL_RADIUS capsule.height = KAPSEL_HEIGHT cs.shape = capsule add_child(cs) look_cam = Camera3D.new() look_cam.name = "Camera" look_cam.position.y = EYE_HEIGHT look_cam.far = 4000.0 add_child(look_cam) look_cam.current = true _baue_dritte_kamera() _apply_look() ## Third-Person-Kamera (Karte t_17a35c59): SpringArm3D = RayCast hinter dem ## Spieler — die Kamera wird an die Kollision zurückgezogen (kein Clipping ## durch Wände). Pivot auf Augenhöhe, Arm-Länge 3,5 m + 0,3 m Margin, der ## eigene Body ist ausgeschlossen (sonst trifft der Strahl die eigene Kapsel). func _baue_dritte_kamera() -> void: spring = SpringArm3D.new() spring.name = "ThirdPersonArm" spring.position = Vector3(0.0, EYE_HEIGHT, 0.0) spring.spring_length = 3.5 spring.margin = 0.3 spring.collision_mask = 1 tpp_cam = Camera3D.new() tpp_cam.name = "ThirdPersonCamera" tpp_cam.far = 4000.0 spring.add_child(tpp_cam) add_child(spring) spring.add_excluded_object(get_rid()) tpp_cam.current = false ## Kinder: die Third-Person-Kamera (SpringArm + Kamera) — Karte t_17a35c59. var spring: SpringArm3D = null var tpp_cam: Camera3D = null ## Playtest-Avatar (Spiegel des Bot-Rigs, playtest_avatar.gd). null = kein ## Client-Klon/Modell → Third Person bleibt gesperrt (graceful Fallback). var avatar: Node3D = null ## Perspektive (Taste V, persistiert in PlaytestSettings): false = First ## Person (Standard), true = Third Person (Avatar sichtbar, Kamera hinten). var perspektive_dritte := false ## Blickrichtung auf Körper + Kameras übertragen. ## Der BODY rotiert mit dem Blick-Yaw (wie TestmapPlayer.rotate_y) — das ## trägt die Avatar-Front (Kind des Körpers) und die Third-Person-Kamera. ## Die WASD-Bewegung selbst folgt dem Yaw über _fahren (parallel gefixt in ## 52919b3, dort aus yaw_deg gebaut — beide Wege ergeben dieselbe Richtung). ## Vor beiden Fixes blieb der Body ungedreht (Beweis-Probe ## tests/probe_move_dir.gd: yaw −90° + W → −Z statt +X = weltachsen-fixiert). func _apply_look() -> void: # 1:1 TestmapPlayer: der BODY trägt den Yaw (rotate_y), die Kamera nur # den Pitch — sonst doppelt sich die Drehung (Kamera hängt am Körper). rotation.y = deg_to_rad(yaw_deg) if look_cam == null: return look_cam.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0) look_cam.position = Vector3(0.0, EYE_HEIGHT, 0.0) # Third Person: die SpringArm-Kamera teilt dieselbe Blickachse (Yaw über # den Körper, Pitch über den Arm) — Blickrichtung == Kamerarichtung. if spring != null: spring.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0) ## Füß-Höhe über der Welt (Kapsel-Mitte minus Halbhöhe) — HUD/Tests. func fuss_hoehe() -> float: return global_position.y - HALF_H ## Volle 3D-Blickrichtung (Karte t_aaff5dcc): -Z um Pitch (X) und Yaw (Y) ## gedreht — genau die Blickachse der Kamera. Fliegen bewegt entlang DIESEM ## Vektor (Blick nach unten = abwärts fliegen), Gehen nur horizontal. func view_direction() -> Vector3: var yaw_rad := deg_to_rad(yaw_deg) var pitch_rad := deg_to_rad(pitch_deg) return Vector3(-sin(yaw_rad) * cos(pitch_rad), sin(pitch_rad), -cos(yaw_rad) * cos(pitch_rad)) ## Echter Physics-Step: Input zusammenbauen (Tasten ODER Autopilot) → fahren. func _physics_process(delta: float) -> void: var input := {} if not auto_input.is_empty(): input = auto_input else: input = { "fwd": Input.is_key_pressed(KEY_W), "back": Input.is_key_pressed(KEY_S), "left": Input.is_key_pressed(KEY_A), "right": Input.is_key_pressed(KEY_D), "run": Input.is_key_pressed(KEY_SHIFT) or auto_run, "jump": Input.is_key_pressed(KEY_SPACE) or auto_jump, } if auto_move_dir.length_squared() > 0.0: input["auto_dir"] = auto_move_dir _fahren(delta, input) ## Ein Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen. ## input = {fwd, back, left, right, run, jump: bool, auto_dir: Vector3}. ## DIESE Methode ist 1:1 die _physics_process des TestmapPlayers — ## identische Bewegungsmathe, identische Step-Up-Aufrufreihenfolge. func _fahren(delta: float, input: Dictionary) -> void: # --- Fliegen / God-Mode (Karte t_aaff5dcc, Taste G) ---------------------- # Keine Gravitation, Bewegung in der VOLLEN Blickrichtung, Space = steigen, # Noclip (direkt positionieren — kein move_and_slide, kein Kollider). if fly_mode: var fly_speed := WALK_SPEED if not bool(input.get("run", false)) else SPRINT_SPEED var fly_yaw_rad := deg_to_rad(yaw_deg) var fly_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad)) var wish3: Vector3 var ad: Variant = input.get("auto_dir", null) if ad is Vector3 and (ad as Vector3).length_squared() > 0.0: wish3 = (ad as Vector3).normalized() * fly_speed else: wish3 = view_direction() * (1.0 if bool(input.get("fwd", false)) else 0.0) \ - view_direction() * (1.0 if bool(input.get("back", false)) else 0.0) \ + fly_right * (1.0 if bool(input.get("right", false)) else 0.0) \ - fly_right * (1.0 if bool(input.get("left", false)) else 0.0) if bool(input.get("jump", false)): wish3 += Vector3.UP if wish3.length_squared() > 0.0: wish3 = wish3.normalized() * fly_speed velocity = wish3 position += velocity * delta on_floor = false _apply_look() return # Welt-Richtung aus dem Blickwinkel (y-only, wie Client). WICHTIG: aus # yaw_deg bauen, NICHT aus transform.basis (Sly-Befund 20.10.: WASD lief # sonst stur in eine Himmelsrichtung). Seit t_17a35c59 rotiert der # Körperknoten ZUSÄTZLICH mit dem Yaw (_apply_look — Avatar-Front und # Third-Person-Kamera hängen daran); die Bewegung bleibt bewusst an # yaw_deg gekoppelt — beide Wege führen auf dieselbe Richtung, die # Entkopplung hält die Bewegung unabhängig vom Avatar-Zustand. # Identisch zu view_direction(). var yaw_rad := deg_to_rad(yaw_deg) var forward := Vector3(-sin(yaw_rad), 0.0, -cos(yaw_rad)) var right := Vector3(cos(yaw_rad), 0.0, -sin(yaw_rad)) var dir := Vector3.ZERO var ad2: Variant = input.get("auto_dir", null) if ad2 is Vector3 and (ad2 as Vector3).length_squared() > 0.0: dir = ad2 as Vector3 else: if bool(input.get("fwd", false)): dir += forward if bool(input.get("back", false)): dir -= forward if bool(input.get("right", false)): dir += right if bool(input.get("left", false)): dir -= right var speed := SPRINT_SPEED if bool(input.get("run", false)) else WALK_SPEED if dir.length_squared() > 0.0: dir = dir.normalized() * speed velocity.x = dir.x velocity.z = dir.z if is_on_floor() and bool(input.get("jump", false)): velocity.y = JUMP_SPEED if not is_on_floor(): velocity.y -= GRAVITY * delta var pre_pos := global_position move_and_slide() # Stufen-Hilfe (Karte t_f9adf97c, 1:1 aus TestmapPlayer): nach # move_and_slide() prüfen, ob die horizontale Bewegung von einer Wand # gestoppt wurde, während der Wunsch weiter in die Wand zeigt. var wish_xz := Vector3(dir.x, 0.0, dir.z) var blocked := false if wish_xz.length_squared() > 0.0001: if is_on_wall() and get_wall_normal().dot(wish_xz.normalized()) < -0.1: blocked = true else: var got := global_position - pre_pos got.y = 0.0 var want := wish_xz * delta if got.length() < want.length() * 0.4: blocked = true if blocked: try_step_up(wish_xz) if global_position.y < KILL_Y_DROP_M: # Sicherheitsnetz (sollte mit Trimesh-Terrain nicht passieren). global_position = _spawn_pos velocity = Vector3.ZERO on_floor = is_on_floor() _apply_look() ## ------------------------------------------------------------------ ## Stufen-Hilfe — 1:1 aus client/scripts/world/testmap_player.gd ## (Karte t_f9adf97c): Stufen bis STEP_HEIGHT erklimmen. Nach ## move_and_slide(), wenn die horizontale Bewegung von einer Wand gestoppt ## wurde UND der Bewegungswunsch in die Wand zeigt: ## Ray 1 (Fußhöhe vorwärts) → Wand-/Kanten-Fläche finden, ## Ray 2 (Kopfhöhe vorwärts) → Wand überragt die Stufenhöhe? → blockt, ## Ray 3 (über der Kante abwärts) → neue Standfläche messen, ## Decken-Strahl → Platz für die Kapsel über der Stufe? ## ------------------------------------------------------------------ func _capsule_half_height() -> float: for child in get_children(): var cs := child as CollisionShape3D if cs != null and cs.shape is CapsuleShape3D: return (cs.shape as CapsuleShape3D).height * 0.5 return 0.9 func try_step_up(wish: Vector3) -> bool: var dir := Vector3(wish.x, 0.0, wish.z) if not is_on_floor() or dir.length_squared() < 0.0001: return false dir = dir.normalized() var space := get_world_3d().direct_space_state if space == null: return false var half_h := _capsule_half_height() var feet := global_position.y - half_h var mask := collision_mask # nur die Welt tasten, nicht den eigenen Körper var exclude: Array[RID] = [get_rid()] # Ray 1: vorwärts in Knöchelhöhe — gibt es überhaupt eine Stufe/Wand? var ankle := global_position + Vector3(0.0, -half_h + 0.05, 0.0) var q1 := PhysicsRayQueryParameters3D.create( ankle + dir * 0.1, ankle + dir * STEP_PROBE_DIST, mask, exclude) var hit1 := space.intersect_ray(q1) if hit1.is_empty(): return false if (hit1["normal"] as Vector3).dot(dir) > -0.3: return false # Fläche zeigt nicht gegen die Laufrichtung (Rampe/Kante) # Ray 2: vorwärts knapp ÜBER der maximalen Stufenhöhe — trifft er die # Wand noch, ist sie höher als STEP_HEIGHT → kein Anheben. var top_probe := global_position + Vector3(0.0, -half_h + STEP_HEIGHT + 0.01, 0.0) var q2 := PhysicsRayQueryParameters3D.create( top_probe + dir * 0.1, top_probe + dir * STEP_PROBE_DIST, mask, exclude) if not space.intersect_ray(q2).is_empty(): return false # Wand überragt die Stufenhöhe # Ray 3: über der Kante abwärts — neue Standfläche messen (Startpunkt # liegt über Ray 2 frei, also außerhalb der Wand). var edge := (hit1["position"] as Vector3) + dir * 0.1 var q3 := PhysicsRayQueryParameters3D.create( Vector3(edge.x, feet + STEP_HEIGHT + 0.05, edge.z), Vector3(edge.x, feet - 0.05, edge.z), mask, exclude) var hit3 := space.intersect_ray(q3) if hit3.is_empty(): return false var new_feet := (hit3["position"] as Vector3).y if new_feet < feet - 0.01: # Abgrund statt Fläche? Nichts anheben. return false var diff := new_feet - feet if diff > STEP_HEIGHT + 0.005 or diff <= 0.0: return false if (hit3["normal"] as Vector3).y < 0.5: return false # keine begehbare Fläche (Decke/Schräge von unten) # Decken-Check: Platz für die volle Kapselhöhe über der neuen Standfläche? # An der Ziel-XZ prüfen (etwas vorwärts — dort landt die Kapsel nach dem # Schritt), sonst entgeht die Terrassen-Decke über dem Stufenkopf. var check := global_position + dir * 0.45 var q4 := PhysicsRayQueryParameters3D.create( Vector3(check.x, new_feet + 0.05, check.z), Vector3(check.x, new_feet + half_h * 2.0 + 0.05, check.z), mask, exclude) if not space.intersect_ray(q4).is_empty(): return false # Decke zu tief (z. B. Terrassen-Untersicht): unten bleiben # Sanftes Anheben: Positions-Snap auf die neue Standfläche … global_position.y = new_feet + half_h + STEP_LIFT_EPS # … und Bewegung fortführen: der zweite move_and_slide()-Schritt trägt # die Kapsel VOR die Kante auf die neue Stufe (sonst steht sie nur # tangential auf der Kante und fällt zurück). move_and_slide() return true ## Maus-Bewegung (nur der Spieler selbst fängt sie — die Editor-Werkzeuge ## sind im Lauf-Modus eingefroren). func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion: var mm := event as InputEventMouseMotion yaw_deg = wrapf(yaw_deg - mm.relative.x * mouse_sens, -180.0, 180.0) pitch_deg = clampf(pitch_deg - mm.relative.y * mouse_sens, -PITCH_LIMIT, PITCH_LIMIT) _apply_look() get_viewport().set_input_as_handled() ## ESC-Request — wird von main.gd angerufen (die Esc-Priorität gehört dort ## hin, damit die normale Esc-Kette NICHT auch noch zieht). func request_exit() -> void: exit_requested.emit() ## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 → ## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe ## Code wie beim Editor-Rendering) — nichts neu rechnen. Die PHYSIK läuft ## über den Trimesh-Kollider (KolliderBau.terrain_form, identisch zum ## Client); dieses Raster dient Spawn/jump_to_spawn/HUD-Referenz. func set_terrain(d: Dictionary) -> void: doc = d heights = PackedFloat32Array() res = 0 size_x = 0.0 size_z = 0.0 hmin = 0.0 hmax = 0.0 if d.is_empty(): return var terrain: Dictionary = d.get("terrain", {}) res = int(terrain.get("resolution", 0)) size_x = float(terrain.get("size_x_m", 0.0)) size_z = float(terrain.get("size_z_m", 0.0)) hmin = float(terrain.get("min_height_m", 0.0)) hmax = float(terrain.get("max_height_m", 0.0)) if res < 2 or size_x <= 0.0 or size_z <= 0.0: return var decoded: PackedFloat32Array = SvmapIO.decode_heights(terrain) if decoded.size() < res * res: return heights = decoded ## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im ## Testlauf): Strahl aus dem Auge gegen die ECHTE Physik-Welt (Trimesh- ## Terrain + Objekt-/Bauteil-Kollider — dieselben Flächen wie im Spiel). ## Gelandet wird auf dem Boden DARUNTER (Strahl senkrecht abwärts). Nie im ## Objekt landen: ist die Kapsel am Zielpunkt nicht frei, wird der Teleport ## abgelehnt (ok=false). Rückgabe: {ok, x, z, ground, msg}. func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary: var space := get_world_3d().direct_space_state if space == null: return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."} var d := dir if d.length_squared() > 0.000000001: d = d.normalized() var q := PhysicsRayQueryParameters3D.create(origin, origin + d * TELEPORT_REICHWEITE) q.exclude = [get_rid()] var hit := space.intersect_ray(q) if hit.is_empty(): return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."} var ziel := hit["position"] as Vector3 # Boden DARUNTER: senkrechter Strahl ab etwas über dem Treffer — trifft # er nichts (Treffer war eine Wandfläche ohne Boden drunter), Terrain- # Raster als Rückfallebene. var ground := ziel.y var q2 := PhysicsRayQueryParameters3D.create( Vector3(ziel.x, ziel.y + 0.5, ziel.z), Vector3(ziel.x, ziel.y - 50.0, ziel.z)) q2.exclude = [get_rid()] var hit2 := space.intersect_ray(q2) if hit2.is_empty(): ground = terrain_height(ziel.x, ziel.z) else: ground = (hit2["position"] as Vector3).y var tx := ziel.x var tz := ziel.z # Landeplatz prüfen: passt die Kapsel dort frei? (Nie im Objekt landen.) var probe := CapsuleShape3D.new() probe.radius = KAPSEL_RADIUS probe.height = KAPSEL_HEIGHT var qp := PhysicsShapeQueryParameters3D.new() qp.shape = probe qp.transform = Transform3D(Basis.IDENTITY, Vector3(tx, ground + HALF_H + 0.02, tz)) qp.exclude = [get_rid()] if not space.intersect_shape(qp, 1).is_empty(): return {"ok": false, "x": tx, "z": tz, "ground": ground, "msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]} position = Vector3(tx, ground + HALF_H + 0.02, tz) velocity = Vector3.ZERO on_floor = true _apply_look() return {"ok": true, "x": tx, "z": tz, "ground": ground, "msg": ""} ## Terrain-Höhe an Welt-X/Z — aus dem DOCUMENT-Raster (nichts neu rechnen). ## Referenz für Spawn/jump_to_spawn/HUD; die Physik nutzt den Trimesh. func terrain_height(x: float, z: float) -> float: if res < 2 or size_x <= 0.0 or size_z <= 0.0: return hmin if heights.size() < res * res: return hmin var last := float(res - 1) var fx := clampf((x / size_x + 0.5) * last, 0.0, last) var fz := clampf((z / size_z + 0.5) * last, 0.0, last) var xi := int(floor(fx)) var zi := int(floor(fz)) var tx := fx - float(xi) var tz := fz - float(zi) var xi1 := mini(xi + 1, res - 1) var zi1 := mini(zi + 1, res - 1) var h00 := _sample(xi, zi) var h10 := _sample(xi1, zi) var h01 := _sample(xi, zi1) var h11 := _sample(xi1, zi1) return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) func _sample(xi: int, zi: int) -> float: var idx := zi * res + xi if idx < 0 or idx >= heights.size(): return hmin return heights[idx] ## Maus-Empfindlichkeit als Multiplikator setzen (Karte t_aaff5dcc, 0,1–5,0, ## Standard 1,0): die Body-Basis (DEFAULT_MOUSE_SENS Grad/Pixel) wird mit dem ## Multiplikator skaliert — jede PC/Maus-Kombination ist anders. func set_sensitivity(mult: float) -> void: var m := clampf(mult, PlaytestSettings.MIN_SENS, PlaytestSettings.MAX_SENS) mouse_sens = DEFAULT_MOUSE_SENS * m # ------------------------------------------ Avatar + Perspektive (V) ------- # Karte t_17a35c59: derselbe Avatar wie die Bots im Spiel (Spiegel-Modul # playtest_avatar.gd, GLB aus dem Nachbar-Klon), umschaltbar First/Third. ## Avatar (aus PlaytestAvatar.lade_avatar) anhängen. Anker: Kopf-Anker- ## Konvention des Clients (bot_avatar.gd) — Avatar-Node in Augenhöhe ## (MODEL_EYE_HEIGHT 1,65 m) über dem Füßpunkt, GLB-Kind hängt mit ## MODEL_Y_OFFSET nach unten. null = kein Modell (Third bleibt gesperrt). func set_avatar(node: Node3D) -> void: if avatar != null and is_instance_valid(avatar): avatar.queue_free() avatar = node if avatar == null: perspektive_dritte = false _apply_perspektive() return # add_child reparentet NICHT — ein Node mit altem Parent würde still # abgelehnt (Probe 20.10.: Avatar blieb unter root). Erst lösen, dann # als Kind anhängen. if avatar.get_parent() != null: avatar.get_parent().remove_child(avatar) add_child(avatar) avatar.position = Vector3(0.0, PlaytestAvatar.MODEL_EYE_HEIGHT - HALF_H, 0.0) # Start-Zustand sauber in den Baum schreiben (apply_state vor add_child # könnte Tree-Parameter noch nicht flushen). avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0) _apply_perspektive() ## Taste V: Perspektive umschalten. Rückgabe: neuer Zustand (true = Third). ## Ohne Avatar bleibt First Person aktiv (graceful Fallback — Meldung macht ## der Aufrufer/PlaytestMode). func toggle_perspektive() -> bool: var neu := not perspektive_dritte if avatar == null: neu = false return set_perspektive(neu) ## Perspektive direkt setzen (Enter-Pfad liest PlaytestSettings). Rückgabe: ## der tatsächlich aktive Zustand (ohne Avatar nie Third). func set_perspektive(dritte: bool) -> bool: if avatar == null: dritte = false perspektive_dritte = dritte _apply_perspektive() return perspektive_dritte ## Sichtbarkeit + aktive Kamera je Perspektive: ## First Person — Avatar unsichtbar (Kapsel bleibt physisch aktiv, ## Kamera auf Augenhöhe wie Client-TestmapPlayer), ## Third Person — Avatar sichtbar, SpringArm-Kamera hinter/über dem ## Spieler mit Wand-Kollision (kein Clipping). func _apply_perspektive() -> void: if avatar != null and is_instance_valid(avatar): avatar.visible = perspektive_dritte if look_cam != null: look_cam.current = not perspektive_dritte if tpp_cam != null: tpp_cam.current = perspektive_dritte ## Echte horizontale Geschwindigkeit (m/s) aus dem letzten Physik-Schritt — ## Quelle für die Avatar-Gait-Speisung (Stillstand/Walk/Jog aus der realen ## Bewegung, nicht aus dem Input-Wunsch). func horizontale_geschwindigkeit() -> float: var v := velocity v.y = 0.0 return v.length()