Playtest-Physik = Quest 3 identisch (t_1da96275): CharacterBody3D + echte Kollider statt AABB-Blocking — KolliderBau (1:1-Spiegel svmap_world), Testmap-Player-Parameter (Kapsel 0.35x1.8, 6/14 m/s, Jump 4.5, Step-Up 0.45), Bauteil-Trimeshe (Treppe/Tür), Identitäts-Vertrag Suite (Editor vs. Client, 8 Entscheidungen identisch), FPS-Probe (Physik-Overhead 7%)

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 21:01:37 +00:00
parent fabbbcce20
commit 0cdf68622c
24 changed files with 1591 additions and 535 deletions

View file

@ -1,59 +1,80 @@
extends Node3D extends CharacterBody3D
class_name PlaytestBody class_name PlaytestBody
## Lauf-Modus Spieler (Karte t_1cdc1a72): erste Person, WASD + Maus, ## Lauf-Modus Spieler (Karte t_1cdc1a72, Physik-Neubau t_1da96275):
## Shift = rennen, Space = springen, Gravitation. ## erste Person, WASD + Maus, Shift = rennen, Space = springen, Gravitation.
## ##
## Kollision OHNE Physik-Welt (SubViewport ohne Physik-Tick): ## ECHTE PHYSIK — identisch zum Quest-3-Client (Sly-Forderung 20.10.:
## - Boden: Terrain-Raster des DOKUMENTS abtasten (SvmapDocument. ## „Die Möglichkeiten des Map Editors sollen ABSOLUT identisch mit der
## terrain_height_at) — nichts neu berechnen. ## Quest-3-Version sein“). Dieser Body ist ein CharacterBody3D mit EXAKT
## - Objekte/Bau-Teile: vorgecachte Welt-AABBs (playtest_mode.gd sammelt ## denselben Parametern wie client/scripts/world/testmap_player.gd
## sie beim Start aus den Marker-Metas + BuildOps.piece_aabb) als ## (TestmapPlayer): Kapsel 0.35 × 1.8, WALK/SPRINT/JUMP/GRAVITY,
## einfache Blöcke. Blöcke mit Oberkante ≤ Schritt-Höhe sind begehbar ## Step-Up 0.45 m nach move_and_slide (RayCast-Kanten-Abtastung, 1:1
## (Dach wird Boden), alles was darüber ragt blockt horizontal. ## gespiegelt inkl. Decken-Guard). Die Kollider kommen von KolliderBau
## (Spiegel von svmap_world.gd) — dieselben Trimeshe wie im Spiel.
## ##
## Steuerung bewusst als reine step()-Methode: die echte _physics_process ## Der alte AABB-Blocking-Code (vorgecachte Kisten, eigener step()-Solver
## pollt nur die Tasten und ruft step() mit einem Input-Dictionary — ## ohne Physik-Server) ist ENTFERNT — es gibt genau EINEN Bewegungscode,
## die Suite (tests/test_playtest.gd) ruft step() mit simulierten Inputs ## denselben wie auf der Quest.
## und testet Mathe/Kollision OHNE echten Input.
## ##
## ESC wird NICHT hier gefangen: main.gd behandelt Esc VOR der normalen ## Steuerung: _physics_process pollt die Tasten (oder die Autopilot-Haken
## Esc-Kette, solange der Lauf-Modus aktiv ist („Esc = zurück zum Editor“). ## 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 signal exit_requested
const DEFAULT_MOVE_SPEED := 4.0 # m/s Gehen # --- Parameter 1:1 aus client/scripts/world/testmap_player.gd ---------------
const DEFAULT_RUN_MULT := 2.0 # Shift = rennen const WALK_SPEED := 6.0
const DEFAULT_JUMP_SPEED := 4.5 # m/s Sprungimpuls const SPRINT_SPEED := 14.0
const DEFAULT_GRAVITY := 12.0 # m/s² — Spiele-Wert, kein Physik-Server const JUMP_SPEED := 4.5
const DEFAULT_EYE_HEIGHT := 1.7 # Kamera-Augenhöhe über dem Fußpunkt const GRAVITY := 9.8
const DEFAULT_STEP_HEIGHT := 0.45 # Stufen/Trittkanten, die begehbar sind const EYE_HEIGHT := 0.65 # Kamera über der Kapsel-Mitte (Kapsel 1.8 m)
const DEFAULT_RADIUS := 0.35 # Spieler-Kolisionsradius (AABB-Aufblas) const KILL_Y_DROP_M := -200.0 # Notfall: unter die Welt gefallen → Respawn
const DEFAULT_BODY_HEIGHT := 1.85 # Kopf-Raum (Block-ab-Prüfung) 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 DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung
const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze
const TELEPORT_REICHWEITE := 4000.0
## Spielbare Parameter (PlaytestMode setzt die Defaults; Suite kann nachsetzen) ## Spielbare Parameter
var move_speed: float = DEFAULT_MOVE_SPEED
var run_mult: float = DEFAULT_RUN_MULT
var jump_speed: float = DEFAULT_JUMP_SPEED
var gravity: float = DEFAULT_GRAVITY
var eye_height: float = DEFAULT_EYE_HEIGHT
var step_height: float = DEFAULT_STEP_HEIGHT
var radius: float = DEFAULT_RADIUS
var body_height: float = DEFAULT_BODY_HEIGHT
var mouse_sens: float = DEFAULT_MOUSE_SENS var mouse_sens: float = DEFAULT_MOUSE_SENS
## Map-Dokument (nur LESEND — Terrain-Höhenabfrage) ## Map-Dokument (nur LESEND — Terrain-Raster für Spawn/HUD/Notfall)
var doc: Dictionary = {} var doc: Dictionary = {}
## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung ## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung
## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Kollision aus ## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Noclip — der Tester
## (Noclip — der Tester kommt auch über Wände/Hügel schnell ans Ziel). ## kommt auch über Wände/Hügel schnell ans Ziel.
var fly_mode := false var fly_mode := false
## Vorgecachte Hindernis-AABBs (Weltkoordinaten) — von PlaytestMode gefüllt ## Autopilot-Haken (wie TestmapPlayer.auto_move_dir, Karte t_f9adf97c):
var obstacles: Array = [] # Array[AABB] ## 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() ## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain()
## gefüllt, damit die Boden-Abfrage NICHT jeden Schritt das Base64 dekodiert. ## gefüllt (Spawn/HUD/jump_to_spawn; die PHYSIK läuft über den Trimesh-Kollider).
var heights: PackedFloat32Array = PackedFloat32Array() var heights: PackedFloat32Array = PackedFloat32Array()
var res: int = 0 var res: int = 0
var size_x: float = 0.0 var size_x: float = 0.0
@ -61,22 +82,23 @@ var size_z: float = 0.0
var hmin: float = 0.0 var hmin: float = 0.0
var hmax: float = 0.0 var hmax: float = 0.0
## Bewegungszustand var _spawn_pos := Vector3.ZERO
var yaw_deg: float = 0.0 # Gierwinkel Blickrichtung (Grad)
var pitch_deg: float = 0.0 # Nickwinkel Blickrichtung (Grad)
var velocity: Vector3 = Vector3.ZERO # m/s
var on_floor: bool = false
## Kinder: die Spiel-Kamera (Augen) — PlaytestMode hängt sie an und macht current
var look_cam: Camera3D
func _ready() -> void: 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 = Camera3D.new()
look_cam.near = 0.1 look_cam.name = "Camera"
look_cam.far = 6000.0 look_cam.position.y = EYE_HEIGHT
look_cam.current = true look_cam.far = 4000.0
add_child(look_cam) add_child(look_cam)
look_cam.current = true
_apply_look() _apply_look()
@ -85,7 +107,12 @@ func _apply_look() -> void:
if look_cam == null: if look_cam == null:
return return
look_cam.rotation = Vector3(deg_to_rad(pitch_deg), deg_to_rad(yaw_deg), 0.0) look_cam.rotation = Vector3(deg_to_rad(pitch_deg), deg_to_rad(yaw_deg), 0.0)
look_cam.position = Vector3(0.0, eye_height, 0.0) look_cam.position = Vector3(0.0, EYE_HEIGHT, 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) ## Volle 3D-Blickrichtung (Karte t_aaff5dcc): -Z um Pitch (X) und Yaw (Y)
@ -98,110 +125,192 @@ func view_direction() -> Vector3:
-cos(yaw_rad) * cos(pitch_rad)) -cos(yaw_rad) * cos(pitch_rad))
## Eine Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen. ## Echter Physics-Step: Input zusammenbauen (Tasten ODER Autopilot) → fahren.
## input = {fwd: bool, back: bool, left: bool, right: bool, run: bool, func _physics_process(delta: float) -> void:
## jump: bool} — die echte _physics_process baut das aus den var input := {}
## Tasten, die Suite aus simulierten Flags. delta in Sekunden. if not auto_input.is_empty():
## Rückgabe: {pos, vel, on_floor} nach dem Schritt. input = auto_input
func step(delta: float, input: Dictionary) -> Dictionary: else:
var fwd: bool = bool(input.get("fwd", false)) input = {
var back: bool = bool(input.get("back", false)) "fwd": Input.is_key_pressed(KEY_W),
var left: bool = bool(input.get("left", false)) "back": Input.is_key_pressed(KEY_S),
var right: bool = bool(input.get("right", false)) "left": Input.is_key_pressed(KEY_A),
var run: bool = bool(input.get("run", false)) "right": Input.is_key_pressed(KEY_D),
var jump: bool = bool(input.get("jump", false)) "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)
var speed := move_speed * (run_mult if run else 1.0)
var move := Vector2.ZERO
if fwd:
move.y += 1.0
if back:
move.y -= 1.0
if right:
move.x += 1.0
if left:
move.x -= 1.0
if move.length_squared() > 0.0:
move = move.normalized()
# --- Fliegen / God-Mode (Karte t_aaff5dcc, Taste G) ----------------------- ## Ein Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen.
# Keine Gravitation, Bewegung in der VOLLEN Blickrichtung (Blick nach ## input = {fwd, back, left, right, run, jump: bool, auto_dir: Vector3}.
# unten = abwärts fliegen), Space = zusätzlich steigen, Kollision AUS ## DIESE Methode ist 1:1 die _physics_process des TestmapPlayers —
# (Noclip — schnell über die Map, auch über Wände/Hügel hinweg). ## 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: if fly_mode:
var fly_speed := move_speed * (run_mult if run else 1.0) 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_yaw_rad := deg_to_rad(yaw_deg)
var fly_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad)) var fly_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad))
var wish3 := view_direction() * move.y + fly_right * move.x var wish3: Vector3
if jump: 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 wish3 += Vector3.UP
if wish3.length_squared() > 0.0: if wish3.length_squared() > 0.0:
wish3 = wish3.normalized() wish3 = wish3.normalized() * fly_speed
velocity = wish3 * fly_speed velocity = wish3
position += velocity * delta position += velocity * delta
on_floor = false on_floor = false
_apply_look() _apply_look()
return {"pos": position, "vel": velocity, "on_floor": on_floor} return
# Welt-Richtung aus dem Blickwinkel (y-only, keine Sprung-Schräglage) # Welt-Richtung aus dem Blickwinkel (y-only, wie Client).
var yaw_rad := deg_to_rad(yaw_deg) var forward := -transform.basis.z
var forward := Vector3(-sin(yaw_rad), 0.0, -cos(yaw_rad)) var right := transform.basis.x
var right_v := Vector3(cos(yaw_rad), 0.0, -sin(yaw_rad)) var dir := Vector3.ZERO
var wish := (forward * move.y + right_v * move.x) * speed var ad2: Variant = input.get("auto_dir", null)
if ad2 is Vector3 and (ad2 as Vector3).length_squared() > 0.0:
# Horizontal ist snappy (kein Anlauf-Trägheit-Momentum): direkt setzen. dir = ad2 as Vector3
velocity.x = wish.x
velocity.z = wish.z
# Vertikal: Gravitation + Sprung (nur vom Boden).
if jump and on_floor:
velocity.y = jump_speed
on_floor = false
elif on_floor:
velocity.y = 0.0
else: else:
velocity.y = velocity.y - gravity * delta if bool(input.get("fwd", false)):
dir += forward
var was_floor := on_floor if bool(input.get("back", false)):
dir -= forward
# --- Horizontal X/Z SEPARAT auflösen (Wand-Sliding) --- if bool(input.get("right", false)):
var next := position dir += right
var nx := next.x + velocity.x * delta if bool(input.get("left", false)):
if not _blocked_at(nx, next.z, next.y): dir -= right
next.x = nx 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: else:
velocity.x = 0.0 var got := global_position - pre_pos
var nz := next.z + velocity.z * delta got.y = 0.0
if not _blocked_at(next.x, nz, next.y): var want := wish_xz * delta
next.z = nz if got.length() < want.length() * 0.4:
else: blocked = true
velocity.z = 0.0 if blocked:
try_step_up(wish_xz)
# --- Vertikal: Boden finden (Terrain + begehbare Block-Decken) --- if global_position.y < KILL_Y_DROP_M:
var ground := _ground_height_at(next.x, next.z, next.y) # Sicherheitsnetz (sollte mit Trimesh-Terrain nicht passieren).
var ny := next.y + velocity.y * delta global_position = _spawn_pos
if velocity.y <= 0.0: velocity = Vector3.ZERO
# Fallen oder am Boden: Bodenhöhe greifen (auch beim Bergab-Gehen, on_floor = is_on_floor()
# wenn der Boden unter der Füßen „wegrutscht“ — snap-down nur bis
# step_height pro Schritt, sonst fällt man Abhänge frei).
if ny <= ground:
ny = ground
velocity.y = 0.0
on_floor = true
elif was_floor and ny - ground <= step_height:
ny = ground # Bergab-Übergang (kleine Kante mitnehmen)
velocity.y = 0.0
on_floor = true
else:
on_floor = false
else:
# Sprung-Phase: kein Boden-Greifen; auf_floaten lassen
on_floor = false
next.y = ny
position = next
_apply_look() _apply_look()
return {"pos": position, "vel": velocity, "on_floor": on_floor}
## ------------------------------------------------------------------
## 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 ## Maus-Bewegung (nur der Spieler selbst fängt sie — die Editor-Werkzeuge
@ -224,7 +333,9 @@ func request_exit() -> void:
## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 → ## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 →
## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe ## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe
## Code wie beim Editor-Rendering) — nichts neu rechnen. ## 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: func set_terrain(d: Dictionary) -> void:
doc = d doc = d
heights = PackedFloat32Array() heights = PackedFloat32Array()
@ -249,116 +360,55 @@ func set_terrain(d: Dictionary) -> void:
heights = decoded heights = decoded
## Realer physics step: Tasten poll → step() aufrufen.
func _physics_process(_delta: float) -> void:
var 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),
"jump": Input.is_key_pressed(KEY_SPACE),
}
step(_delta, input)
# ------------------------------------------------------------ Kollision -------
## Ist die Position (x, z) mit Füßen bei feet_y von einem BLOCKIERNDEM
## Hindernis belegt? Blockierend = Oberkante über der Trittkante
## (feet_y + step_height) UND Unterkante unter der Kopf-Höhe.
## Aufblas um den Spieler-Radius: die AABB wächst um `radius` in x/z.
func _blocked_at(x: float, z: float, feet_y: float) -> bool:
var head := feet_y + body_height
for ab in obstacles:
var box: AABB = ab
if box.end.y <= feet_y + step_height:
continue # begehbar (Trittkante)
if box.position.y >= head:
continue # oberhalb des Kopfes — drunter durchlaufen
if x < box.position.x - radius or x > box.end.x + radius:
continue
if z < box.position.z - radius or z > box.end.z + radius:
continue
return true
return false
## Boden-Höhe an (x, z): Terrain-Raster + alle begehbaren Block-Decken,
## deren Oberkante zwischen Terrain und feet_y + step_height liegt.
func _ground_height_at(x: float, z: float, feet_y: float) -> float:
var g := terrain_height(x, z)
var limit := feet_y + step_height
for ab in obstacles:
var box: AABB = ab
if box.end.y > limit or box.end.y <= g:
continue
if x < box.position.x - radius or x > box.end.x + radius:
continue
if z < box.position.z - radius or z > box.end.z + radius:
continue
g = box.end.y
return g
## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im ## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im
## Testlauf): Strahl aus dem Auge → erster Treffer auf Terrain ODER Hindernis- ## Testlauf): Strahl aus dem Auge gegen die ECHTE Physik-Welt (Trimesh-
## AABB; gelandet wird auf dem Boden DARUNTER (Terrain + begehbare Block-Decken ## Terrain + Objekt-/Bauteil-Kollider — dieselben Flächen wie im Spiel).
## bis zum Ziel-Höhenpunkt). Nie im Objekt landen: ist der Zielpunkt von einem ## Gelandet wird auf dem Boden DARUNTER (Strahl senkrecht abwärts). Nie im
## BLOCKIERENDEN Hindernis belegt, wird der Teleport abgelehnt (ok=false). ## Objekt landen: ist die Kapsel am Zielpunkt nicht frei, wird der Teleport
## Rückgabe: {ok, x, z, ground, msg} — msg im Klartext für HUD/Statuszeile. ## abgelehnt (ok=false). Rückgabe: {ok, x, z, ground, msg}.
func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary: func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary:
# Terrain-Treffer (derselbe Decoder wie im Editor-Werkzeug). var space := get_world_3d().direct_space_state
var t_hit: Variant = TerrainField.raycast(heights, res, size_x, size_z, origin, dir) if space == null:
var t_dist := INF return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
if t_hit != null: "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
t_dist = origin.distance_to(t_hit as Vector3)
# Hindernis-Treffer: nähester AABB-Einstiegspunkt gewinnt gegen Terrain.
var o_dist := INF
var o_point := Vector3.ZERO
var d := dir var d := dir
if d.length_squared() > 0.000000001: if d.length_squared() > 0.000000001:
d = d.normalized() d = d.normalized()
for ab in obstacles: var q := PhysicsRayQueryParameters3D.create(origin, origin + d * TELEPORT_REICHWEITE)
var box: AABB = ab q.exclude = [get_rid()]
var t := TestStart.ray_aabb(origin, d, box) var hit := space.intersect_ray(q)
if t >= 0.0 and t < o_dist: if hit.is_empty():
o_dist = t
o_point = origin + d * t
# Ziel = der nähere der beiden Treffer.
var tx := 0.0
var tz := 0.0
var hit_y := 0.0
var found := false
if o_dist < t_dist:
tx = o_point.x
tz = o_point.z
hit_y = o_point.y
found = true
elif t_hit != null:
var th := t_hit as Vector3
tx = th.x
tz = th.z
hit_y = th.y
found = true
if not found:
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
"msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."} "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
# Boden unter dem Zielpunkt: Terrain + begehbare Block-Decken bis zum var ziel := hit["position"] as Vector3
# Ziel-Höhenpunkt (Ziel auf einem Kisten-Dach = dort oben landen). # Boden DARUNTER: senkrechter Strahl ab etwas über dem Treffer — trifft
var ground := terrain_height(tx, tz) # er nichts (Treffer war eine Wandfläche ohne Boden drunter), Terrain-
var ceiling := maxf(hit_y, ground) + step_height # Raster als Rückfallebene.
for ab in obstacles: var ground := ziel.y
var box: AABB = ab var q2 := PhysicsRayQueryParameters3D.create(
if box.end.y > ceiling or box.end.y <= ground: Vector3(ziel.x, ziel.y + 0.5, ziel.z),
continue Vector3(ziel.x, ziel.y - 50.0, ziel.z))
if tx < box.position.x or tx > box.end.x or tz < box.position.z or tz > box.end.z: q2.exclude = [get_rid()]
continue var hit2 := space.intersect_ray(q2)
ground = box.end.y if hit2.is_empty():
if _blocked_at(tx, tz, ground): 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, return {"ok": false, "x": tx, "z": tz, "ground": ground,
"msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]} "msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]}
position = Vector3(tx, ground, tz) position = Vector3(tx, ground + HALF_H + 0.02, tz)
velocity = Vector3.ZERO velocity = Vector3.ZERO
on_floor = true on_floor = true
_apply_look() _apply_look()
@ -366,6 +416,7 @@ func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary:
## Terrain-Höhe an Welt-X/Z — aus dem DOCUMENT-Raster (nichts neu rechnen). ## 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: func terrain_height(x: float, z: float) -> float:
if res < 2 or size_x <= 0.0 or size_z <= 0.0: if res < 2 or size_x <= 0.0 or size_z <= 0.0:
return hmin return hmin

