scopedvr-map-core/editor/playtest/playtest_body.gd
2026-11-01 19:31:33 +01:00

581 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()