581 lines
23 KiB
GDScript
581 lines
23 KiB
GDScript
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()
|