View file

@ -6,9 +6,14 @@ extends Node
## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen) ## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen)
## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input ## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input
## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden. ## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden.
## - PlaytestBody (src/editor/playtest/playtest_body.gd) anlegen, Terrain- ## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel
## Raster EINMAL dekodieren, Start = erster Spawnpunkt (Team Blau), ## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider
## sonst Kartenmitte. ## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen)
## + Bauteil-Trimeshe (BuildOps-Pieces: Treppen/Türen/Wände begehbar
## wie echte Geometrie).
## - PlaytestBody (CharacterBody3D, IDENTISCHE Parameter wie der
## Testmap-Spieler inkl. Step-Up 0.45 m) anlegen, Terrain-Raster EINMAL
## dekodieren, Start = erster Spawnpunkt (Team Blau), sonst Kartenmitte.
## - ESC → exit_requested → main.gd stellt den Editor-Zustand 1:1 wieder her ## - ESC → exit_requested → main.gd stellt den Editor-Zustand 1:1 wieder her
## (Panels, Maus, Kamera, Auswahl, Dokument, Undo-Stapel unverändert). ## (Panels, Maus, Kamera, Auswahl, Dokument, Undo-Stapel unverändert).
## - HUD (Position, FPS, „ESC = zurück zum Editor") als CanvasLayer-Overlay. ## - HUD (Position, FPS, „ESC = zurück zum Editor") als CanvasLayer-Overlay.
@ -18,9 +23,13 @@ extends Node
const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd" const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd"
const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd" const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd"
const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd"
## Der laufende Spieler (PlaytestBody, step-basiert) ## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik)
var body: Node3D = null var body: Node3D = null
## Welt-Kollider (Terrain + Objekte + Bauteile) — Kind dieses Modus,
## wird mit exit() abgeräumt.
var _kollider_root: Node3D = null
## Editor-UI-Verweise (vom main.gd-Handler gesetzt): die Panels, die ## Editor-UI-Verweise (vom main.gd-Handler gesetzt): die Panels, die
## AUSGEBLENDET werden sollen (Variant statt Control-Typ — keine UI-Kopie). ## AUSGEBLENDET werden sollen (Variant statt Control-Typ — keine UI-Kopie).
@ -66,12 +75,19 @@ func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null
body = load(BODY_SCRIPT).new() body = load(BODY_SCRIPT).new()
body.call("set_terrain", doc) body.call("set_terrain", doc)
body.obstacles = _collect_obstacles(doc, view_builder)
var start := _spawn_start(doc) var start := _spawn_start(doc)
if start_override != null: if start_override != null:
var sp := start_override as Vector3 var sp := start_override as Vector3
start = Vector3(sp.x, 0.0, sp.z) start = Vector3(sp.x, 0.0, sp.z)
body.position = Vector3(start.x, body.terrain_height(start.x, start.z), start.z) # Spawn AUF der Terrainhöhe: Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz,
# die Physik lässt den Body sanft auf den Trimesh fallen — wie im Spiel).
body.position = Vector3(start.x,
body.terrain_height(start.x, start.z) + PlaytestBody.HALF_H + 0.05,
start.z)
# Kollider 1:1 vom Quest-Client (Karte t_1da96275): Terrain-Trimesh +
# Objekt-Kollider aus der Registry + Bauteil-Trimeshe. Alles als Kind
# dieses Modus — exit() räumt es mit ab.
_baue_kollider(doc)
add_child(body) add_child(body)
# Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der # Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der
# gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis. # gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis.
@ -93,6 +109,9 @@ func exit() -> void:
if body != null: if body != null:
body.queue_free() body.queue_free()
body = null body = null
if _kollider_root != null:
_kollider_root.queue_free()
_kollider_root = null
if hud != null: if hud != null:
hud.queue_free() hud.queue_free()
hud = null hud = null
@ -144,83 +163,36 @@ func _spawn_start(doc: Dictionary) -> Vector3:
(float(mn[2]) + float(mx[2])) * 0.5) (float(mn[2]) + float(mx[2])) * 0.5)
## Hindernis-AABBs sammeln (nur LESEND, keine neue Physik-Welt): ## Echter Kollider-Bau (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
## - Objekt-Marker: der ECHTE Fußabdruck aus der Registry (collision.size × ## identisch"): der alte AABB-Blocking-Code ist ENTFERNT. Es gibt genau EINEN
## scale, um rot_y_deg konservativ gedreht — Sly-Befund 20.10.: die alte ## Kollider-Pfad, denselben wie der Quest-Client:
## pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne = ## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel
## „unsichtbare Wand“ weit vor dem Modell). ## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl.
## - Bau-Teile: exakt über BuildOps.piece_aabb (Dokument-Teile → AABB). ## library/kollisions-Quelldateien).
## - Fallback: Objekte ohne Registry-Eintrag behalten die alte Kugel-Näherung. ## - Bauteile (gebaeude): Trimesh aus dem gebackten Piece-Mesh — Türen
func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array: ## bleiben Durchgänge, Treppen sind begehbar (wie die sichtbare Geometrie).
var out: Array = [] func _baue_kollider(doc: Dictionary) -> void:
var lib := _lade_objekt_registry() var kb := load(KOLLIDER_SCRIPT)
if view_builder != null: _kollider_root = kb.call("baue_welt_kollider", doc)
for child in view_builder.get_children(): add_child(_kollider_root)
if child is Node3D and (child as Node3D).has_meta("obj_index"):
var m := child as Node3D
var idx := int(m.get_meta("obj_index", -1))
var aabb_v: Variant = _objekt_aabb_aus_registry(doc, idx, lib, m)
if aabb_v != null:
out.append(aabb_v)
continue
# Fallback: alte Kugel-Näherung (Objekt fehlt in der Registry).
var r := float(m.get_meta("pick_radius", 1.0))
var ch := float(m.get_meta("half_height", 0.5))
var p := m.position
out.append(AABB(Vector3(p.x - r, p.y, p.z - r),
Vector3(r * 2.0, ch * 2.0, r * 2.0)))
var build_ops := load(BUILD_OPS_SCRIPT) var build_ops := load(BUILD_OPS_SCRIPT)
if build_ops != null: if build_ops == null:
return
for teil in doc.get("gebaeude", []): for teil in doc.get("gebaeude", []):
if teil is Dictionary: if not (teil is Dictionary):
var ab: AABB = build_ops.call("piece_aabb", teil) continue
if ab.size.x > 0.0 or ab.size.y > 0.0 or ab.size.z > 0.0: var mesh: Mesh = build_ops.call("piece_mesh", teil)
out.append(ab) if mesh == null:
return out continue
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var pb := StaticBody3D.new()
## objects.json einmal je Sammel-Lauf laden (klein genug, Playtest startet pb.name = "Bauteil_Kollider_%d" % _kollider_root.get_child_count()
## selten) — Key „objects“ → Dictionary oid → Entry. var cs := CollisionShape3D.new()
func _lade_objekt_registry() -> Dictionary: cs.shape = mesh.create_trimesh_shape()
var parsed: Variant = JSON.parse_string( pb.add_child(cs)
FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH)) pb.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
if parsed is Dictionary and (parsed as Dictionary).has("objects"): pb.rotation.y = deg_to_rad(float(teil.get("rot", 0.0)))
return (parsed as Dictionary)["objects"] _kollider_root.add_child(pb)
return {}
## Rotierter Fußabdruck-AABB eines Objekts aus der Registry — null wenn keine
## belastbaren Daten da sind (Caller fällt auf die Kugel-Näherung zurück).
## Y läuft vom Fußpunkt (Marker-Position) bis Fußpunkt + size.y × scale —
## gleiche Konvention wie der Client-Kollider.
func _objekt_aabb_aus_registry(doc: Dictionary, idx: int, lib: Dictionary,
marker: Node3D) -> Variant:
var objects: Array = doc.get("objects", [])
if idx < 0 or idx >= objects.size() or not (objects[idx] is Dictionary):
return null
var obj: Dictionary = objects[idx]
var entry: Dictionary = lib.get(str(obj.get("id", "")), {})
if entry.is_empty():
return null
var col: Dictionary = entry.get("collision", {})
var size_v: Variant = col.get("size", null)
if not (size_v is Array) or (size_v as Array).size() != 3:
return null
var skala := float(obj.get("scale", 1.0))
var sy := float(size_v[1]) * skala
var hx := float(size_v[0]) * skala * 0.5
var hz := float(size_v[2]) * skala * 0.5
if hx <= 0.0 or hz <= 0.0:
return null
var p := marker.position
var rot := deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
var c := cos(rot)
var s := sin(rot)
# 4 Grundflächen-Ecken rotieren → konservative xz-Ausdehnung (symmetrisch
# um die Position, da der Fußabdruck zentriert ist).
var ex := maxf(absf(hx * c + hz * s), absf(hx * c - hz * s))
var ez := maxf(absf(hx * s + hz * c), absf(hx * s - hz * c))
return AABB(Vector3(p.x - ex, p.y, p.z - ez), Vector3(ex * 2.0, sy, ez * 2.0))
# ------------------------------------------------------------------- HUD ---- # ------------------------------------------------------------------- HUD ----
@ -288,8 +260,11 @@ func _process(delta: float) -> void:
if hud_pos != null: if hud_pos != null:
hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z] hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z]
if hud_dir != null: if hud_dir != null:
# Höhe = FUSSPUNKT (Kapsel-Mitte minus Halbhöhe) — direkt vergleichbar
# mit der Terrainhöhe unter den Füßen (Karte t_1da96275).
hud_dir.text = "Blick: %.0f° / %.0f° · Höhe: %.1f m" % [ hud_dir.text = "Blick: %.0f° / %.0f° · Höhe: %.1f m" % [
float(body.get("yaw_deg")), float(body.get("pitch_deg")), body.position.y] float(body.get("yaw_deg")), float(body.get("pitch_deg")),
body.position.y - PlaytestBody.HALF_H]
if hud_fps != null: if hud_fps != null:
hud_fps.text = "FPS: %d" % int(round(_fps_smooth)) hud_fps.text = "FPS: %d" % int(round(_fps_smooth))
@ -395,7 +370,9 @@ func jump_to_spawn(index: int) -> bool:
return false return false
var target: Vector3 = sp["pos"] var target: Vector3 = sp["pos"]
var y: float = body.terrain_height(target.x, target.z) var y: float = body.terrain_height(target.x, target.z)
body.set("position", Vector3(target.x, y, target.z)) # Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz) — die Physik lässt den Body
# sanft auf den Trimesh fallen (wie beim Start, Karte t_1da96275).
body.set("position", Vector3(target.x, y + PlaytestBody.HALF_H + 0.02, target.z))
body.set("velocity", Vector3.ZERO) body.set("velocity", Vector3.ZERO)
body.set("on_floor", true) body.set("on_floor", true)
show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [ show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [

236
src/svmap/kollider_bau.gd Normal file
View file

@ -0,0 +1,236 @@
extends RefCounted
class_name KolliderBau
## Kollider-Bau 1:1 aus dem Quest-3-Client gespiegelt (Karte t_1da96275 —
## „Editor-Playtest-Physik = Quest 3 identisch").
##
## Dieser Modul ist der SPIEGEL von client/scripts/world/svmap_world.gd
## (_collider/_primitiv_form/_trimesh_aus_datei/_half_height + Terrain-
## Trimesh). Die Playtest-Physik im Editor benutzt EXAKT dieselben Formen
## wie der Quest-Client: box/kugel/zylinder/verbund, Trimesh (inline oder
## aus library/kollisions/<oid>.json), Terrain als Trimesh des Höhenfelds.
##
## Regel: Ändert der Client den Kollider-Bau, wird dieser Spiegel MIT
## geändert (Karte verweist auf svmap_world.gd als Quelle der Wahrheit).
## Bewusst KEINE Eigenlogik hier — jede Abweichung wäre eine Abweichung
## vom Quest-3-Verhalten, die Sly ausdrücklich abgelehnt hat.
const DEFAULT_MANIFEST_PATH := "res://library/objects.json"
## Trimesh-Quelldateien (begehbare Gebäude): Cache über alle Exemplare —
## mehrere Platzierungen derselben OID teilen sich die Vertices (wie im Client).
static var _trimesh_datei_cache := {}
## Bequemer Einstieg: Manifest selbst laden (leer → Objekte ohne Eintrag
## werden übersprungen — die Editor-Validierung hätte sie abgelehnt).
## Spiegel von SvmapWorld.load_manifest().
static func lade_manifest() -> Dictionary:
if not FileAccess.file_exists(DEFAULT_MANIFEST_PATH):
return {}
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(DEFAULT_MANIFEST_PATH))
return parsed if parsed is Dictionary else {}
## Baut ALLE Welt-Kollider aus einem svmap-Dokument: Terrain-Trimesh +
## je Objekt ein StaticBody3D (Form aus der Registry, Quelle-Dateien aus
## library/kollisions). Gibt die Wurzel zurück (Caller hängt sie ein und
## gibt sie mit frei). Spiegel von SvmapWorld._build (nur Kollider, ohne
## Visuals — die sieht der Editor bereits durch den view_builder).
static func baue_welt_kollider(data: Dictionary, manifest: Dictionary = {}) -> Node3D:
var root := Node3D.new()
root.name = "PlaytestKollider"
var lib: Dictionary = {}
if manifest.is_empty():
manifest = lade_manifest()
lib = manifest.get("objects", {})
var terrain: Dictionary = data.get("terrain", {})
if not terrain.is_empty():
var tform := terrain_form(data)
if tform != null:
var tbody := StaticBody3D.new()
tbody.name = "TerrainKollider"
var tcs := CollisionShape3D.new()
tcs.shape = tform
tbody.add_child(tcs)
root.add_child(tbody)
var objects: Array = data.get("objects", [])
var i := 0
for obj in objects:
i += 1
if not (obj is Dictionary):
continue
var oid := str(obj.get("id", ""))
var entry: Dictionary = lib.get(oid, {})
if entry.is_empty():
continue # Editor-Validierung hätte das abgelehnt — defensiv.
var col_spec: Dictionary = entry.get("collision", {})
var scale := float(obj.get("scale", 1.0))
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var marker := Node3D.new()
marker.name = "Kollider_%d_%s" % [i, oid]
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
marker.add_child(kollider(col_spec, scale))
root.add_child(marker)
return root
## --------------------------------------------------------------------- --
## Ab hier 1:1-SPIEGEL von svmap_world.gd (Client) — Funktionsweise, Reihen-
## folge und Toleranzen identisch, nur statisch gebunden.
## --------------------------------------------------------------------- --
## Spiegel von SvmapWorld._collider(): compound = mehrere Teile mit eigenem
## Versatz/Drehung, "none" = kein Kollider, sonst eine Form am Fußpunkt.
static func kollider(col_spec: Dictionary, scale: float) -> StaticBody3D:
var body := StaticBody3D.new()
# Rein dekorativ ("none"): kein Kollider.
if str(col_spec.get("shape", "box")) == "none":
return body
if str(col_spec.get("shape", "box")) == "compound":
for teil_v in col_spec.get("parts", []):
var teil: Dictionary = teil_v if teil_v is Dictionary else {}
var cs := CollisionShape3D.new()
cs.shape = primitiv_form(teil, scale)
var off: Array = teil.get("pos", [0.0, 0.0, 0.0])
cs.position = Vector3(float(off[0]), float(off[1]), float(off[2])) * scale
var rot: Array = teil.get("rot_deg", [0.0, 0.0, 0.0])
cs.rotation_degrees = Vector3(float(rot[0]), float(rot[1]), float(rot[2]))
body.add_child(cs)
return body
var cs2 := CollisionShape3D.new()
var form := primitiv_form(col_spec, scale)
if form == null:
return body # Ungültige Kollisions-Spec (z. B. defektes Trimesh): kein Kollider.
cs2.shape = form
cs2.position.y = halbe_hoehe(cs2.shape) # pos = Objekt-Fußpunkt (Server-Konvention)
body.add_child(cs2)
return body
## Spiegel von SvmapWorld._primitiv_form(): box/sphere/cylinder/trimesh.
## Trimesh: inline-vertices oder collision.quelle aus library/kollisions/,
## × scale, backface AN (begehbar + beschußbar von beiden Seiten).
## Defekte Specs → null (kein Kollider).
static func primitiv_form(col_spec: Dictionary, scale: float) -> Shape3D:
match str(col_spec.get("shape", "box")):
"sphere":
var s := SphereShape3D.new()
s.radius = float(col_spec.get("radius", 1.0)) * scale
return s
"cylinder":
var s := CylinderShape3D.new()
s.radius = float(col_spec.get("radius", 1.0)) * scale
s.height = float(col_spec.get("height", 1.0)) * scale
return s
"trimesh":
var verts_v: Variant = col_spec.get("vertices", null)
if verts_v == null and col_spec.has("quelle"):
verts_v = trimesh_aus_datei(str(col_spec["quelle"]))
if not (verts_v is Array):
return null
var verts: Array = verts_v
if verts.is_empty() or verts.size() % 3 != 0:
return null
var faces := PackedVector3Array()
faces.resize(verts.size())
var fi := 0
for v in verts:
if not (v is Array) or (v as Array).size() != 3 \
or not (v[0] is float or v[0] is int) \
or not (v[1] is float or v[1] is int) \
or not (v[2] is float or v[2] is int):
return null
faces[fi] = Vector3(float(v[0]), float(v[1]), float(v[2])) * scale
fi += 1
var s := ConcavePolygonShape3D.new()
s.set_faces(faces)
s.backface_collision = true
return s
_:
var s := BoxShape3D.new()
var size: Array = col_spec.get("size", [1.0, 1.0, 1.0])
s.size = Vector3(float(size[0]), float(size[1]), float(size[2])) * scale
return s
## Spiegel von SvmapWorld._trimesh_aus_datei(): Quelle-Datei relativ zu
## res://library/, JSON {"vertices": [[x,y,z], ...]}.
static func trimesh_aus_datei(quelle: String) -> Variant:
if quelle.is_empty():
return null
if _trimesh_datei_cache.has(quelle):
return _trimesh_datei_cache[quelle]
var fa := FileAccess.open("res://library/" + quelle, FileAccess.READ)
if fa == null:
_trimesh_datei_cache[quelle] = null
return null
var parsed: Variant = JSON.parse_string(fa.get_as_text())
var verts: Variant = null
if parsed is Dictionary and (parsed as Dictionary).get("vertices", null) is Array:
verts = (parsed as Dictionary)["vertices"]
_trimesh_datei_cache[quelle] = verts
return verts
## Spiegel des Terrain-Kolliders aus SvmapWorld._build_terrain(): DASSELBE
## Mesh (gleiche Platzierungs-Konvention (0,0) = (−size/2, −size/2), gleiche
## Dreiecks-Wicklung, generate_normals) → create_trimesh_shape(). Nur der
## Kollider, kein MeshInstance (den rendered der Editor bereits).
static func terrain_form(data: Dictionary) -> ConcavePolygonShape3D:
var terrain: Dictionary = data.get("terrain", {})
var res := int(terrain.get("resolution", 0))
var size_x := float(terrain.get("size_x_m", 0.0))
var size_z := float(terrain.get("size_z_m", 0.0))
var heights := SvmapIO.decode_heights(terrain)
if res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0:
return null
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.set_smooth_group(-1) # wie Client (einflussfrei auf die Geometrie)
var last := float(res - 1)
var zi := 0
while zi < res:
var xi := 0
while xi < res:
# Platzierungs-Konvention (TerrainField.gd, map_document,
# HeightMapShape3D): Index (0,0) liegt bei (−size/2, −size/2).
var idx := zi * res + xi
st.add_vertex(Vector3(
float(xi) / last * size_x - size_x * 0.5,
heights[idx],
float(zi) / last * size_z - size_z * 0.5))
xi += 1
zi += 1
# Dreiecke so gewickelt, dass die Normalen nach OBEN zeigen — exakt die
# Client-Wicklung ((a, d, c) + (a, b, d), Godot-Front-Faces = clockwise).
var zi2 := 0
while zi2 < res - 1:
var xi2 := 0
while xi2 < res - 1:
var a := zi2 * res + xi2
var b := zi2 * res + xi2 + 1
var c := (zi2 + 1) * res + xi2
var d := (zi2 + 1) * res + xi2 + 1
st.add_index(a)
st.add_index(d)
st.add_index(c)
st.add_index(a)
st.add_index(b)
st.add_index(d)
xi2 += 1
zi2 += 1
st.generate_normals()
var mesh := st.commit()
return mesh.create_trimesh_shape() as ConcavePolygonShape3D
## Spiegel von SvmapWorld._half_height().
static func halbe_hoehe(shape: Shape3D) -> float:
if shape is BoxShape3D:
return (shape as BoxShape3D).size.y * 0.5
if shape is CylinderShape3D:
return (shape as CylinderShape3D).height * 0.5
if shape is SphereShape3D:
return (shape as SphereShape3D).radius
return 0.0

View file

@ -0,0 +1 @@
uid://cncg002d4e5x7

View file

@ -0,0 +1,85 @@
extends SceneTree
## Identitäts-Probe-Map (Karte t_1da96275): deterministische Testkarte, die
## BOTH WORLDS — Editor-Playtest UND Quest-Client — mit EXAKT denselben
## Daten fährt (Identitäts-Vertrag: gleiche Map → gleiche Block/Walk-
## Entscheidungen).
##
## Layout (Höhenfeld res 33 = 2-m-Zellen, 64 × 64 m):
## x < 0 → Höhe 0.0 (flach)
## 0 ≤ x < 8 → Höhe 0.3 (Stufe 0.3 m — erklimmbar, Step-Up)
## x ≥ 8 → Höhe 1.1 (Stufe 0.8 m darüber — blockt)
## Objekte (alle auf FLACHEM Terrain, damit die Proben sauber sind):
## building_001 bei (−16, 0, −16) → Tür-Probe (Azimut 202.5°, Befund
## Karte t_f9adf97c: freie Breite 1.10 m)
## barracks_001 bei ( 20, 0, 12) → Wand-Probe (Trimesh blockt)
## box_006 bei (−16, 0, 8) → Kisten-Stufe 0.334 m (Step-Up)
## Run: godot4 --headless --path . -s res://tests/gen_identitaet_map.gd
## Schreibt: ${SVR_IDENTITAET_MAP:-/tmp/svr_identitaet_probe.svmap} (JSON).
const STEP_KLEIN := 0.3
const STUFE_GROSS := 1.1
func _initialize() -> void:
var res := 33
var size := 64.0
var heights := PackedFloat32Array()
heights.resize(res * res)
for zi in res:
for xi in res:
# Platzierungs-Konvention: (0,0) liegt bei (−size/2, −size/2);
# Höhe hängt nur von x ab (Spalten).
var x := float(xi) / float(res - 1) * size - size * 0.5
var h := 0.0
if x >= 0.0:
h = STEP_KLEIN
if x >= 8.0:
h = STUFE_GROSS
heights[zi * res + xi] = h
var raw_b64 := TerrainField.encode(heights, 0.0, STUFE_GROSS)
var doc: Dictionary = {
"format_version": 1,
"info": {"name": "Identitaets-Probe", "author": "Karte t_1da96275", "version": 1},
"terrain": {
"type": "heightmap",
"resolution": res,
"size_x_m": size,
"size_z_m": size,
"min_height_m": 0.0,
"max_height_m": STUFE_GROSS,
"heightmap_r16_b64": raw_b64,
},
"objects": [
{"id": "building_001", "pos": [-16.0, 0.0, -16.0], "rot_y_deg": 0.0, "scale": 1.0},
{"id": "barracks_001", "pos": [20.0, 0.0, 12.0], "rot_y_deg": 0.0, "scale": 1.0},
{"id": "box_006", "pos": [-16.0, 0.0, 8.0], "rot_y_deg": 0.0, "scale": 1.0},
# Wand-Ersatz für die „Stufe 0.8 m blockt"-Probe: 0.85 m hoch —
# über der Step-Up-Grenze (0.45 m) → muss blocken. Steht AUF der
# 0.3-Ebene (Terrain bei x=2 ist 0.3), Fußpunkt-Konvention.
{"id": "barrel_001", "pos": [2.0, 0.3, 20.0], "rot_y_deg": 0.0, "scale": 1.0},
],
"spawn_zones": {
"0": {"points": [
{"id": "A1", "pos": [-30.0, 0.0, -30.0]}, {"id": "A2", "pos": [-28.0, 0.0, -20.0]},
{"id": "A3", "pos": [-26.0, 0.0, 20.0]}, {"id": "A4", "pos": [-24.0, 0.0, 10.0]},
]},
"1": {"points": [
{"id": "B1", "pos": [30.0, 0.0, -30.0]}, {"id": "B2", "pos": [28.0, 0.0, -20.0]},
{"id": "B3", "pos": [26.0, 0.0, 20.0]}, {"id": "B4", "pos": [24.0, 0.0, 10.0]},
]},
},
"bounds": {"min": [-32.0, -5.0, -32.0], "max": [32.0, 20.0, 32.0]},
"weather": "mittag",
}
var path := OS.get_environment("SVR_IDENTITAET_MAP")
if path.is_empty():
path = "/tmp/svr_identitaet_probe.svmap"
var fa := FileAccess.open(path, FileAccess.WRITE)
if fa == null:
printerr("IDENT_GEN FAIL: kann nicht schreiben: " + path)
quit(1)
return
fa.store_string(JSON.stringify(doc))
fa.close()
print("IDENT_MAP=%s" % path)
quit(0)

View file

@ -0,0 +1 @@
uid://b83poyvxdh3cv

View file

@ -0,0 +1 @@
uid://dgsj8nupg3hc4

View file

@ -0,0 +1 @@
uid://baoakwtc0wtvc

View file

@ -0,0 +1,61 @@
extends SceneTree
## Wegwerf-Probe (Karte t_1da96275): tickt echte Physik (CharacterBody3D +
## StaticBody-Trimesh) in einem SubViewport mit own_world_3d — so lebt der
## Playtest im Editor. Exit 0 = ja.
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var vp := SubViewport.new()
vp.own_world_3d = true
root.add_child(vp)
var welt := Node3D.new()
vp.add_child(welt)
# Boden: Box als StaticBody (Oberkante y=0)
var boden := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(40, 0.2, 40)
cs.shape = box
boden.add_child(cs)
boden.position = Vector3(0, -0.1, 0)
welt.add_child(boden)
# Wand zum Blocken
var wand := StaticBody3D.new()
var cs2 := CollisionShape3D.new()
var box2 := BoxShape3D.new()
box2.size = Vector3(2, 3, 0.6)
cs2.shape = box2
wand.add_child(cs2)
wand.position = Vector3(0, 1.5, -5)
welt.add_child(wand)
# PlaytestBody via Autopilot
var body: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new()
welt.add_child(body)
body.auto_move_dir = Vector3(0, 0, -1)
body.global_position = Vector3(0, 1.2, 5)
for i in 40:
await physics_frame
print("PROBE nach Fall: feet=%.3f on_floor=%s" % [body.fuss_hoehe(), str(body.is_on_floor())])
var ok_fall: bool = body.is_on_floor() and absf(body.fuss_hoehe()) < 0.05
for i in 240:
await physics_frame
if body.global_position.z < -4.2:
break
body.auto_move_dir = Vector3.ZERO
var z: float = body.global_position.z
print("PROBE vor Wand gestoppt: z=%.3f (Wand-Fläche bei -4.7)" % z)
var ok_block: bool = z > -4.6
# RayQuery gegen den Space des SubViewports
var raum := body.get_world_3d().direct_space_state
var hit := raum.intersect_ray(PhysicsRayQueryParameters3D.create(
Vector3(0, 2, -10), Vector3(0, 0, -10)))
print("PROBE ray hit Wand: %s" % str(not hit.is_empty()))
var ok_ray: bool = not hit.is_empty()
if ok_fall and ok_block and ok_ray:
print("PROBE OK: Physik im SubViewport tickt (Fall+Block+Ray)")
quit(0)
else:
print("PROBE FAIL: fall=%s block=%s ray=%s" % [str(ok_fall), str(ok_block), str(ok_ray)])
quit(1)

View file

@ -0,0 +1 @@
uid://5fc1sxkkbxr8

105
tests/probe_playtest_fps.gd Normal file
View file

@ -0,0 +1,105 @@
extends SceneTree
## FPS-Probe (Karte t_1da96275, Punkt 5 der Spec): Playtest mit ECHTER Physik
## (SubViewport + World3D + CharacterBody3D + Trimesh-Kollider: Terrain +
## several Gebäude/Bauteile) darf die Editor-Sicht NICHT unter ~60 fps drücken.
##
## Ausführen (braucht einen Bildschirm — virtueller Screen):
## xvfb-run -a godot4 --path . --rendering-driver opengl3 -s res://tests/probe_playtest_fps.gd
## Messung RELATIV (der Container rendert Software-OpenGL/llvmpipe — die
## absolute FPS-Zahl ist dort ein Render-Artefakt): Editor-Sicht OHNE Playtest
## vs. MIT Playtest-Physik (SubViewport + World3D + CharacterBody3D +
## Trimesh-Kollider). Exit 0, wenn der Physik-Zusatz ≤ 20 % Kosten bringt und
## die Editor-Basis unverändert flüssig bleibt (echte 60-fps-Abnahme: Sly-Test).
var main: Control
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
# Probe-Map: Terrain + mehrere Objekte (Trimeshe) + Bauteile — echte Last.
var doc: Dictionary = SvmapDocument.create_new("FPS-Probe", 200.0, "mittag")
doc["objects"] = [
{"id": "building_001", "pos": [0.0, 0.0, -30.0], "rot_y_deg": 0.0, "scale": 1.0},
{"id": "barracks_001", "pos": [20.0, 0.0, 30.0], "rot_y_deg": 90.0, "scale": 1.0},
{"id": "hangar_001", "pos": [-40.0, 0.0, 20.0], "rot_y_deg": 45.0, "scale": 1.0},
{"id": "container_001", "pos": [10.0, 0.0, 10.0], "rot_y_deg": 0.0, "scale": 1.0},
{"id": "tent_001", "pos": [-20.0, 0.0, -10.0], "rot_y_deg": 0.0, "scale": 1.0},
{"id": "box_006", "pos": [6.0, 0.0, 4.0], "rot_y_deg": 0.0, "scale": 1.0},
]
var build_ops := load("res://src/editor/build_ops.gd")
var teile: Array = []
for i in 24:
var kind: String = ["wand", "boden", "tuer", "treppe"][i % 4]
var px := -16.0 + float(i % 6) * 2.0
var pz := 40.0 + float(i / 6) * 2.0
teile.append(build_ops.call("make_piece", kind, Vector3(px, 0.0, pz), 0.0))
doc["gebaeude"] = teile
doc["spawn_zones"] = {"0": {"points": [{"id": "A1", "pos": [0.0, 0.0, 20.0]},
{"id": "A2", "pos": [5.0, 0.0, 20.0]}, {"id": "A3", "pos": [-5.0, 0.0, 20.0]},
{"id": "A4", "pos": [10.0, 0.0, 20.0]}]}}
main.set("doc", doc)
main.set("current_path", "")
main.set("dirty", false)
main.get("history").begin(doc)
main.call("_clear_edit_state")
main.call("_refresh_all")
await process_frame
await process_frame
# --- Basis: Editor-Sicht OHNE Playtest (240 Frames) ----------------------
await process_frame
var basis: Dictionary = await _messfenster(240)
print("FPS_PROBE ZAHLEN Basis (ohne Playtest): avg=%.1f min=%.0f max=%.0f" %
[basis.avg, basis.min, basis.max])
main.call("_on_test_run")
await process_frame
var pt: Node = main.get("playtest")
if pt == null:
print("FPS_PROBE FAIL: Playtest läuft nicht")
quit(1)
return
var body: Node = pt.get("body")
# Auto-Lauf durch die Szene (Bewegung + Physik + Render gleichzeitig).
body.set("auto_move_dir", Vector3(0, 0, -1))
var kollider: Node3D = pt.get("_kollider_root")
print("FPS_PROBE: Kollider-Kinder=%d (Terrain + Objekte + Bauteile)" % kollider.get_child_count())
# --- Playtest: Physik + Render (240 Frames) ------------------------------
await process_frame
var mit: Dictionary = await _messfenster(240)
body.set("auto_move_dir", Vector3.ZERO)
print("FPS_PROBE ZAHLEN Playtest (mit Physik): avg=%.1f min=%.0f max=%.0f" %
[mit.avg, mit.min, mit.max])
var overhead := 0.0
if basis.avg > 0.0:
overhead = (basis.avg - mit.avg) / basis.avg * 100.0
print("FPS_PROBE ZAHLEN Overhead: %.1f %%" % overhead)
if overhead <= 20.0:
print("FPS_PROBE OK: Physik-Zusatz %.1f %% ≤ 20 %% — keine Editor-Regression" % overhead)
quit(0)
else:
print("FPS_PROBE FAIL: Physik-Zusatz %.1f %% > 20 %%" % overhead)
quit(1)
func _messfenster(frames: int) -> Dictionary:
var n_min := 9999.0
var n_max := 0.0
var sum := 0.0
for i in frames:
await process_frame
var fps := float(Engine.get_frames_per_second())
n_min = minf(n_min, fps)
n_max = maxf(n_max, fps)
sum += fps
return {"avg": sum / float(frames), "min": n_min, "max": n_max}

View file

@ -0,0 +1 @@
uid://bnyeqe2tyxj58

View file

@ -77,7 +77,7 @@ func _run() -> void:
print("ZAHLEN (a) Spawn: body.position=", body.position, print("ZAHLEN (a) Spawn: body.position=", body.position,
" | Terrainhöhe dort=", body.terrain_height(0.0, 0.0)) " | Terrainhöhe dort=", body.terrain_height(0.0, 0.0))
print("ZAHLEN (a) yaw=", body.get("yaw_deg"), " pitch=", body.get("pitch_deg"), print("ZAHLEN (a) yaw=", body.get("yaw_deg"), " pitch=", body.get("pitch_deg"),
" eye_height=", body.get("eye_height"), " radius=", body.get("radius")) " Kapsel-Mitte y=", body.position.y, " feet=", body.fuss_hoehe())
var look_cam: Camera3D = body.get("look_cam") var look_cam: Camera3D = body.get("look_cam")
var editor_cam: Camera3D = main.get("camera") var editor_cam: Camera3D = main.get("camera")
print("ZAHLEN (a) Spiel-Kamera current=", look_cam.current, print("ZAHLEN (a) Spiel-Kamera current=", look_cam.current,
@ -93,16 +93,17 @@ func _run() -> void:
" rechts=", main.get("ui_right_panel").visible) " rechts=", main.get("ui_right_panel").visible)
await _shot("t1_a_spieler_startansicht.png") await _shot("t1_a_spieler_startansicht.png")
# --- (b) 2 s Gehen (60 × step 1/30 s, fwd) — Bewegung + Terrainanpassung -- # --- (b) 1 s Gehen (60 Physik-Frames, Autopilot) — Bewegung + Terrain ----
var pos_vor: Vector3 = body.position var pos_vor: Vector3 = body.position
body.set("auto_move_dir", Vector3(0, 0, -1))
for i in 60: for i in 60:
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, await physics_frame
"right": false, "run": false, "jump": false}) body.set("auto_move_dir", Vector3.ZERO)
var pos_nach: Vector3 = body.position var pos_nach: Vector3 = body.position
print("ZAHLEN (b) Position vor/nach 60 steps: ", pos_vor, " → ", pos_nach, print("ZAHLEN (b) Position vor/nach 60 Physik-Frames: ", pos_vor, " → ", pos_nach,
" (Δ=", pos_vor.distance_to(pos_nach), " m)") " (Δ=", pos_vor.distance_to(pos_nach), " m)")
print("ZAHLEN (b) on_floor=", body.get("on_floor"), print("ZAHLEN (b) on_floor=", body.is_on_floor(),
" y-nach=", pos_nach.y, " feet=", body.fuss_hoehe(),
" Terrainhöhe dort=", body.terrain_height(pos_nach.x, pos_nach.z)) " Terrainhöhe dort=", body.terrain_height(pos_nach.x, pos_nach.z))
await _shot("t1_b_spieler_gelaufen.png") await _shot("t1_b_spieler_gelaufen.png")

View file

@ -0,0 +1 @@
uid://j6ly4d03x0n2

View file

@ -0,0 +1 @@
uid://f0nwxnrohk4a

View file

@ -0,0 +1 @@
uid://cntoenc8jaapy

94
tests/run_identitaet.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Identitäts-Vertrag (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
# identisch"): dieselbe Probe-Map läuft durch
# A) Editor-Playtest (KolliderBau + PlaytestBody im SubViewport)
# B) Quest-Client (SvmapWorld + TestmapPlayer, im CLIENT-Projekt)
# Beide drucken `IDENT <name>=<wert>`-Zeilen; der Runner verlangt, dass die
# Zeilen IDENTISCH sind (gleiche Block/Walk-Entscheidungen in beiden Welten)
# und dass beide Suiten ihre Soll-Erwartungen erfüllen (Exit 0).
#
# Environment:
# GODOT Godot-Binary (Default: $HOME/bin/godot4, dann $PATH)
# SVR_CLIENT_DIR Client-Projektordner (Default: ../scopedvr-client neben
# diesem Repo — erkannt an project.godot)
# Exit 0 = Identität bestätigt.
set -u
if [ -n "${GODOT:-}" ]; then :
elif [ -x "$HOME/bin/godot4" ]; then
GODOT="$HOME/bin/godot4"
elif command -v godot4 >/dev/null 2>&1; then
GODOT=$(command -v godot4)
else
echo "FAIL: Kein Godot gefunden — GODOT=<pfad> setzen." >&2
exit 1
fi
EDITOR_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Client-Projekt finden: Env gewinnt, sonst Nachbar-Klon des Editors.
CLIENT_DIR="${SVR_CLIENT_DIR:-}"
if [ -z "$CLIENT_DIR" ]; then
for cand in "$EDITOR_DIR/../scopedvr-client" "$EDITOR_DIR/client"; do
if [ -f "$cand/project.godot" ]; then CLIENT_DIR="$cand"; break; fi
done
fi
if [ -z "$CLIENT_DIR" ] || [ ! -f "$CLIENT_DIR/project.godot" ]; then
echo "FAIL: Client-Projekt nicht gefunden (SVR_CLIENT_DIR setzen)." >&2
exit 1
fi
export GODOT_SILENCE_ROOT_WARNING=1
LOGDIR="${SVR_IDENTITAET_LOGDIR:-/tmp/svr_identitaet_logs_$$}"
mkdir -p "$LOGDIR"
MAP="${SVR_IDENTITAET_MAP:-/tmp/svr_identitaet_probe.svmap}"
export SVR_IDENTITAET_MAP="$MAP"
fail=0
echo "== 0) Probe-Map erzeugen =="
"$GODOT" --headless --path "$EDITOR_DIR" -s res://tests/gen_identitaet_map.gd \
> "$LOGDIR/gen.log" 2>&1
if ! grep -q "^IDENT_MAP=" "$LOGDIR/gen.log"; then
echo "FAIL: Probe-Map nicht erzeugt (siehe $LOGDIR/gen.log)" >&2
cat "$LOGDIR/gen.log" >&2
exit 1
fi
echo "== 1) Import (beide Projekte) =="
"$GODOT" --headless --path "$EDITOR_DIR" --import > "$LOGDIR/import_editor.log" 2>&1
grep -E "Parse Error|SCRIPT ERROR" "$LOGDIR/import_editor.log" | head -5 >&2
"$GODOT" --headless --path "$CLIENT_DIR" --import > "$LOGDIR/import_client.log" 2>&1
grep -E "Parse Error|SCRIPT ERROR" "$LOGDIR/import_client.log" | head -5 >&2
echo "== 2) Editor-Playtest-Probe =="
"$GODOT" --headless --path "$EDITOR_DIR" -s res://tests/test_playtest_identitaet.gd \
> "$LOGDIR/editor.log" 2>&1
code=$?
grep -E "^IDENT [a-z_0-9]+=" "$LOGDIR/editor.log" > "$LOGDIR/editor.ident"
grep -E "ALL OK|FAILED" "$LOGDIR/editor.log" | tail -1
[ $code -ne 0 ] && fail=1 && echo "Editor-Suite rot (exit=$code):" >&2 && cat "$LOGDIR/editor.log" >&2
echo "== 3) Quest-Client-Probe =="
"$GODOT" --headless --path "$CLIENT_DIR" -s "$EDITOR_DIR/tests/test_identitaet_client.gd" \
> "$LOGDIR/client.log" 2>&1
code=$?
grep -E "^IDENT [a-z_0-9]+=" "$LOGDIR/client.log" > "$LOGDIR/client.ident"
grep -E "ALL OK|FAILED" "$LOGDIR/client.log" | tail -1
[ $code -ne 0 ] && fail=1 && echo "Client-Suite rot (exit=$code):" >&2 && cat "$LOGDIR/client.log" >&2
echo "== 4) Identitäts-Vergleich =="
n_editor=$(wc -l < "$LOGDIR/editor.ident")
n_client=$(wc -l < "$LOGDIR/client.ident")
if [ "$n_editor" -lt 6 ] || [ "$n_client" -lt 6 ]; then
echo "FAIL: Zu wenige IDENT-Zeilen (editor=$n_editor, client=$n_client) — Suiten sind gecrasht." >&2
tail -20 "$LOGDIR/editor.log" >&2
tail -20 "$LOGDIR/client.log" >&2
fail=1
elif diff -u "$LOGDIR/editor.ident" "$LOGDIR/client.ident" > "$LOGDIR/diff.txt"; then
echo "IDENTITAET OK: $n_editor Entscheidungen identisch."
else
echo "FAIL: Entscheidungen weichen ab:" >&2
cat "$LOGDIR/diff.txt" >&2
fail=1
fi
[ $fail -eq 0 ] && echo "Logs: $LOGDIR" && exit 0
echo "Logs: $LOGDIR"
exit 1

View file

@ -0,0 +1,154 @@
extends SceneTree
## Identitäts-Vertrag, QUEST-CLIENT-SEITE (Karte t_1da96275): dieselbe
## Probe-Map wie tests/test_playtest_identitaet.gd, aber im echten
## Client-Kontext (SvmapWorld + TestmapPlayer — der Quest-3-Weg).
## Wird ÜBER das Editor-Repo mit dem Client-Projekt gefahren:
## SVR_IDENTITAET_MAP=/tmp/svr_identitaet_probe.svmap \
## godot4 --headless --path <scopedvr-client> -s <abs>/tests/test_identitaet_client.gd
## Druckt dieselben `IDENT <name>=<wert>`-Zeilen; der Runner diffed beide.
##
## BEWUSST 1:1 gespiegelt zu test_playtest_identitaet.gd — gleiche Start-
## Punkte, gleiche Frame-Zahlen, gleiche Entscheidungsregeln. Nur die
## Welt-/Spieler-Klassen sind die des Clients.
const HALF_H := 0.9
const TUER_AZIMUT_DEG := 202.5
const GEB_TUER := Vector2(-16.0, -16.0)
const GEB_HALF := Vector2(8.94, 3.54)
var fails: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _check(cond: bool, what: String) -> void:
if cond:
print("IDENT ok: ", what)
else:
fails.append(what)
print("IDENT FAIL: ", what)
func _run() -> void:
var map_path := OS.get_environment("SVR_IDENTITAET_MAP")
if map_path.is_empty():
map_path = "/tmp/svr_identitaet_probe.svmap"
if not FileAccess.file_exists(map_path):
printerr("IDENT FAIL: Probe-Map fehlt (%s)." % map_path)
quit(1)
return
var doc_v: Variant = JSON.parse_string(FileAccess.get_file_as_string(map_path))
if not (doc_v is Dictionary):
printerr("IDENT FAIL: Probe-Map nicht lesbar.")
quit(1)
return
var doc: Dictionary = doc_v
# --- Client-Aufbau: SvmapWorld (Kollider + Visuals) + TestmapPlayer ------
var welt: Node3D = SvmapWorld.build_from_data(doc, SvmapWorld.load_manifest())
root.add_child(welt)
var player: CharacterBody3D = TestmapPlayer.new()
welt.add_child(player)
await physics_frame
await physics_frame
# 1) Stufe 0.3 m (Terrain) — erklommen.
var r := await _lauf(player, Vector3(-8.0, 0.0, 20.0), Vector3(1, 0, 0), 300)
var st30 := "BLOCK"
if r.max_feet >= 0.25 and r.end.x > 1.0:
st30 = "KLIMM"
_ident("stufe_terrain_030", "%s %.2f" % [st30, r.max_feet])
_check(st30 == "KLIMM", "Stufe 0.3 m erklommen (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
# 2) Hohes Hindernis 0.85 m (barrel_001, Box-Kollider) — blockt: über der
# Step-Up-Grenze 0.45 m gibt es KEIN Klettern (Karte t_f9adf97c).
r = await _lauf(player, Vector3(0.5, 0.0, 20.0), Vector3(1, 0, 0), 240)
var st80 := "KLIMM"
if r.end.x < 1.55 and r.max_feet < 0.55:
st80 = "BLOCK"
_ident("wand_barrel", "%s %.2f x=%.2f" % [st80, r.max_feet, r.end.x])
_check(st80 == "BLOCK", "Hindernis 0.85 m blockt (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
# 3) Kisten-Stufe 0.334 m (box_006) — erklommen (Step-Up über Objekt).
r = await _lauf(player, Vector3(-24.0, 0.0, 8.0), Vector3(1, 0, 0), 260)
var box := "BLOCK"
if r.max_feet >= 0.28:
box = "KLIMM"
_ident("kisten_stufe", "%s %.2f" % [box, r.max_feet])
_check(box == "KLIMM", "Kiste 0.334 m erklommen (max feet %.2f)" % r.max_feet)
# 4) Wand (barracks_001-Trimesh) — blockt.
r = await _lauf(player, Vector3(20.0, 0.0, 31.5), Vector3(0, 0, -1), 300)
var wand := "DURCH"
if r.end.z > 29.3:
wand = "BLOCK"
_ident("wand_barracks", "%s z=%.2f" % [wand, r.end.z])
_check(wand == "BLOCK", "Kaserne blockt (Ende z=%.2f)" % r.end.z)
# 5) Tür (building_001) — durchlässig, Spieler landet IM Gebäude.
var outward := Vector3(cos(deg_to_rad(TUER_AZIMUT_DEG)), 0.0,
sin(deg_to_rad(TUER_AZIMUT_DEG)))
var start := Vector3(GEB_TUER.x + outward.x * 12.0, 0.0,
GEB_TUER.y + outward.z * 12.0)
r = await _lauf(player, start, -outward, 600)
var drin := absf(r.end.x - GEB_TUER.x) < GEB_HALF.x - 0.3 \
and absf(r.end.z - GEB_TUER.y) < GEB_HALF.y - 0.3
_ident("tuer_building", "%s %.2f %.2f" % ["DRIN" if drin else "DRAUSSEN", r.end.x, r.end.z])
_check(drin, "Tür building_001 durchlässig (Ende %.2f, %.2f)" % [r.end.x, r.end.z])
# 6) Terrain-Höhe unter den Füßen (Strahl abwärts, exakt).
_hoehe(welt, Vector3(-10.0, 10.0, 0.0), "terrain_hoehe_a", 0.0)
_hoehe(welt, Vector3(4.0, 10.0, 20.0), "terrain_hoehe_b", 0.3)
_hoehe(welt, Vector3(10.0, 12.0, -28.0), "terrain_hoehe_c", 1.1)
if fails.is_empty():
print("IDENT CLIENT: ALLE OK")
quit(0)
else:
print("IDENT CLIENT: FAILED %d" % fails.size())
quit(1)
func _ident(name: String, wert: String) -> void:
print("IDENT %s=%s" % [name, wert])
## Autopilot-Lauf — 1:1 zur Editor-Seite: Start y=3.0 (Fall-Settle),
## dann Richtung fahren, max Füß-Höhe + Endposition messen.
func _lauf(player: CharacterBody3D, start_xz: Vector3, dir: Vector3,
frames: int) -> Dictionary:
player.auto_move_dir = Vector3.ZERO
player.velocity = Vector3.ZERO
player.global_position = Vector3(start_xz.x, 3.0, start_xz.z)
for i in 45:
await physics_frame
player.auto_move_dir = dir
var max_feet := -INF
for i in frames:
await physics_frame
var feet := player.global_position.y - HALF_H
max_feet = maxf(max_feet, feet)
if feet < -5.0:
break
player.auto_move_dir = Vector3.ZERO
for i in 10:
await physics_frame
return {"end": Vector3(player.global_position), "max_feet": max_feet}
## Boden-Strahl an einem Punkt → IDENT-Zeile + Soll-Prüfung (±1 cm).
func _hoehe(welt: Node3D, p: Vector3, name: String, soll: float) -> void:
var raum := welt.get_world_3d().direct_space_state
var hit := raum.intersect_ray(PhysicsRayQueryParameters3D.create(
p, p + Vector3(0, -30, 0)))
var y := INF
if not hit.is_empty():
y = (hit["position"] as Vector3).y
if y == INF:
_ident(name, "KEIN_TREFFER")
_check(false, name + ": kein Boden-Treffer")
return
_ident(name, "%.2f" % y)
_check(absf(y - soll) <= 0.01, "%s: %.3f ≈ Soll %.2f" % [name, y, soll])

View file

@ -0,0 +1 @@
uid://ddt6sblpqgh4m

View file

@ -1,18 +1,21 @@
extends SceneTree extends SceneTree
## Editor-Test (headless): Karte t_1cdc1a72 — Lauf-Modus IM Map-Builder selbst ## Editor-Test (headless): Karte t_1cdc1a72 + Physik-Neubau t_1da96275 —
## (erste Person, WASD + Maus, OHNE Client-Ordner/VR/Server). ## Lauf-Modus IM Map-Builder selbst mit ECHTER Physik (CharacterBody3D +
## Ausführen: godot4 --headless --path . --script res://tests/test_playtest.gd ## Trimesh-Kollider via KolliderBau, identisch zum Quest-Client).
## Ausführen: godot4 --headless --path . --import && godot4 --headless --path . --script res://tests/test_playtest.gd
## Exit 0 = alles grün. ## Exit 0 = alles grün.
## ##
## Instanziert die ECHTE Main-UI (wie test_gui_smoke) und deckt ab: ## Instanziert die ECHTE Main-UI (wie test_gui_smoke) und deckt ab:
## 1. Start/Zurück-Zustand: Modus-Flags, Panels aus/an, Maus-Capture-Wunsch, ## 1. Start/Zurück-Zustand: Modus-Flags, Panels aus/an, Maus-Capture-Wunsch,
## Spieler lebt im world3d, Kamera übernimmt. ## Spieler = CharacterBody3D mit Kapsel 0.35 × 1.8 (TestmapPlayer-Params),
## 2. Bodenhöhe == Terrainhöhe an der Spielerposition (Body-Decoder == ## Kollider-Baum existiert (Terrain + Objekte), Kamera übernimmt.
## SvmapDocument-Decoder am selben Dokument). ## 2. Bodenhöhe == Terrainhöhe an der Spielerposition (Physik-Landung auf
## 3. Spieler bleibt vor einem Objekt-AABB stehen (Block-Kollision). ## dem Trimesh == SvmapDocument-Decoder am selben Dokument).
## 4. Rennen/Springen wirkt korrekt (step-Input-Dictionary). ## 3. Echte Wand blockt den Spieler (StaticBody in der Physik-Welt).
## 5. Rückkehr stellt den Editor-Zustand VOLLSTÄNDIG wieder her ## 4. Step-Up: Kiste 0.3 m wird erklommen, Hindernis 0.85 m blockt
## (Dokument byte-identisch, Undo-Stapel unverändert, Panels sichtbar). ## (Stufen-Semantik 1:1 wie TestmapPlayer, Karte t_f9adf97c).
## 5. Gehen 6 m/s / Rennen 14 m/s / Springen (Client-Geschwindigkeiten).
## 6. Rückkehr stellt den Editor-Zustand VOLLSTÄNDIG wieder her.
var failures := 0 var failures := 0
var checks := 0 var checks := 0
@ -20,6 +23,8 @@ var main: Control
var camera: Camera3D var camera: Camera3D
var subvp: SubViewport var subvp: SubViewport
const HALF_H := 0.9
func _initialize() -> void: func _initialize() -> void:
_run_all.call_deferred() _run_all.call_deferred()
@ -54,10 +59,11 @@ func _run_all() -> void:
await process_frame await process_frame
_test_start_state() _test_start_state()
_test_terrain_height_matches_document() await _test_terrain_height_matches_document()
_test_obstacle_blocks_player() await _test_wall_blocks_player()
_test_run_and_jump() await _test_step_up_semantics()
_test_obstacles_use_registry_footprint() await _test_run_and_jump()
await _test_bauteile_treppe_tuer()
_test_exit_restores_editor() _test_exit_restores_editor()
_finish() _finish()
@ -103,9 +109,22 @@ func _body() -> Node:
return pt.get("body") return pt.get("body")
## Statische Box in die Playtest-Physik-Welt hängen (wie im Spiel).
func _box(welt: Node3D, center: Vector3, size: Vector3) -> StaticBody3D:
var body := StaticBody3D.new()
var cs := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = size
cs.shape = shape
body.add_child(cs)
body.position = center
welt.add_child(body)
return body
## --- Test 1: Start/Zurück-Zustand ------------------------------------------- ## --- Test 1: Start/Zurück-Zustand -------------------------------------------
func _test_start_state() -> void: func _test_start_state() -> void:
print("Testgruppe: Start-Zustand (Modus-Flags, Panels, Maus, Kamera)") print("Testgruppe: Start-Zustand (Modus-Flags, Panels, Maus, Kamera, Kollider)")
main.call("_on_test_run") main.call("_on_test_run")
await process_frame await process_frame
@ -124,10 +143,28 @@ func _test_start_state() -> void:
_check(panel != null and not panel.visible, "%s ausgeblendet" % pair[1]) _check(panel != null and not panel.visible, "%s ausgeblendet" % pair[1])
# Spieler lebt in der 3D-Welt (world3d) und ist an den PlaytestMode gehängt. # Spieler lebt in der 3D-Welt (world3d) und ist an den PlaytestMode gehängt.
var body: Node3D = pt.get("body") var body: Node = pt.get("body")
_check(body != null and body.get_parent() == pt, "Body ist Kind des PlaytestMode") _check(body is CharacterBody3D, "Body ist ein CharacterBody3D (echte Physik)")
_check(body.get_parent() == pt, "Body ist Kind des PlaytestMode")
_check(pt.get_parent() == main.get("world3d"), "PlaytestMode lebt im world3d") _check(pt.get_parent() == main.get("world3d"), "PlaytestMode lebt im world3d")
# Kollider-Baum: Terrain + (leere) Objektliste — mindestens Terrain drin.
var kb: Node3D = pt.get("_kollider_root")
_check(kb != null and kb.get_child_count() >= 1,
"Kollider-Baum existiert (Terrain-Kollider drin, %d Kinder)" %
(kb.get_child_count() if kb != null else 0))
var tbody := kb.get_node_or_null("TerrainKollider") if kb != null else null
_check(tbody != null, "Terrain-Kollider (Trimesh wie im Client) vorhanden")
# Kapsel-Parameter 1:1 wie TestmapPlayer.
var capsule_ok := false
for child in body.get_children():
var cs := child as CollisionShape3D
if cs != null and cs.shape is CapsuleShape3D:
var cap := cs.shape as CapsuleShape3D
capsule_ok = is_equal_approx(cap.radius, 0.35) and is_equal_approx(cap.height, 1.8)
_check(capsule_ok, "Kapsel 0.35 × 1.8 m (identisch zum Testmap-Spieler)")
# Spiel-Kamera ist current, Editor-Kamera abgegeben. # Spiel-Kamera ist current, Editor-Kamera abgegeben.
var body_cam: Camera3D = body.get("look_cam") var body_cam: Camera3D = body.get("look_cam")
_check(body_cam != null and body_cam.current, "Spiel-Kamera ist current") _check(body_cam != null and body_cam.current, "Spiel-Kamera ist current")
@ -149,188 +186,153 @@ func _test_start_state() -> void:
"Start == erster Spawnpunkt (%.1f, %.1f)" % [expected.x, expected.z]) "Start == erster Spawnpunkt (%.1f, %.1f)" % [expected.x, expected.z])
# Kein History-Eintrag durch den Start (Dokument/Undo-Stapel unangetastet). # Kein History-Eintrag durch den Start (Dokument/Undo-Stapel unangetastet).
var hist: Node = main.get("history") var hist: Variant = main.get("history")
_check(not hist.can_undo(), "Kein Undo-Eintrag durch den Testlauf-Start") _check(not hist.can_undo(), "Kein Undo-Eintrag durch den Testlauf-Start")
## --- Test 2: Bodenhöhe == Terrainhöhe --------------------------------------- ## --- Test 2: Bodenhöhe == Terrainhöhe ---------------------------------------
func _test_terrain_height_matches_document() -> void: func _test_terrain_height_matches_document() -> void:
print("Testgruppe: Bodenhöhe == Terrainhöhe an der Spielerposition") print("Testgruppe: Bodenhöhe == Terrainhöhe (Physik-Landung auf dem Trimesh)")
var pt := _pt() var pt := _pt()
if pt == null: if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)") _check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)")
return return
var body: Node3D = pt.get("body") var body: Node = pt.get("body")
var doc: Dictionary = main.get("doc") var doc: Dictionary = main.get("doc")
# Schritt ausführen (Gravitation lässt den Spieler auf den Boden fallen). # Gravitation lässt den Spieler auf den Terrain-Trimesh fallen.
body.call("step", 1.0 / 30.0, {"fwd": false, "back": false, "left": false, for i in 45:
"right": false, "run": false, "jump": false}) await physics_frame
var feet_y: float = body.position.y var feet_y: float = body.fuss_hoehe()
var terrain_y: float = SvmapDocument.terrain_height_at(doc, body.position.x, body.position.z) var terrain_y: float = SvmapDocument.terrain_height_at(doc, body.position.x, body.position.z)
var body_terrain: float = body.call("terrain_height", body.position.x, body.position.z) var body_terrain: float = body.call("terrain_height", body.position.x, body.position.z)
_check(is_equal_approx(feet_y, terrain_y), _check(absf(feet_y - terrain_y) <= 0.05,
"Fusspunkt == Terrainhöhe am Spielerort (%.3f == %.3f)" % [feet_y, terrain_y]) "Fusspunkt == Terrainhöhe am Spielerort (%.3f ≈ %.3f)" % [feet_y, terrain_y])
_check(is_equal_approx(body_terrain, terrain_y), _check(is_equal_approx(body_terrain, terrain_y),
"Body-Decoder == SvmapDocument-Decoder (%.3f == %.3f)" % [body_terrain, terrain_y]) "Body-Decoder == SvmapDocument-Decoder (%.3f == %.3f)" % [body_terrain, terrain_y])
## --- Test 3: Objekt-AABB blockiert den Spieler ------------------------------ ## --- Test 3: Echte Wand blockiert den Spieler -------------------------------
func _test_obstacle_blocks_player() -> void: func _test_wall_blocks_player() -> void:
print("Testgruppe: Spieler bleibt vor einem Objekt-AABB stehen") print("Testgruppe: Echte Wand blockt den Spieler (StaticBody in der Physik-Welt)")
var pt := _pt() var pt := _pt()
if pt == null: if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)") _check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return return
var body: Node3D = pt.get("body") var body: Node = pt.get("body")
# Hindernis VOR dem Spieler (Blick -Z bei yaw 0): Wand spannt var welt: Node3D = main.get("world3d")
# z ∈ [start_z - 3.0, start_z - 2.4] — der Spieler kommt aus +Z und
# trifft zuerst die Face bei end.z (start_z - 2.4).
var start_x: float = body.position.x var start_x: float = body.position.x
var start_z: float = body.position.z var start_z: float = body.position.z
var wall := AABB(Vector3(body.position.x - 2.0, -5.0, start_z - 3.0), # Wand VOR dem Spieler: Fläche bei start_x + 2.4 (Lauf +x).
Vector3(4.0, 10.0, 0.6)) var wall := _box(welt, Vector3(start_x + 2.7, 0.0, start_z), Vector3(0.6, 10.0, 4.0))
body.get("obstacles").append(wall) body.set("yaw_deg", -90.0) # Blick nach +x (Konsistenz mit dem Lauf)
body.set("yaw_deg", 0.0) # Blick nach -Z body.set("auto_move_dir", Vector3(1, 0, 0))
for i in 90:
var ok := true await physics_frame
for i in 60: # 2 Sekunden laufen body.set("auto_move_dir", Vector3.ZERO)
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, for i in 10:
"right": false, "run": false, "jump": false}) await physics_frame
# Abstand: führende Kante des Spielers (z - radius) zur dem Spieler # Abstand: führende Kante des Spielers (x + radius) zur Wand-Fläche.
# zugewandten Wand-Face (end.z — er kommt aus +Z). >= 0 = davor geblieben. var dist_to_wall: float = (start_x + 2.4) - (float(body.position.x) + 0.35)
var dist_to_wall: float = (
float(body.position.z) - float(body.get("radius"))
- float(wall.end.z))
_check(dist_to_wall >= -0.05, _check(dist_to_wall >= -0.05,
"Spieler hält vor der Wand (Abstand %.2f m >= 0)" % dist_to_wall) "Spieler hält vor der Wand (Abstand %.2f m >= 0)" % dist_to_wall)
if dist_to_wall < -0.05: _check(absf(float(body.position.z) - start_z) < 0.01, "Kein Drift in Z")
ok = false _check(absf(float(body.position.x) - start_x) > 0.5, "Gegangen, nicht sofort blockiert")
# Kein Drift in X (kein Links/Rechts-Input → Position unverändert). wall.queue_free()
_check(absf(float(body.position.x) - start_x) < 0.01, "Kein Drift in X") for i in 5:
# Hindernis wieder entfernen (sauberer Zustand für die Folgetests). await physics_frame
body.get("obstacles").erase(wall)
## --- Test 3b: Hindernisse = echter Registry-Fußabdruck (Sly 20.10.) --------- ## --- Test 4: Step-Up-Semantik (1:1 TestmapPlayer) ---------------------------
## Die alte pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne func _test_step_up_semantics() -> void:
## (= „unsichtbare Wand“ weit vor dem Modell). Jetzt: collision.size × scale, print("Testgruppe: Step-Up — Kiste 0.3 m erklommen, Hindernis 0.85 m blockt")
## um rot_y_deg gedreht; Fallback Kugel-Näherung nur ohne Registry-Eintrag.
func _test_obstacles_use_registry_footprint() -> void:
print("Testgruppe: Hindernis-AABBs aus Registry-Fußabdruck")
var pt := _pt() var pt := _pt()
if pt == null: if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)") _check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return return
var body: Node = pt.get("body")
var welt: Node3D = main.get("world3d")
var sx: float = body.position.x
var sz: float = body.position.z
# Faken Viewport-Baukasten: 1 Marker mit obj_index + absurder Pick-Kugel. # a) Kiste 0.3 m (unter Step-Up-Grenze 0.45) → raufsteigen (Lauf +x).
var builder := Node3D.new() var kiste := _box(welt, Vector3(sx + 3.0, 0.15, sz), Vector3(1.0, 0.3, 2.0))
var marker := Node3D.new() body.set("yaw_deg", -90.0)
marker.set_meta("obj_index", 0) body.set("auto_move_dir", Vector3(1, 0, 0))
marker.set_meta("pick_radius", 20.3) var feet_max := -INF
marker.set_meta("half_height", 3.1) for i in 180:
marker.position = Vector3(0.0, 0.0, -5.0) await physics_frame
builder.add_child(marker) feet_max = maxf(feet_max, body.fuss_hoehe())
root.add_child(builder) body.set("auto_move_dir", Vector3.ZERO)
for i in 10:
await physics_frame
_check(feet_max >= 0.25, "Kiste 0.3 m erklommen (max feet %.2f)" % feet_max)
kiste.queue_free()
for i in 5:
await physics_frame
# Kaserne um 90° gedreht: lange Achse (35.5 m) liegt dann auf X. # b) Fass 0.85 m (über der Grenze) → blockt, kein Cheat-Klettern.
var doc: Dictionary = {"objects": [{ var fass := _box(welt, Vector3(sx + 3.0, 0.425, sz), Vector3(0.617, 0.85, 0.617))
"id": "barracks_001", "scale": 1.0, "rot_y_deg": 90.0, "pos": [0.0, 0.0, -5.0], body.set("auto_move_dir", Vector3(1, 0, 0))
}]} var feet_max2 := -INF
var obs: Array = pt.call("_collect_obstacles", doc, builder) for i in 180:
_check(obs.size() == 1, "Genau 1 Hindernis gesammelt (ist %d)" % obs.size()) await physics_frame
if obs.size() == 1: feet_max2 = maxf(feet_max2, body.fuss_hoehe())
var ab: AABB = obs[0] body.set("auto_move_dir", Vector3.ZERO)
_check(ab.size.x < 38.0, for i in 10:
"X-Ausdehnung unter Pick-Kugel-Blob (ist %.1f < 38)" % ab.size.x) await physics_frame
_check(absf(ab.size.x - 35.456) < 1.2, _check(feet_max2 < 0.55, "Hindernis 0.85 m blockt (max feet %.2f < 0.55)" % feet_max2)
"Gedrehte Länge auf X ≈ 35.5 m (ist %.2f)" % ab.size.x) fass.queue_free()
_check(absf(ab.size.z - 16.99) < 1.2, for i in 5:
"Gedrehte Breite auf Z ≈ 17.0 m (ist %.2f)" % ab.size.z) await physics_frame
_check(absf(ab.size.y - 6.247) < 0.5,
"Höhe ≈ 6.2 m (ist %.2f)" % ab.size.y)
_check(absf(ab.position.y - 0.0) < 0.01, "Fußpunkt an Marker-Y")
# Rotation 0: Länge wieder auf Z.
var doc0: Dictionary = {"objects": [{
"id": "barracks_001", "scale": 1.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
}]}
var obs0: Array = pt.call("_collect_obstacles", doc0, builder)
if obs0.size() == 1:
var ab0: AABB = obs0[0]
_check(absf(ab0.size.z - 35.456) < 1.2,
"Rotation 0: Länge auf Z ≈ 35.5 m (ist %.2f)" % ab0.size.z)
else:
_check(false, "Rotation-0-Fall liefert 1 Hindernis")
# Scale wird mitgenommen (2× → doppelte Grundfläche).
var doc2: Dictionary = {"objects": [{
"id": "barracks_001", "scale": 2.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
}]}
var obs2: Array = pt.call("_collect_obstacles", doc2, builder)
if obs2.size() == 1:
_check(absf((obs2[0] as AABB).size.z - 70.9) < 2.4,
"Scale 2× verdoppelt die Länge (ist %.1f)" % (obs2[0] as AABB).size.z)
else:
_check(false, "Scale-Fall liefert 1 Hindernis")
# Fallback: unbekannte OID → alte Kugel-Näherung bleibt.
var docx: Dictionary = {"objects": [{
"id": "gibts_nicht_999", "scale": 1.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
}]}
var obsx: Array = pt.call("_collect_obstacles", docx, builder)
if obsx.size() == 1:
var abx: AABB = obsx[0]
_check(absf(abx.size.x - 40.6) < 0.2,
"Fallback: Kugel-Näherung 2×pick_radius (ist %.1f)" % abx.size.x)
else:
_check(false, "Fallback-Fall liefert 1 Hindernis")
builder.queue_free()
## --- Test 4: Rennen/Springen ------------------------------------------------ ## --- Test 5: Gehen/Rennen/Springen (Client-Geschwindigkeiten) ---------------
func _test_run_and_jump() -> void: func _test_run_and_jump() -> void:
print("Testgruppe: Rennen/Springen wirkt korrekt") print("Testgruppe: Gehen 6 m/s, Rennen 14 m/s, Springen (Client-Parameter)")
var pt := _pt() var pt := _pt()
if pt == null: if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)") _check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return return
var body: Node3D = pt.get("body") var body: Node = pt.get("body")
# Gehen: 1 s bei 4 m/s → ~4 m Wegstrecke. # Gehen: 1 s bei 6 m/s → ~6 m Wegstrecke.
var start := Vector3(body.position) var start := Vector3(body.position)
for i in 30: body.set("auto_move_dir", Vector3(1, 0, 0))
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, for i in 60:
"right": false, "run": false, "jump": false}) await physics_frame
body.set("auto_move_dir", Vector3.ZERO)
var walk_dist: float = (Vector3(body.position) - start).length() var walk_dist: float = (Vector3(body.position) - start).length()
_check(walk_dist > 3.0 and walk_dist < 5.0, _check(walk_dist > 5.0 and walk_dist < 7.0,
"Gehen ≈ 4 m/s (gemessen %.2f m in 1 s)" % walk_dist) "Gehen ≈ 6 m/s (gemessen %.2f m in 1 s)" % walk_dist)
# Rennen: gleiche Zeit, Verdopplung → deutlich mehr Strecke. # Rennen: gleiche Zeit, deutlich mehr Strecke (14 m/s).
var start2 := Vector3(body.position) var start2 := Vector3(body.position)
for i in 30: body.set("auto_run", true)
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, body.set("auto_move_dir", Vector3(1, 0, 0))
"right": false, "run": true, "jump": false}) for i in 60:
await physics_frame
body.set("auto_move_dir", Vector3.ZERO)
body.set("auto_run", false)
var run_dist: float = (Vector3(body.position) - start2).length() var run_dist: float = (Vector3(body.position) - start2).length()
_check(run_dist > walk_dist * 1.5, _check(run_dist > 12.0 and run_dist < 16.0,
"Rennen deutlich schneller (%.2f m > %.2f m)" % [run_dist, walk_dist]) "Rennen ≈ 14 m/s (gemessen %.2f m in 1 s)" % run_dist)
# Springen: y-Impuls, Spieler verlässt den Boden und landet wieder. # Springen: y-Impuls, Spieler verlässt den Boden und landet wieder.
var y0: float = body.position.y var feet0: float = body.fuss_hoehe()
var airborne := false var airborne := false
for i in 60: body.set("auto_jump", true)
var state: Dictionary = body.call("step", 1.0 / 30.0, for i in 90:
{"fwd": false, "back": false, "left": false, "right": false, await physics_frame
"run": false, "jump": i < 2}) if body.fuss_hoehe() > feet0 + 0.2:
if not bool(state["on_floor"]):
airborne = true airborne = true
if i > 5 and bool(state["on_floor"]): if i > 5 and airborne and body.is_on_floor():
break break
body.set("auto_jump", false)
_check(airborne, "Sprung verlässt den Boden") _check(airborne, "Sprung verlässt den Boden")
_check(is_equal_approx(float(body.position.y), y0), "Landung wieder auf Terrainhöhe") _check(absf(body.fuss_hoehe() - feet0) < 0.05, "Landung wieder auf Terrainhöhe")
## --- Test 5: Rückkehr stellt den Editor-Zustand wieder her ------------------ ## --- Test 6: Rückkehr stellt den Editor-Zustand wieder her ------------------
func _test_exit_restores_editor() -> void: func _test_exit_restores_editor() -> void:
print("Testgruppe: Rückkehr in den Editor (Dokument + Undo unverändert)") print("Testgruppe: Rückkehr in den Editor (Dokument + Undo unverändert)")
var pt := _pt() var pt := _pt()
@ -361,6 +363,97 @@ func _test_exit_restores_editor() -> void:
_check(hist.can_undo() == undo_before and hist.can_redo() == redo_before, _check(hist.can_undo() == undo_before and hist.can_redo() == redo_before,
"Undo/Redo-Stapel unverändert") "Undo/Redo-Stapel unverändert")
_check(str(main.get("tool_mode")) == tool_before, "Werkzeug-Modus unverändert") _check(str(main.get("tool_mode")) == tool_before, "Werkzeug-Modus unverändert")
_check(not pt.wants_capture() if pt != null else true,
"Maus-Capture-Wunsch zurückgenommen")
_check(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "Maus wieder frei") _check(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "Maus wieder frei")
## --- Test 7: Bauteile (Bauen-Modus) — Treppe begehbar, Tür durchlässig ------
## Karte t_1da96275: auch die Modular-Builder-Pieces bekommen echte Trimesh-
## Kollider (statt AABB-Kisten) — Treppen hochlaufen, durch Türen gehen.
func _test_bauteile_treppe_tuer() -> void:
print("Testgruppe: Bauteile — Treppe hoch, durch die Tür (Trimesh-Kollider)")
var pt := _pt()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return
# Erst raus aus dem laufenden Testlauf, dann Map mit Bauteilen neu starten.
pt.call("handle_escape")
await process_frame
var build_ops := load("res://src/editor/build_ops.gd")
var doc: Dictionary = SvmapDocument.create_new("Bauteil-Karte", 100.0, "klar")
var t0: float = SvmapDocument.terrain_height_at(doc, 0.0, 0.0)
var treppe: Dictionary = build_ops.call("make_piece", "treppe", Vector3(4.0, t0, 0.0), 0.0)
var tuer: Dictionary = build_ops.call("make_piece", "tuer", Vector3(0.0, t0, 14.0), 0.0)
doc["gebaeude"] = [treppe, tuer]
main.set("doc", doc)
main.set("current_path", "")
main.set("dirty", false)
main.get("history").begin(doc)
main.call("_clear_edit_state")
main.call("_refresh_all")
await process_frame
main.call("_on_test_run")
await process_frame
var pt2 := _pt()
_check(pt2 != null and bool(pt2.get("active")), "Testlauf mit Bauteil-Map läuft")
if pt2 == null:
return
var body: Node = pt2.get("body")
var kb: Node3D = pt2.get("_kollider_root")
var bauteile := 0
for child in kb.get_children():
if str(child.name).begins_with("Bauteil_Kollider"):
bauteile += 1
_check(bauteile == 2, "2 Bauteil-Kollider gebaut (Treppe + Tür, ist %d)" % bauteile)
# a) Treppe (2 m lang, 1 m hoch, 10 Stufen à 0.1 m): hochlaufen.
# Erst VOR die Treppe stellen (Spawn liegt weit weg — Karte Mitte).
body.set("position", Vector3(0.0, t0 + PlaytestBody.HALF_H + 0.05, 0.0))
body.set("velocity", Vector3.ZERO)
for i in 20:
await physics_frame
var feet0: float = body.fuss_hoehe()
_check(absf(feet0 - t0) <= 0.05, "Start auf Terrainhöhe (%.3f ≈ %.3f)" % [feet0, t0])
body.set("auto_move_dir", Vector3(1, 0, 0))
var feet_max := -INF
for i in 240:
await physics_frame
feet_max = maxf(feet_max, body.fuss_hoehe())
body.set("auto_move_dir", Vector3.ZERO)
for i in 10:
await physics_frame
_check(feet_max >= 0.9,
"Treppe (1.0 m, Stufen à 0.1) erklommen (max feet %.2f)" % feet_max)
# b) Tür (Wand 2 m entlang x, Öffnung 1 × 2 m): durchgehen (Lauf +z).
var t0_2: float = body.call("terrain_height", 1.0, 16.0)
body.set("position", Vector3(1.0, t0_2 + PlaytestBody.HALF_H + 0.05, 16.0))
body.set("velocity", Vector3.ZERO)
body.set("auto_move_dir", Vector3(0, 0, -1))
for i in 150:
await physics_frame
body.set("auto_move_dir", Vector3.ZERO)
for i in 10:
await physics_frame
var durch: bool = body.position.z < 13.4
_check(durch, "Tür-Öffnung durchlässig (Ende z=%.2f < 13.4; Wandfläche bei 14)" %
body.position.z)
if not durch:
_check(false, "Tür-Block-Debug: pos=%s feet=%.2f" % [str(body.position), body.fuss_hoehe()])
# c) Aufräumen: zurück, dann ALTE Map für den Exit-Test wiederherstellen.
pt2.call("handle_escape")
await process_frame
var doc0: Dictionary = SvmapDocument.create_new("Testlauf-Karte", 100.0, "klar")
main.set("doc", doc0)
main.set("current_path", "")
main.set("dirty", false)
main.get("history").begin(doc0)
main.call("_clear_edit_state")
main.call("_refresh_all")
await process_frame
# Playtest auf der wiederhergestellten Karte neu starten — der Exit-Test
# braucht einen aktiven Lauf-Modus.
main.call("_on_test_run")
await process_frame
# Nachtrag (Bugfix): das obige Dateiende endete ohne Neustart — der Exit-Test
# braucht einen aktiven Lauf-Modus auf der wiederhergestellten Karte.

View file

@ -120,9 +120,6 @@ func _key_event(code: Key) -> InputEventKey:
return evt return evt
const NO_INPUT := {"fwd": false, "back": false, "left": false, "right": false,
"run": false, "jump": false}
## --- Test 1: Fliegen / God-Mode --------------------------------------------- ## --- Test 1: Fliegen / God-Mode ---------------------------------------------
func _test_fly_godmode() -> void: func _test_fly_godmode() -> void:
@ -138,61 +135,68 @@ func _test_fly_godmode() -> void:
pt.call("_unhandled_input", _key_event(KEY_G)) pt.call("_unhandled_input", _key_event(KEY_G))
_check(bool(body.get("fly_mode")), "Taste G schaltet Fliegen AN (Dispatch)") _check(bool(body.get("fly_mode")), "Taste G schaltet Fliegen AN (Dispatch)")
# Gravitation AUS: in der Luft schwebt der Spieler ohne Input. # Gravitation AUS: in der Luft schwebt der Spieler ohne Input (Physik-
# Frames, wie im echten Testlauf).
body.set("position", Vector3(body.position.x, 10.0, body.position.z)) body.set("position", Vector3(body.position.x, 10.0, body.position.z))
var y0: float = body.position.y var y0: float = body.position.y
for i in 30: for i in 30:
body.call("step", 1.0 / 30.0, NO_INPUT) await physics_frame
_check(absf(float(body.position.y) - y0) < 0.001, _check(absf(float(body.position.y) - y0) < 0.001,
"Keine Gravitation: Höhe hält (10 m, %.3f nach 1 s)" % float(body.position.y)) "Keine Gravitation: Höhe hält (10 m, %.3f nach 0,5 s)" % float(body.position.y))
# Noclip: Wand quer vor dem Spieler, Fliegen geht DURCH (kein Block). # Noclip: Fliegen geht durch alles (direkte Position, kein Kollider-Block).
var start := Vector3(body.position) var start := Vector3(body.position)
body.set("yaw_deg", 0.0) # Blick nach -Z body.set("yaw_deg", 0.0) # Blick nach -Z
body.get("obstacles").append(AABB(Vector3(start.x - 2.0, -5.0, start.z - 3.0), body.set("auto_move_dir", Vector3(0, 0, -1))
Vector3(4.0, 10.0, 0.6)))
for i in 60: for i in 60:
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, await physics_frame
"right": false, "run": false, "jump": false}) body.set("auto_move_dir", Vector3.ZERO)
var dist: float = start.distance_to(body.position) var dist: float = start.distance_to(body.position)
_check(dist > 3.5, "Fliegen geht durch die Wand (%.2f m Weg, Wand bei 2.4–3.0)" % dist) _check(dist > 3.5, "Fliegen noclip: 6 m Weg ohne Block (%.2f m)" % dist)
body.get("obstacles").clear()
# Bewegung in Blickrichtung: Blick 30° nach unten → Wegstrecke teilt sich # Bewegung in Blickrichtung: Blick 30° nach unten → Wegstrecke teilt sich
# in -z und -y (sin30 = 0.5, cos30 ≈ 0.866; 4 m in 1 s). # in -z und -y (sin30 = 0.5, cos30 ≈ 0.866; 6 m/s → 6 m in 1 s).
var start2 := Vector3(body.position) var blick: Vector3 = body.call("view_direction") if body.has_method("view_direction") \
else Vector3.ZERO
body.set("yaw_deg", 0.0) body.set("yaw_deg", 0.0)
body.set("pitch_deg", -30.0) body.set("pitch_deg", -30.0)
for i in 30: blick = body.call("view_direction")
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, _check(absf(blick.z + 0.866) < 0.01 and absf(blick.y + 0.5) < 0.01,
"right": false, "run": false, "jump": false}) "Blickrichtung-Vektor 30° unter Horizont (%.3f, %.3f)" % [blick.z, blick.y])
var start2 := Vector3(body.position)
body.set("auto_move_dir", blick)
for i in 60:
await physics_frame
body.set("auto_move_dir", Vector3.ZERO)
var moved: Vector3 = body.position - start2 var moved: Vector3 = body.position - start2
_check(absf(moved.z + 3.46) < 0.05 and absf(moved.y + 2.0) < 0.05, _check(absf(moved.z + 5.19) < 0.1 and absf(moved.y + 3.0) < 0.1,
"Blickrichtung inkl. Nickwinkel (Δz=%.2f, Δy=%.2f, Soll -3.46/-2.0)" % [moved.z, moved.y]) "Flug entlang der Blickrichtung (Δz=%.2f, Δy=%.2f, Soll -5.19/-3.0)" % [moved.z, moved.y])
# Space = steigen. # Space = steigen.
var y_up: float = body.position.y var y_up: float = body.position.y
for i in 15: body.set("auto_input", {"fwd": false, "back": false, "left": false,
body.call("step", 1.0 / 30.0, {"fwd": false, "back": false, "left": false,
"right": false, "run": false, "jump": true}) "right": false, "run": false, "jump": true})
for i in 15:
await physics_frame
body.set("auto_input", {})
_check(float(body.position.y) > y_up + 1.0, _check(float(body.position.y) > y_up + 1.0,
"Space steigt im Flug (+%.1f m)" % float(body.position.y - y_up)) "Space steigt im Flug (+%.1f m)" % float(body.position.y - y_up))
# Ausschalten: Gravitation wirkt wieder (Fällen zum Terrain). # Ausschalten: Gravitation wirkt wieder (Fällen zum Terrain-Trimesh).
pt.call("_unhandled_input", _key_event(KEY_G)) pt.call("_unhandled_input", _key_event(KEY_G))
_check(not bool(body.get("fly_mode")), "Zweites G schaltet Fliegen AUS") _check(not bool(body.get("fly_mode")), "Zweites G schaltet Fliegen AUS")
var y_fall: float = body.position.y var y_fall: float = body.position.y
var landed := false var landed := false
for i in 120: for i in 240:
var state: Dictionary = body.call("step", 1.0 / 30.0, NO_INPUT) await physics_frame
if bool(state["on_floor"]): if body.is_on_floor():
landed = true landed = true
break break
_check(landed and float(body.position.y) < y_fall, _check(landed and float(body.position.y) < y_fall,
"Fliegen aus: Spieler fällt und landet (y=%.2f)" % float(body.position.y)) "Fliegen aus: Spieler fällt und landet (y=%.2f)" % float(body.position.y))
var terrain_y: float = body.call("terrain_height", body.position.x, body.position.z) var terrain_y: float = body.call("terrain_height", body.position.x, body.position.z)
_check(is_equal_approx(float(body.position.y), terrain_y), _check(absf(float(body.fuss_hoehe()) - terrain_y) <= 0.05,
"Landung auf Terrainhöhe (%.3f == %.3f)" % [float(body.position.y), terrain_y]) "Landung auf Terrainhöhe (%.3f ≈ %.3f)" % [float(body.fuss_hoehe()), terrain_y])
## --- Test 2: Sprung zu den Spawnpunkten -------------------------------------- ## --- Test 2: Sprung zu den Spawnpunkten --------------------------------------
@ -216,7 +220,8 @@ func _test_spawn_jumps() -> void:
continue continue
var pos: Array = (pts[0] as Dictionary).get("pos", [0.0, 0.0, 0.0]) var pos: Array = (pts[0] as Dictionary).get("pos", [0.0, 0.0, 0.0])
var expected := Vector3(float(pos[0]), var expected := Vector3(float(pos[0]),
body.call("terrain_height", float(pos[0]), float(pos[2])), float(pos[2])) float(body.call("terrain_height", float(pos[0]), float(pos[2])))
+ PlaytestBody.HALF_H + 0.02, float(pos[2]))
# Vorher woanders hin (sichert, dass wirklich gesprungen wird). # Vorher woanders hin (sichert, dass wirklich gesprungen wird).
body.set("position", Vector3(99.0, 5.0, 99.0)) body.set("position", Vector3(99.0, 5.0, 99.0))
var ok := bool(pt.call("jump_to_spawn", idx)) var ok := bool(pt.call("jump_to_spawn", idx))

View file

@ -0,0 +1,164 @@
extends SceneTree
## Identitäts-Vertrag (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
## identisch“): dieselbe Probe-Map läuft hier im EDITOR-Kontext (KolliderBau
## + PlaytestBody im SubViewport — exakt der Playtest-Aufbau des Editors)
## und in tests/test_identitaet_client.gd im QUEST-CLIENT-Kontext
## (SvmapWorld + TestmapPlayer). Der Runner (tests/run_identitaet.sh)
## verlangt IDENTISCHE Entscheidungen.
##
## Jede Probe druckt eine Zeile `IDENT <name>=<wert>` — Werte sind
## grobe Entscheidungen (KLIMM/BLOCK/DRIN/DRAUSSEN) plus auf cm gerundete
## Höhen, damit beide Welten vergleichbar sind (gleiche Engine, gleiche
## Shapes → deterministisch gleich).
##
## Run: godot4 --headless --path . -s res://tests/test_playtest_identitaet.gd
## Map: $SVR_IDENTITAET_MAP (Default /tmp/svr_identitaet_probe.svmap,
## erzeugt von tests/gen_identitaet_map.gd).
const HALF_H := 0.9
const TUER_AZIMUT_DEG := 202.5 # Befund Karte t_f9adf97c (building_001, rot 0)
const GEB_TUER := Vector2(-16.0, -16.0) # building_001-Fußpunkt (x, z)
const GEB_HALF := Vector2(8.94, 3.54) # AABB-Halbmaße (x, z) rot 0
var fails: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _check(cond: bool, what: String) -> void:
if cond:
print("IDENT ok: ", what)
else:
fails.append(what)
print("IDENT FAIL: ", what)
func _run() -> void:
var map_path := OS.get_environment("SVR_IDENTITAET_MAP")
if map_path.is_empty():
map_path = "/tmp/svr_identitaet_probe.svmap"
if not FileAccess.file_exists(map_path):
printerr("IDENT FAIL: Probe-Map fehlt (%s) — erst gen_identitaet_map.gd laufen lassen." % map_path)
quit(1)
return
var doc_v: Variant = JSON.parse_string(FileAccess.get_file_as_string(map_path))
if not (doc_v is Dictionary):
printerr("IDENT FAIL: Probe-Map nicht lesbar.")
quit(1)
return
var doc: Dictionary = doc_v
# --- Editor-Playtest-Aufbau: SubViewport (own world) + KolliderBau + Body
var vp := SubViewport.new()
vp.own_world_3d = true
root.add_child(vp)
var welt := Node3D.new()
vp.add_child(welt)
welt.add_child(KolliderBau.baue_welt_kollider(doc))
var body: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new()
welt.add_child(body)
body.call("set_terrain", doc)
await physics_frame
await physics_frame
# 1) Stufe 0.3 m (Terrain) — erklommen.
var r := await _lauf(body, Vector3(-8.0, 0.0, 20.0), Vector3(1, 0, 0), 300)
var st30 := "BLOCK"
if r.max_feet >= 0.25 and r.end.x > 1.0:
st30 = "KLIMM"
_ident("stufe_terrain_030", "%s %.2f" % [st30, r.max_feet])
_check(st30 == "KLIMM", "Stufe 0.3 m erklommen (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
# 2) Hohes Hindernis 0.85 m (barrel_001, Box-Kollider) — blockt: über der
# Step-Up-Grenze 0.45 m gibt es KEIN Klettern (Karte t_f9adf97c).
r = await _lauf(body, Vector3(0.5, 0.0, 20.0), Vector3(1, 0, 0), 240)
var st80 := "KLIMM"
if r.end.x < 1.55 and r.max_feet < 0.55:
st80 = "BLOCK"
_ident("wand_barrel", "%s %.2f x=%.2f" % [st80, r.max_feet, r.end.x])
_check(st80 == "BLOCK", "Hindernis 0.85 m blockt (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
# 3) Kisten-Stufe 0.334 m (box_006) — erklommen (Step-Up über Objekt).
r = await _lauf(body, Vector3(-24.0, 0.0, 8.0), Vector3(1, 0, 0), 260)
var box := "BLOCK"
if r.max_feet >= 0.28:
box = "KLIMM"
_ident("kisten_stufe", "%s %.2f" % [box, r.max_feet])
_check(box == "KLIMM", "Kiste 0.334 m erklommen (max feet %.2f)" % r.max_feet)
# 4) Wand (barracks_001-Trimesh) — blockt.
r = await _lauf(body, Vector3(20.0, 0.0, 31.5), Vector3(0, 0, -1), 300)
var wand := "DURCH"
if r.end.z > 29.3:
wand = "BLOCK"
_ident("wand_barracks", "%s z=%.2f" % [wand, r.end.z])
_check(wand == "BLOCK", "Kaserne blockt (Ende z=%.2f, Fläche bei 29.73)" % r.end.z)
# 5) Tür (building_001) — durchlässig, Spieler landet IM Gebäude.
var outward := Vector3(cos(deg_to_rad(TUER_AZIMUT_DEG)), 0.0,
sin(deg_to_rad(TUER_AZIMUT_DEG)))
var start := Vector3(GEB_TUER.x + outward.x * 12.0, 0.0,
GEB_TUER.y + outward.z * 12.0)
r = await _lauf(body, start, -outward, 600)
var drin := absf(r.end.x - GEB_TUER.x) < GEB_HALF.x - 0.3 \
and absf(r.end.z - GEB_TUER.y) < GEB_HALF.y - 0.3
_ident("tuer_building", "%s %.2f %.2f" % ["DRIN" if drin else "DRAUSSEN", r.end.x, r.end.z])
_check(drin, "Tür building_001 durchlässig (Ende %.2f, %.2f — Gebäude x∈[%.1f,%.1f] z∈[%.1f,%.1f])"
% [r.end.x, r.end.z, GEB_TUER.x - GEB_HALF.x, GEB_TUER.x + GEB_HALF.x,
GEB_TUER.y - GEB_HALF.y, GEB_TUER.y + GEB_HALF.y])
# 6) Terrain-Höhe unter den Füßen (Strahl abwärts, exakt).
_hoehe(body, Vector3(-10.0, 10.0, 0.0), "terrain_hoehe_a", 0.0)
_hoehe(body, Vector3(4.0, 10.0, 20.0), "terrain_hoehe_b", 0.3)
_hoehe(body, Vector3(10.0, 12.0, -28.0), "terrain_hoehe_c", 1.1)
if fails.is_empty():
print("IDENT EDITOR: ALLE OK")
quit(0)
else:
print("IDENT EDITOR: FAILED %d" % fails.size())
quit(1)
func _ident(name: String, wert: String) -> void:
print("IDENT %s=%s" % [name, wert])
## Autopilot-Lauf — 1:1 zur Client-Seite: Start y=3.0 (Fall-Settle auf den
## Trimesh), dann Richtung fahren, max Füß-Höhe + Endposition messen.
func _lauf(body: CharacterBody3D, start_xz: Vector3, dir: Vector3,
frames: int) -> Dictionary:
body.auto_move_dir = Vector3.ZERO
body.velocity = Vector3.ZERO
body.global_position = Vector3(start_xz.x, 3.0, start_xz.z)
for i in 45:
await physics_frame
body.auto_move_dir = dir
var max_feet := -INF
for i in frames:
await physics_frame
max_feet = maxf(max_feet, body.fuss_hoehe())
if body.fuss_hoehe() < -5.0:
break # unter die Welt — nichts mehr zu holen
body.auto_move_dir = Vector3.ZERO
for i in 10:
await physics_frame
return {"end": Vector3(body.global_position), "max_feet": max_feet}
## Boden-Strahl an einem Punkt → IDENT-Zeile + Soll-Prüfung (±1 cm).
func _hoehe(body: CharacterBody3D, p: Vector3, name: String, soll: float) -> void:
var raum := body.get_world_3d().direct_space_state
var hit := raum.intersect_ray(PhysicsRayQueryParameters3D.create(
p, p + Vector3(0, -30, 0)))
var y := INF
if not hit.is_empty():
y = (hit["position"] as Vector3).y
if y == INF:
_ident(name, "KEIN_TREFFER")
_check(false, name + ": kein Boden-Treffer")
return
_ident(name, "%.2f" % y)
_check(absf(y - soll) <= 0.01, "%s: %.3f ≈ Soll %.2f" % [name, y, soll])

View file

@ -0,0 +1 @@
uid://bay4ioxemv0r2

View file

@ -9,7 +9,7 @@ extends SceneTree
## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm). ## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm).
## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte). ## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte).
## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren). ## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren).
## 5. T im Testlauf versetzt genau dorthin, Augenhöhe 1,70 m, Kollision ## 5. T im Testlauf versetzt genau dorthin, Kapsel-Physik (0.65 über Mitte), Kollision
## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben). ## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben).
## 6. Marker existiert nur bei gesetztem Punkt. ## 6. Marker existiert nur bei gesetztem Punkt.
## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf. ## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf.
@ -313,8 +313,8 @@ func _test_playtest_teleport() -> void:
body.set("yaw_deg", 0.0) # Blick nach -Z body.set("yaw_deg", 0.0) # Blick nach -Z
body.set("pitch_deg", -45.0) body.set("pitch_deg", -45.0)
body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera
var eye: Vector3 = body.position + Vector3(0.0, float(body.get("eye_height")), 0.0)
var cam: Camera3D = body.get("look_cam") var cam: Camera3D = body.get("look_cam")
var eye: Vector3 = cam.global_position # Kapsel-Mitte + EYE_HEIGHT (0.65)
var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0) var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0)
_check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)") _check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)")
var h0: float = body.call("terrain_height", eye.x, eye.z) var h0: float = body.call("terrain_height", eye.x, eye.z)
@ -322,14 +322,16 @@ func _test_playtest_teleport() -> void:
var expected := eye + dir * th var expected := eye + dir * th
main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung) main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung)
await process_frame await process_frame
await physics_frame
var np := body.position var np := body.position
_check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15, _check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15,
"Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [ "Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [
np.x, np.z, expected.x, expected.z]) np.x, np.z, expected.x, expected.z])
var ground_here: float = body.call("terrain_height", np.x, np.z) var ground_here: float = body.call("terrain_height", np.x, np.z)
_check(absf(np.y - ground_here) <= 0.01, _check(absf((np.y - PlaytestBody.HALF_H) - ground_here) <= 0.05,
"Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf(np.y - ground_here)) "Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf((np.y - PlaytestBody.HALF_H) - ground_here))
_check(absf(float(cam.position.y) - 1.7) < 0.001, "Augenhöhe 1,70 m (look_cam-Offset)") _check(absf(float(cam.position.y) - 0.65) < 0.001,
"Kamera-Offset 0.65 m über Kapsel-Mitte (identisch TestmapPlayer)")
var stored: Variant = main.get("test_start_point") var stored: Variant = main.get("test_start_point")
_check(stored != null and absf((stored as Vector3).x - np.x) < 0.01 _check(stored != null and absf((stored as Vector3).x - np.x) < 0.01
and absf((stored as Vector3).z - np.z) < 0.01, and absf((stored as Vector3).z - np.z) < 0.01,
@ -337,33 +339,50 @@ func _test_playtest_teleport() -> void:
var msg: String = (pt.get("hud_msg") as Label).text var msg: String = (pt.get("hud_msg") as Label).text
_check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg) _check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg)
# b) Kollision: hohe Wand vor dem Spieler, flacher Blick (−10°) zielt HINTER # b) Kollision: hohe Wand vor dem Spieler (echter StaticBody in der
# die Wand ins Terrain — der Wand-AABB-Treffer kommt zuerst → ABGELEHNT. # Physik-Welt), flacher Blick (−10°) trifft die Wand → ABGELEHNT
# (Wand ragt weit über die Augenhöhe am Ramp-Terrain: Augen ≈ 7,5 m dort.) # (Kapsel am Zielpunkt nicht frei — wie im Spiel).
var welt: Node3D = main.get("world3d")
var vor := Vector3(body.position) var vor := Vector3(body.position)
body.obstacles.append(AABB(Vector3(vor.x - 2.0, -5.0, vor.z - 4.0), Vector3(4.0, 20.0, 0.6))) var wand := StaticBody3D.new()
var wcs := CollisionShape3D.new()
var wshape := BoxShape3D.new()
wshape.size = Vector3(4.0, 20.0, 0.6)
wcs.shape = wshape
wand.add_child(wcs)
wand.position = Vector3(vor.x, 5.0, vor.z - 2.7)
welt.add_child(wand)
body.set("yaw_deg", 0.0) body.set("yaw_deg", 0.0)
body.set("pitch_deg", -10.0) body.set("pitch_deg", -10.0)
body.call("_apply_look") body.call("_apply_look")
main._unhandled_key_input(_press_key(KEY_T)) main._unhandled_key_input(_press_key(KEY_T))
await process_frame await process_frame
await physics_frame
_check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert") _check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert")
var blocked_msg: String = (pt.get("hud_msg") as Label).text var blocked_msg: String = (pt.get("hud_msg") as Label).text
_check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"), _check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"),
"Abgelehnt: Klartext im HUD: '%s'" % blocked_msg) "Abgelehnt: Klartext im HUD: '%s'" % blocked_msg)
body.obstacles.pop_back() wand.queue_free()
await physics_frame
# c) Kisten-Dach: begehbare Block-Decke → oben drauf landen (Boden DARONTER, # c) Kisten-Dach: echter Kisten-StaticBody → oben drauf landen (Boden
# nie im Objekt). Strahl von oben senkrecht auf die Kiste. # DARAUFD, nie im Objekt). Strahl von oben senkrecht auf die Kiste.
var crate := AABB(Vector3(np.x + 5.0, np.y, np.z), Vector3(1.0, 1.0, 1.0)) var cground: float = body.call("terrain_height", np.x + 5.0, np.z)
body.obstacles.append(crate) var kiste := StaticBody3D.new()
var ccenter := crate.get_center() var ccs := CollisionShape3D.new()
var cshape := BoxShape3D.new()
cshape.size = Vector3(1.0, 1.0, 1.0)
ccs.shape = cshape
kiste.add_child(ccs)
kiste.position = Vector3(np.x + 5.0, cground + 0.5, np.z)
welt.add_child(kiste)
var roof: Dictionary = body.call("teleport_to_ground", var roof: Dictionary = body.call("teleport_to_ground",
Vector3(ccenter.x, crate.end.y + 2.0, ccenter.z), Vector3(0.0, -1.0, 0.0)) Vector3(np.x + 5.0, cground + 3.0, np.z), Vector3(0.0, -1.0, 0.0))
_check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - crate.end.y) < 0.01, _check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - (cground + 1.0)) < 0.01,
"Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [ "Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [
float(roof.get("ground")), crate.end.y]) float(roof.get("ground")), cground + 1.0])
body.obstacles.pop_back() kiste.queue_free()
await physics_frame
# d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt). # d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt).
var doc_before := str(doc) var doc_before := str(doc)