diff --git a/src/editor/playtest/playtest_body.gd b/src/editor/playtest/playtest_body.gd index 89c8b3f..b8fa27e 100644 --- a/src/editor/playtest/playtest_body.gd +++ b/src/editor/playtest/playtest_body.gd @@ -1,59 +1,80 @@ -extends Node3D +extends CharacterBody3D class_name PlaytestBody -## Lauf-Modus Spieler (Karte t_1cdc1a72): erste Person, WASD + Maus, -## Shift = rennen, Space = springen, Gravitation. +## Lauf-Modus Spieler (Karte t_1cdc1a72, Physik-Neubau t_1da96275): +## erste Person, WASD + Maus, Shift = rennen, Space = springen, Gravitation. ## -## Kollision OHNE Physik-Welt (SubViewport ohne Physik-Tick): -## - Boden: Terrain-Raster des DOKUMENTS abtasten (SvmapDocument. -## terrain_height_at) — nichts neu berechnen. -## - Objekte/Bau-Teile: vorgecachte Welt-AABBs (playtest_mode.gd sammelt -## sie beim Start aus den Marker-Metas + BuildOps.piece_aabb) als -## einfache Blöcke. Blöcke mit Oberkante ≤ Schritt-Höhe sind begehbar -## (Dach wird Boden), alles was darüber ragt blockt horizontal. +## ECHTE PHYSIK — identisch zum Quest-3-Client (Sly-Forderung 20.10.: +## „Die Möglichkeiten des Map Editors sollen ABSOLUT identisch mit der +## Quest-3-Version sein“). Dieser Body ist ein CharacterBody3D mit EXAKT +## denselben Parametern wie client/scripts/world/testmap_player.gd +## (TestmapPlayer): Kapsel 0.35 × 1.8, WALK/SPRINT/JUMP/GRAVITY, +## Step-Up 0.45 m nach move_and_slide (RayCast-Kanten-Abtastung, 1:1 +## gespiegelt inkl. Decken-Guard). Die Kollider kommen von KolliderBau +## (Spiegel von svmap_world.gd) — dieselben Trimeshe wie im Spiel. ## -## Steuerung bewusst als reine step()-Methode: die echte _physics_process -## pollt nur die Tasten und ruft step() mit einem Input-Dictionary — -## die Suite (tests/test_playtest.gd) ruft step() mit simulierten Inputs -## und testet Mathe/Kollision OHNE echten Input. +## Der alte AABB-Blocking-Code (vorgecachte Kisten, eigener step()-Solver +## ohne Physik-Server) ist ENTFERNT — es gibt genau EINEN Bewegungscode, +## denselben wie auf der Quest. ## -## 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“). +## Steuerung: _physics_process pollt die Tasten (oder die Autopilot-Haken +## auto_move_dir/auto_jump/auto_run für Suiten und spätere Automatisierung) +## und fährt dieselbe Logik wie der Client. ESC wird NICHT hier gefangen: +## main.gd behandelt Esc VOR der normalen Esc-Kette, solange der Lauf-Modus +## aktiv ist („Esc = zurück zum Editor“). +## +## Koordinaten-Konvention (wie Client): Body-Ursprung = KAPSEL-MITTE, +## Kamera sitzt EYE_HEIGHT über der Mitte (TestmapPlayer.EYE_HEIGHT). signal exit_requested -const DEFAULT_MOVE_SPEED := 4.0 # m/s Gehen -const DEFAULT_RUN_MULT := 2.0 # Shift = rennen -const DEFAULT_JUMP_SPEED := 4.5 # m/s Sprungimpuls -const DEFAULT_GRAVITY := 12.0 # m/s² — Spiele-Wert, kein Physik-Server -const DEFAULT_EYE_HEIGHT := 1.7 # Kamera-Augenhöhe über dem Fußpunkt -const DEFAULT_STEP_HEIGHT := 0.45 # Stufen/Trittkanten, die begehbar sind -const DEFAULT_RADIUS := 0.35 # Spieler-Kolisionsradius (AABB-Aufblas) -const DEFAULT_BODY_HEIGHT := 1.85 # Kopf-Raum (Block-ab-Prüfung) -const DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung +# --- Parameter 1:1 aus client/scripts/world/testmap_player.gd --------------- +const WALK_SPEED := 6.0 +const SPRINT_SPEED := 14.0 +const JUMP_SPEED := 4.5 +const GRAVITY := 9.8 +const EYE_HEIGHT := 0.65 # Kamera über der Kapsel-Mitte (Kapsel 1.8 m) +const KILL_Y_DROP_M := -200.0 # Notfall: unter die Welt gefallen → Respawn +const KAPSEL_RADIUS := 0.35 +const KAPSEL_HEIGHT := 1.8 +const HALF_H := 0.9 # Kapsel-Halbhöhe (Kapsel 1.8 m) — Füße = y − 0.9 +const STEP_HEIGHT := 0.45 # = TestmapPlayer.STEP_HEIGHT (Karte t_f9adf97c) +const STEP_PROBE_DIST := 0.7 +const STEP_LIFT_EPS := 0.02 -const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze +# --- Editor-seitige Konventionen (unverändert gegenüber dem alten Body) ----- +const DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung +const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze +const TELEPORT_REICHWEITE := 4000.0 -## Spielbare Parameter (PlaytestMode setzt die Defaults; Suite kann nachsetzen) -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 +## Spielbare Parameter 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 = {} ## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung -## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Kollision aus -## (Noclip — der Tester kommt auch über Wände/Hügel schnell ans Ziel). +## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Noclip — der Tester +## kommt auch über Wände/Hügel schnell ans Ziel. var fly_mode := false -## Vorgecachte Hindernis-AABBs (Weltkoordinaten) — von PlaytestMode gefüllt -var obstacles: Array = [] # Array[AABB] +## Autopilot-Haken (wie TestmapPlayer.auto_move_dir, Karte t_f9adf97c): +## nicht-leer = DIESER Welt-Vektor ist der Bewegungswunsch (Tastatur aus). +var auto_move_dir := Vector3.ZERO +## Autopilot-Ergänzung für Suiten: Sprung/Run erzwingen ohne Tastatur. +var auto_jump := false +var auto_run := false +## Suite-Hook: nicht-leer = komplettes Input-Dictionary überschreibt ALLES +## (gleiche Form wie der alte step()-Input: fwd/back/left/right/run/jump). +var auto_input: Dictionary = {} + +## Bewegungszustand (Kompatibilität zur alten Oberfläche) +var yaw_deg: float = 0.0 # Gierwinkel Blickrichtung (Grad) +var pitch_deg: float = 0.0 # Nickwinkel Blickrichtung (Grad) +var on_floor: bool = false + +## Kinder: die Spiel-Kamera (Augen) — PlaytestMode hängt sie an und macht current +var look_cam: Camera3D + ## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain() -## gefüllt, 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 res: int = 0 var size_x: float = 0.0 @@ -61,22 +82,23 @@ var size_z: float = 0.0 var hmin: float = 0.0 var hmax: float = 0.0 -## Bewegungszustand -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 +var _spawn_pos := Vector3.ZERO func _ready() -> void: + _spawn_pos = global_position + var cs := CollisionShape3D.new() + var capsule := CapsuleShape3D.new() + capsule.radius = KAPSEL_RADIUS + capsule.height = KAPSEL_HEIGHT + cs.shape = capsule + add_child(cs) look_cam = Camera3D.new() - look_cam.near = 0.1 - look_cam.far = 6000.0 - look_cam.current = true + look_cam.name = "Camera" + look_cam.position.y = EYE_HEIGHT + look_cam.far = 4000.0 add_child(look_cam) + look_cam.current = true _apply_look() @@ -85,7 +107,12 @@ func _apply_look() -> void: if look_cam == null: return 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) @@ -98,110 +125,192 @@ func view_direction() -> Vector3: -cos(yaw_rad) * cos(pitch_rad)) -## Eine Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen. -## input = {fwd: bool, back: bool, left: bool, right: bool, run: bool, -## jump: bool} — die echte _physics_process baut das aus den -## Tasten, die Suite aus simulierten Flags. delta in Sekunden. -## Rückgabe: {pos, vel, on_floor} nach dem Schritt. -func step(delta: float, input: Dictionary) -> Dictionary: - var fwd: bool = bool(input.get("fwd", false)) - var back: bool = bool(input.get("back", false)) - var left: bool = bool(input.get("left", false)) - var right: bool = bool(input.get("right", false)) - var run: bool = bool(input.get("run", false)) - var jump: bool = bool(input.get("jump", false)) +## Echter Physics-Step: Input zusammenbauen (Tasten ODER Autopilot) → fahren. +func _physics_process(delta: float) -> void: + var input := {} + if not auto_input.is_empty(): + input = auto_input + else: + input = { + "fwd": Input.is_key_pressed(KEY_W), + "back": Input.is_key_pressed(KEY_S), + "left": Input.is_key_pressed(KEY_A), + "right": Input.is_key_pressed(KEY_D), + "run": Input.is_key_pressed(KEY_SHIFT) or auto_run, + "jump": Input.is_key_pressed(KEY_SPACE) or auto_jump, + } + if auto_move_dir.length_squared() > 0.0: + input["auto_dir"] = auto_move_dir + _fahren(delta, input) - 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) ----------------------- - # Keine Gravitation, Bewegung in der VOLLEN Blickrichtung (Blick nach - # unten = abwärts fliegen), Space = zusätzlich steigen, Kollision AUS - # (Noclip — schnell über die Map, auch über Wände/Hügel hinweg). +## Ein Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen. +## input = {fwd, back, left, right, run, jump: bool, auto_dir: Vector3}. +## DIESE Methode ist 1:1 die _physics_process des TestmapPlayers — +## identische Bewegungsmathe, identische Step-Up-Aufrufreihenfolge. +func _fahren(delta: float, input: Dictionary) -> void: + # --- Fliegen / God-Mode (Karte t_aaff5dcc, Taste G) ---------------------- + # Keine Gravitation, Bewegung in der VOLLEN Blickrichtung, Space = steigen, + # Noclip (direkt positionieren — kein move_and_slide, kein Kollider). if fly_mode: - var fly_speed := 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_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad)) - var wish3 := view_direction() * move.y + fly_right * move.x - if jump: - wish3 += Vector3.UP - if wish3.length_squared() > 0.0: - wish3 = wish3.normalized() - velocity = wish3 * fly_speed + var wish3: Vector3 + var ad: Variant = input.get("auto_dir", null) + if ad is Vector3 and (ad as Vector3).length_squared() > 0.0: + wish3 = (ad as Vector3).normalized() * fly_speed + else: + wish3 = view_direction() * (1.0 if bool(input.get("fwd", false)) else 0.0) \ + - view_direction() * (1.0 if bool(input.get("back", false)) else 0.0) \ + + fly_right * (1.0 if bool(input.get("right", false)) else 0.0) \ + - fly_right * (1.0 if bool(input.get("left", false)) else 0.0) + if bool(input.get("jump", false)): + wish3 += Vector3.UP + if wish3.length_squared() > 0.0: + wish3 = wish3.normalized() * fly_speed + velocity = wish3 position += velocity * delta on_floor = false _apply_look() - return {"pos": position, "vel": velocity, "on_floor": on_floor} + return - # Welt-Richtung aus dem Blickwinkel (y-only, keine Sprung-Schräglage) - var yaw_rad := deg_to_rad(yaw_deg) - var forward := Vector3(-sin(yaw_rad), 0.0, -cos(yaw_rad)) - var right_v := Vector3(cos(yaw_rad), 0.0, -sin(yaw_rad)) - var wish := (forward * move.y + right_v * move.x) * speed - - # Horizontal ist snappy (kein Anlauf-Trägheit-Momentum): direkt setzen. - 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 + # Welt-Richtung aus dem Blickwinkel (y-only, wie Client). + var forward := -transform.basis.z + var right := transform.basis.x + var dir := Vector3.ZERO + var ad2: Variant = input.get("auto_dir", null) + if ad2 is Vector3 and (ad2 as Vector3).length_squared() > 0.0: + dir = ad2 as Vector3 else: - velocity.y = velocity.y - gravity * delta - - var was_floor := on_floor - - # --- Horizontal X/Z SEPARAT auflösen (Wand-Sliding) --- - var next := position - var nx := next.x + velocity.x * delta - if not _blocked_at(nx, next.z, next.y): - next.x = nx - else: - velocity.x = 0.0 - var nz := next.z + velocity.z * delta - if not _blocked_at(next.x, nz, next.y): - next.z = nz - else: - velocity.z = 0.0 - - # --- Vertikal: Boden finden (Terrain + begehbare Block-Decken) --- - var ground := _ground_height_at(next.x, next.z, next.y) - var ny := next.y + velocity.y * delta - if velocity.y <= 0.0: - # Fallen oder am Boden: Bodenhöhe greifen (auch beim Bergab-Gehen, - # 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 + if bool(input.get("fwd", false)): + dir += forward + if bool(input.get("back", false)): + dir -= forward + if bool(input.get("right", false)): + dir += right + if bool(input.get("left", false)): + dir -= right + var speed := SPRINT_SPEED if bool(input.get("run", false)) else WALK_SPEED + if dir.length_squared() > 0.0: + dir = dir.normalized() * speed + velocity.x = dir.x + velocity.z = dir.z + if is_on_floor() and bool(input.get("jump", false)): + velocity.y = JUMP_SPEED + if not is_on_floor(): + velocity.y -= GRAVITY * delta + var pre_pos := global_position + move_and_slide() + # Stufen-Hilfe (Karte t_f9adf97c, 1:1 aus TestmapPlayer): nach + # move_and_slide() prüfen, ob die horizontale Bewegung von einer Wand + # gestoppt wurde, während der Wunsch weiter in die Wand zeigt. + var wish_xz := Vector3(dir.x, 0.0, dir.z) + var blocked := false + if wish_xz.length_squared() > 0.0001: + if is_on_wall() and get_wall_normal().dot(wish_xz.normalized()) < -0.1: + blocked = true else: - on_floor = false - else: - # Sprung-Phase: kein Boden-Greifen; auf_floaten lassen - on_floor = false - next.y = ny - - position = next + var got := global_position - pre_pos + got.y = 0.0 + var want := wish_xz * delta + if got.length() < want.length() * 0.4: + blocked = true + if blocked: + try_step_up(wish_xz) + if global_position.y < KILL_Y_DROP_M: + # Sicherheitsnetz (sollte mit Trimesh-Terrain nicht passieren). + global_position = _spawn_pos + velocity = Vector3.ZERO + on_floor = is_on_floor() _apply_look() - 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 @@ -224,7 +333,9 @@ func request_exit() -> void: ## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 → ## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe -## Code wie beim Editor-Rendering) — nichts neu rechnen. +## Code wie beim Editor-Rendering) — nichts neu rechnen. Die PHYSIK läuft +## über den Trimesh-Kollider (KolliderBau.terrain_form, identisch zum +## Client); dieses Raster dient Spawn/jump_to_spawn/HUD-Referenz. func set_terrain(d: Dictionary) -> void: doc = d heights = PackedFloat32Array() @@ -249,116 +360,55 @@ func set_terrain(d: Dictionary) -> void: 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 -## Testlauf): Strahl aus dem Auge → erster Treffer auf Terrain ODER Hindernis- -## AABB; gelandet wird auf dem Boden DARUNTER (Terrain + begehbare Block-Decken -## bis zum Ziel-Höhenpunkt). Nie im Objekt landen: ist der Zielpunkt von einem -## BLOCKIERENDEN Hindernis belegt, wird der Teleport abgelehnt (ok=false). -## Rückgabe: {ok, x, z, ground, msg} — msg im Klartext für HUD/Statuszeile. +## Testlauf): Strahl aus dem Auge gegen die ECHTE Physik-Welt (Trimesh- +## Terrain + Objekt-/Bauteil-Kollider — dieselben Flächen wie im Spiel). +## Gelandet wird auf dem Boden DARUNTER (Strahl senkrecht abwärts). Nie im +## Objekt landen: ist die Kapsel am Zielpunkt nicht frei, wird der Teleport +## abgelehnt (ok=false). Rückgabe: {ok, x, z, ground, msg}. func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary: - # Terrain-Treffer (derselbe Decoder wie im Editor-Werkzeug). - var t_hit: Variant = TerrainField.raycast(heights, res, size_x, size_z, origin, dir) - var t_dist := INF - if t_hit != null: - 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 space := get_world_3d().direct_space_state + if space == null: + return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, + "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."} var d := dir if d.length_squared() > 0.000000001: d = d.normalized() - for ab in obstacles: - var box: AABB = ab - var t := TestStart.ray_aabb(origin, d, box) - if t >= 0.0 and t < o_dist: - 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: + var q := PhysicsRayQueryParameters3D.create(origin, origin + d * TELEPORT_REICHWEITE) + q.exclude = [get_rid()] + var hit := space.intersect_ray(q) + if hit.is_empty(): return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."} - # Boden unter dem Zielpunkt: Terrain + begehbare Block-Decken bis zum - # Ziel-Höhenpunkt (Ziel auf einem Kisten-Dach = dort oben landen). - var ground := terrain_height(tx, tz) - var ceiling := maxf(hit_y, ground) + step_height - for ab in obstacles: - var box: AABB = ab - if box.end.y > ceiling or box.end.y <= ground: - continue - if tx < box.position.x or tx > box.end.x or tz < box.position.z or tz > box.end.z: - continue - ground = box.end.y - if _blocked_at(tx, tz, ground): + var ziel := hit["position"] as Vector3 + # Boden DARUNTER: senkrechter Strahl ab etwas über dem Treffer — trifft + # er nichts (Treffer war eine Wandfläche ohne Boden drunter), Terrain- + # Raster als Rückfallebene. + var ground := ziel.y + var q2 := PhysicsRayQueryParameters3D.create( + Vector3(ziel.x, ziel.y + 0.5, ziel.z), + Vector3(ziel.x, ziel.y - 50.0, ziel.z)) + q2.exclude = [get_rid()] + var hit2 := space.intersect_ray(q2) + if hit2.is_empty(): + ground = terrain_height(ziel.x, ziel.z) + else: + ground = (hit2["position"] as Vector3).y + var tx := ziel.x + var tz := ziel.z + # Landeplatz prüfen: passt die Kapsel dort frei? (Nie im Objekt landen.) + var probe := CapsuleShape3D.new() + probe.radius = KAPSEL_RADIUS + probe.height = KAPSEL_HEIGHT + var qp := PhysicsShapeQueryParameters3D.new() + qp.shape = probe + qp.transform = Transform3D(Basis.IDENTITY, + Vector3(tx, ground + HALF_H + 0.02, tz)) + qp.exclude = [get_rid()] + if not space.intersect_shape(qp, 1).is_empty(): return {"ok": false, "x": tx, "z": tz, "ground": ground, "msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]} - position = Vector3(tx, ground, tz) + position = Vector3(tx, ground + HALF_H + 0.02, tz) velocity = Vector3.ZERO on_floor = true _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). +## Referenz für Spawn/jump_to_spawn/HUD; die Physik nutzt den Trimesh. func terrain_height(x: float, z: float) -> float: if res < 2 or size_x <= 0.0 or size_z <= 0.0: return hmin diff --git a/src/editor/playtest/playtest_mode.gd b/src/editor/playtest/playtest_mode.gd index b89e93c..ad620a3 100644 --- a/src/editor/playtest/playtest_mode.gd +++ b/src/editor/playtest/playtest_mode.gd @@ -6,9 +6,14 @@ extends Node ## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen) ## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input ## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden. -## - PlaytestBody (src/editor/playtest/playtest_body.gd) anlegen, Terrain- -## Raster EINMAL dekodieren, Start = erster Spawnpunkt (Team Blau), -## sonst Kartenmitte. +## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel +## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider +## (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 ## (Panels, Maus, Kamera, Auswahl, Dokument, Undo-Stapel unverändert). ## - 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 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 +## 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 ## 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.call("set_terrain", doc) - body.obstacles = _collect_obstacles(doc, view_builder) var start := _spawn_start(doc) if start_override != null: var sp := start_override as Vector3 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) # Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der # gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis. @@ -93,6 +109,9 @@ func exit() -> void: if body != null: body.queue_free() body = null + if _kollider_root != null: + _kollider_root.queue_free() + _kollider_root = null if hud != null: hud.queue_free() hud = null @@ -144,83 +163,36 @@ func _spawn_start(doc: Dictionary) -> Vector3: (float(mn[2]) + float(mx[2])) * 0.5) -## Hindernis-AABBs sammeln (nur LESEND, keine neue Physik-Welt): -## - Objekt-Marker: der ECHTE Fußabdruck aus der Registry (collision.size × -## scale, um rot_y_deg konservativ gedreht — Sly-Befund 20.10.: die alte -## pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne = -## „unsichtbare Wand“ weit vor dem Modell). -## - Bau-Teile: exakt über BuildOps.piece_aabb (Dokument-Teile → AABB). -## - Fallback: Objekte ohne Registry-Eintrag behalten die alte Kugel-Näherung. -func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array: - var out: Array = [] - var lib := _lade_objekt_registry() - if view_builder != null: - for child in view_builder.get_children(): - 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))) +## Echter Kollider-Bau (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3 +## identisch"): der alte AABB-Blocking-Code ist ENTFERNT. Es gibt genau EINEN +## Kollider-Pfad, denselben wie der Quest-Client: +## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel +## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl. +## library/kollisions-Quelldateien). +## - Bauteile (gebaeude): Trimesh aus dem gebackten Piece-Mesh — Türen +## bleiben Durchgänge, Treppen sind begehbar (wie die sichtbare Geometrie). +func _baue_kollider(doc: Dictionary) -> void: + var kb := load(KOLLIDER_SCRIPT) + _kollider_root = kb.call("baue_welt_kollider", doc) + add_child(_kollider_root) var build_ops := load(BUILD_OPS_SCRIPT) - if build_ops != null: - for teil in doc.get("gebaeude", []): - if teil is Dictionary: - var ab: AABB = build_ops.call("piece_aabb", teil) - if ab.size.x > 0.0 or ab.size.y > 0.0 or ab.size.z > 0.0: - out.append(ab) - return out - - -## objects.json einmal je Sammel-Lauf laden (klein genug, Playtest startet -## selten) — Key „objects“ → Dictionary oid → Entry. -func _lade_objekt_registry() -> Dictionary: - var parsed: Variant = JSON.parse_string( - FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH)) - if parsed is Dictionary and (parsed as Dictionary).has("objects"): - return (parsed as Dictionary)["objects"] - 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)) + if build_ops == null: + return + for teil in doc.get("gebaeude", []): + if not (teil is Dictionary): + continue + var mesh: Mesh = build_ops.call("piece_mesh", teil) + if mesh == null: + continue + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var pb := StaticBody3D.new() + pb.name = "Bauteil_Kollider_%d" % _kollider_root.get_child_count() + var cs := CollisionShape3D.new() + cs.shape = mesh.create_trimesh_shape() + pb.add_child(cs) + pb.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + pb.rotation.y = deg_to_rad(float(teil.get("rot", 0.0))) + _kollider_root.add_child(pb) # ------------------------------------------------------------------- HUD ---- @@ -288,8 +260,11 @@ func _process(delta: float) -> void: if hud_pos != null: hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z] 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" % [ - 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: hud_fps.text = "FPS: %d" % int(round(_fps_smooth)) @@ -395,7 +370,9 @@ func jump_to_spawn(index: int) -> bool: return false var target: Vector3 = sp["pos"] 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("on_floor", true) show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [ diff --git a/src/svmap/kollider_bau.gd b/src/svmap/kollider_bau.gd new file mode 100644 index 0000000..816a325 --- /dev/null +++ b/src/svmap/kollider_bau.gd @@ -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/.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 diff --git a/src/svmap/kollider_bau.gd.uid b/src/svmap/kollider_bau.gd.uid new file mode 100644 index 0000000..d7b9df3 --- /dev/null +++ b/src/svmap/kollider_bau.gd.uid @@ -0,0 +1 @@ +uid://cncg002d4e5x7 diff --git a/tests/gen_identitaet_map.gd b/tests/gen_identitaet_map.gd new file mode 100644 index 0000000..afa5bda --- /dev/null +++ b/tests/gen_identitaet_map.gd @@ -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) diff --git a/tests/gen_identitaet_map.gd.uid b/tests/gen_identitaet_map.gd.uid new file mode 100644 index 0000000..5b8940a --- /dev/null +++ b/tests/gen_identitaet_map.gd.uid @@ -0,0 +1 @@ +uid://b83poyvxdh3cv diff --git a/tests/probe_buttons_pos.gd.uid b/tests/probe_buttons_pos.gd.uid new file mode 100644 index 0000000..1909511 --- /dev/null +++ b/tests/probe_buttons_pos.gd.uid @@ -0,0 +1 @@ +uid://dgsj8nupg3hc4 diff --git a/tests/probe_dialog_min.gd.uid b/tests/probe_dialog_min.gd.uid new file mode 100644 index 0000000..09d7ccd --- /dev/null +++ b/tests/probe_dialog_min.gd.uid @@ -0,0 +1 @@ +uid://baoakwtc0wtvc diff --git a/tests/probe_physik_subviewport.gd b/tests/probe_physik_subviewport.gd new file mode 100644 index 0000000..8cbfe9b --- /dev/null +++ b/tests/probe_physik_subviewport.gd @@ -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) diff --git a/tests/probe_physik_subviewport.gd.uid b/tests/probe_physik_subviewport.gd.uid new file mode 100644 index 0000000..03053ee --- /dev/null +++ b/tests/probe_physik_subviewport.gd.uid @@ -0,0 +1 @@ +uid://5fc1sxkkbxr8 diff --git a/tests/probe_playtest_fps.gd b/tests/probe_playtest_fps.gd new file mode 100644 index 0000000..cd8a9f5 --- /dev/null +++ b/tests/probe_playtest_fps.gd @@ -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} diff --git a/tests/probe_playtest_fps.gd.uid b/tests/probe_playtest_fps.gd.uid new file mode 100644 index 0000000..3431d7d --- /dev/null +++ b/tests/probe_playtest_fps.gd.uid @@ -0,0 +1 @@ +uid://bnyeqe2tyxj58 diff --git a/tests/probe_playtest_render.gd b/tests/probe_playtest_render.gd index 98190bb..be3dcff 100644 --- a/tests/probe_playtest_render.gd +++ b/tests/probe_playtest_render.gd @@ -77,7 +77,7 @@ func _run() -> void: print("ZAHLEN (a) Spawn: body.position=", body.position, " | Terrainhöhe dort=", body.terrain_height(0.0, 0.0)) 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 editor_cam: Camera3D = main.get("camera") print("ZAHLEN (a) Spiel-Kamera current=", look_cam.current, @@ -93,16 +93,17 @@ func _run() -> void: " rechts=", main.get("ui_right_panel").visible) 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 + body.set("auto_move_dir", Vector3(0, 0, -1)) for i in 60: - body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, - "right": false, "run": false, "jump": false}) + await physics_frame + body.set("auto_move_dir", Vector3.ZERO) 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)") - print("ZAHLEN (b) on_floor=", body.get("on_floor"), - " y-nach=", pos_nach.y, + print("ZAHLEN (b) on_floor=", body.is_on_floor(), + " feet=", body.fuss_hoehe(), " Terrainhöhe dort=", body.terrain_height(pos_nach.x, pos_nach.z)) await _shot("t1_b_spieler_gelaufen.png") diff --git a/tests/probe_resize_shot.gd.uid b/tests/probe_resize_shot.gd.uid new file mode 100644 index 0000000..e2c582f --- /dev/null +++ b/tests/probe_resize_shot.gd.uid @@ -0,0 +1 @@ +uid://j6ly4d03x0n2 diff --git a/tests/probe_resize_size.gd.uid b/tests/probe_resize_size.gd.uid new file mode 100644 index 0000000..c25bad6 --- /dev/null +++ b/tests/probe_resize_size.gd.uid @@ -0,0 +1 @@ +uid://f0nwxnrohk4a diff --git a/tests/probe_update_check.gd.uid b/tests/probe_update_check.gd.uid new file mode 100644 index 0000000..299b067 --- /dev/null +++ b/tests/probe_update_check.gd.uid @@ -0,0 +1 @@ +uid://cntoenc8jaapy diff --git a/tests/run_identitaet.sh b/tests/run_identitaet.sh new file mode 100755 index 0000000..aefc5d5 --- /dev/null +++ b/tests/run_identitaet.sh @@ -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 =`-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= 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 diff --git a/tests/test_identitaet_client.gd b/tests/test_identitaet_client.gd new file mode 100644 index 0000000..bf9920a --- /dev/null +++ b/tests/test_identitaet_client.gd @@ -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 -s /tests/test_identitaet_client.gd +## Druckt dieselben `IDENT =`-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]) diff --git a/tests/test_identitaet_client.gd.uid b/tests/test_identitaet_client.gd.uid new file mode 100644 index 0000000..83db47f --- /dev/null +++ b/tests/test_identitaet_client.gd.uid @@ -0,0 +1 @@ +uid://ddt6sblpqgh4m diff --git a/tests/test_playtest.gd b/tests/test_playtest.gd index 2fa8ffb..2b909ff 100644 --- a/tests/test_playtest.gd +++ b/tests/test_playtest.gd @@ -1,18 +1,21 @@ extends SceneTree -## Editor-Test (headless): Karte t_1cdc1a72 — Lauf-Modus IM Map-Builder selbst -## (erste Person, WASD + Maus, OHNE Client-Ordner/VR/Server). -## Ausführen: godot4 --headless --path . --script res://tests/test_playtest.gd +## Editor-Test (headless): Karte t_1cdc1a72 + Physik-Neubau t_1da96275 — +## Lauf-Modus IM Map-Builder selbst mit ECHTER Physik (CharacterBody3D + +## 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. ## ## 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, -## Spieler lebt im world3d, Kamera übernimmt. -## 2. Bodenhöhe == Terrainhöhe an der Spielerposition (Body-Decoder == -## SvmapDocument-Decoder am selben Dokument). -## 3. Spieler bleibt vor einem Objekt-AABB stehen (Block-Kollision). -## 4. Rennen/Springen wirkt korrekt (step-Input-Dictionary). -## 5. Rückkehr stellt den Editor-Zustand VOLLSTÄNDIG wieder her -## (Dokument byte-identisch, Undo-Stapel unverändert, Panels sichtbar). +## Spieler = CharacterBody3D mit Kapsel 0.35 × 1.8 (TestmapPlayer-Params), +## Kollider-Baum existiert (Terrain + Objekte), Kamera übernimmt. +## 2. Bodenhöhe == Terrainhöhe an der Spielerposition (Physik-Landung auf +## dem Trimesh == SvmapDocument-Decoder am selben Dokument). +## 3. Echte Wand blockt den Spieler (StaticBody in der Physik-Welt). +## 4. Step-Up: Kiste 0.3 m wird erklommen, Hindernis 0.85 m blockt +## (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 checks := 0 @@ -20,6 +23,8 @@ var main: Control var camera: Camera3D var subvp: SubViewport +const HALF_H := 0.9 + func _initialize() -> void: _run_all.call_deferred() @@ -54,10 +59,11 @@ func _run_all() -> void: await process_frame _test_start_state() - _test_terrain_height_matches_document() - _test_obstacle_blocks_player() - _test_run_and_jump() - _test_obstacles_use_registry_footprint() + await _test_terrain_height_matches_document() + await _test_wall_blocks_player() + await _test_step_up_semantics() + await _test_run_and_jump() + await _test_bauteile_treppe_tuer() _test_exit_restores_editor() _finish() @@ -103,9 +109,22 @@ func _body() -> Node: 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 ------------------------------------------- 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") await process_frame @@ -124,10 +143,28 @@ func _test_start_state() -> void: _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. - var body: Node3D = pt.get("body") - _check(body != null and body.get_parent() == pt, "Body ist Kind des PlaytestMode") + var body: Node = pt.get("body") + _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") + # 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. var body_cam: Camera3D = body.get("look_cam") _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]) # 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") ## --- Test 2: Bodenhöhe == Terrainhöhe --------------------------------------- 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() if pt == null: _check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)") return - var body: Node3D = pt.get("body") + var body: Node = pt.get("body") var doc: Dictionary = main.get("doc") - # Schritt ausführen (Gravitation lässt den Spieler auf den Boden fallen). - body.call("step", 1.0 / 30.0, {"fwd": false, "back": false, "left": false, - "right": false, "run": false, "jump": false}) - var feet_y: float = body.position.y + # Gravitation lässt den Spieler auf den Terrain-Trimesh fallen. + for i in 45: + await physics_frame + var feet_y: float = body.fuss_hoehe() 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) - _check(is_equal_approx(feet_y, terrain_y), - "Fusspunkt == Terrainhöhe am Spielerort (%.3f == %.3f)" % [feet_y, terrain_y]) + _check(absf(feet_y - terrain_y) <= 0.05, + "Fusspunkt == Terrainhöhe am Spielerort (%.3f ≈ %.3f)" % [feet_y, terrain_y]) _check(is_equal_approx(body_terrain, terrain_y), "Body-Decoder == SvmapDocument-Decoder (%.3f == %.3f)" % [body_terrain, terrain_y]) -## --- Test 3: Objekt-AABB blockiert den Spieler ------------------------------ -func _test_obstacle_blocks_player() -> void: - print("Testgruppe: Spieler bleibt vor einem Objekt-AABB stehen") +## --- Test 3: Echte Wand blockiert den Spieler ------------------------------- +func _test_wall_blocks_player() -> void: + print("Testgruppe: Echte Wand blockt den Spieler (StaticBody in der Physik-Welt)") var pt := _pt() if pt == null: _check(false, "PlaytestMode noch aktiv (Vorbedingung)") return - var body: Node3D = pt.get("body") - # Hindernis VOR dem Spieler (Blick -Z bei yaw 0): Wand spannt - # 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 body: Node = pt.get("body") + var welt: Node3D = main.get("world3d") var start_x: float = body.position.x var start_z: float = body.position.z - var wall := AABB(Vector3(body.position.x - 2.0, -5.0, start_z - 3.0), - Vector3(4.0, 10.0, 0.6)) - body.get("obstacles").append(wall) - body.set("yaw_deg", 0.0) # Blick nach -Z - - var ok := true - for i in 60: # 2 Sekunden laufen - body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, - "right": false, "run": false, "jump": false}) - # Abstand: führende Kante des Spielers (z - radius) zur dem Spieler - # zugewandten Wand-Face (end.z — er kommt aus +Z). >= 0 = davor geblieben. - var dist_to_wall: float = ( - float(body.position.z) - float(body.get("radius")) - - float(wall.end.z)) + # Wand VOR dem Spieler: Fläche bei start_x + 2.4 (Lauf +x). + var wall := _box(welt, Vector3(start_x + 2.7, 0.0, start_z), Vector3(0.6, 10.0, 4.0)) + body.set("yaw_deg", -90.0) # Blick nach +x (Konsistenz mit dem Lauf) + body.set("auto_move_dir", Vector3(1, 0, 0)) + for i in 90: + await physics_frame + body.set("auto_move_dir", Vector3.ZERO) + for i in 10: + await physics_frame + # Abstand: führende Kante des Spielers (x + radius) zur Wand-Fläche. + var dist_to_wall: float = (start_x + 2.4) - (float(body.position.x) + 0.35) _check(dist_to_wall >= -0.05, "Spieler hält vor der Wand (Abstand %.2f m >= 0)" % dist_to_wall) - if dist_to_wall < -0.05: - ok = false - # Kein Drift in X (kein Links/Rechts-Input → Position unverändert). - _check(absf(float(body.position.x) - start_x) < 0.01, "Kein Drift in X") - # Hindernis wieder entfernen (sauberer Zustand für die Folgetests). - body.get("obstacles").erase(wall) + _check(absf(float(body.position.z) - start_z) < 0.01, "Kein Drift in Z") + _check(absf(float(body.position.x) - start_x) > 0.5, "Gegangen, nicht sofort blockiert") + wall.queue_free() + for i in 5: + await physics_frame -## --- Test 3b: Hindernisse = echter Registry-Fußabdruck (Sly 20.10.) --------- -## Die alte pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne -## (= „unsichtbare Wand“ weit vor dem Modell). Jetzt: collision.size × scale, -## 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") +## --- Test 4: Step-Up-Semantik (1:1 TestmapPlayer) --------------------------- +func _test_step_up_semantics() -> void: + print("Testgruppe: Step-Up — Kiste 0.3 m erklommen, Hindernis 0.85 m blockt") var pt := _pt() if pt == null: _check(false, "PlaytestMode noch aktiv (Vorbedingung)") 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. - var builder := Node3D.new() - var marker := Node3D.new() - marker.set_meta("obj_index", 0) - marker.set_meta("pick_radius", 20.3) - marker.set_meta("half_height", 3.1) - marker.position = Vector3(0.0, 0.0, -5.0) - builder.add_child(marker) - root.add_child(builder) + # a) Kiste 0.3 m (unter Step-Up-Grenze 0.45) → raufsteigen (Lauf +x). + var kiste := _box(welt, Vector3(sx + 3.0, 0.15, sz), Vector3(1.0, 0.3, 2.0)) + body.set("yaw_deg", -90.0) + body.set("auto_move_dir", Vector3(1, 0, 0)) + var feet_max := -INF + for i in 180: + 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.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. - var doc: Dictionary = {"objects": [{ - "id": "barracks_001", "scale": 1.0, "rot_y_deg": 90.0, "pos": [0.0, 0.0, -5.0], - }]} - var obs: Array = pt.call("_collect_obstacles", doc, builder) - _check(obs.size() == 1, "Genau 1 Hindernis gesammelt (ist %d)" % obs.size()) - if obs.size() == 1: - var ab: AABB = obs[0] - _check(ab.size.x < 38.0, - "X-Ausdehnung unter Pick-Kugel-Blob (ist %.1f < 38)" % ab.size.x) - _check(absf(ab.size.x - 35.456) < 1.2, - "Gedrehte Länge auf X ≈ 35.5 m (ist %.2f)" % ab.size.x) - _check(absf(ab.size.z - 16.99) < 1.2, - "Gedrehte Breite auf Z ≈ 17.0 m (ist %.2f)" % ab.size.z) - _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() + # b) Fass 0.85 m (über der Grenze) → blockt, kein Cheat-Klettern. + var fass := _box(welt, Vector3(sx + 3.0, 0.425, sz), Vector3(0.617, 0.85, 0.617)) + body.set("auto_move_dir", Vector3(1, 0, 0)) + var feet_max2 := -INF + for i in 180: + await physics_frame + feet_max2 = maxf(feet_max2, body.fuss_hoehe()) + body.set("auto_move_dir", Vector3.ZERO) + for i in 10: + await physics_frame + _check(feet_max2 < 0.55, "Hindernis 0.85 m blockt (max feet %.2f < 0.55)" % feet_max2) + fass.queue_free() + for i in 5: + await physics_frame -## --- Test 4: Rennen/Springen ------------------------------------------------ +## --- Test 5: Gehen/Rennen/Springen (Client-Geschwindigkeiten) --------------- 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() if pt == null: _check(false, "PlaytestMode noch aktiv (Vorbedingung)") 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) - for i in 30: - body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, - "right": false, "run": false, "jump": false}) + body.set("auto_move_dir", Vector3(1, 0, 0)) + for i in 60: + await physics_frame + body.set("auto_move_dir", Vector3.ZERO) var walk_dist: float = (Vector3(body.position) - start).length() - _check(walk_dist > 3.0 and walk_dist < 5.0, - "Gehen ≈ 4 m/s (gemessen %.2f m in 1 s)" % walk_dist) + _check(walk_dist > 5.0 and walk_dist < 7.0, + "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) - for i in 30: - body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, - "right": false, "run": true, "jump": false}) + body.set("auto_run", true) + body.set("auto_move_dir", Vector3(1, 0, 0)) + 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() - _check(run_dist > walk_dist * 1.5, - "Rennen deutlich schneller (%.2f m > %.2f m)" % [run_dist, walk_dist]) + _check(run_dist > 12.0 and run_dist < 16.0, + "Rennen ≈ 14 m/s (gemessen %.2f m in 1 s)" % run_dist) # 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 - for i in 60: - var state: Dictionary = body.call("step", 1.0 / 30.0, - {"fwd": false, "back": false, "left": false, "right": false, - "run": false, "jump": i < 2}) - if not bool(state["on_floor"]): + body.set("auto_jump", true) + for i in 90: + await physics_frame + if body.fuss_hoehe() > feet0 + 0.2: airborne = true - if i > 5 and bool(state["on_floor"]): + if i > 5 and airborne and body.is_on_floor(): break + body.set("auto_jump", false) _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: print("Testgruppe: Rückkehr in den Editor (Dokument + Undo unverändert)") 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, "Undo/Redo-Stapel 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") + + +## --- 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. diff --git a/tests/test_playtest_comfort.gd b/tests/test_playtest_comfort.gd index b3c10fc..d7407a5 100644 --- a/tests/test_playtest_comfort.gd +++ b/tests/test_playtest_comfort.gd @@ -120,9 +120,6 @@ func _key_event(code: Key) -> InputEventKey: return evt -const NO_INPUT := {"fwd": false, "back": false, "left": false, "right": false, - "run": false, "jump": false} - ## --- Test 1: Fliegen / God-Mode --------------------------------------------- func _test_fly_godmode() -> void: @@ -138,61 +135,68 @@ func _test_fly_godmode() -> void: pt.call("_unhandled_input", _key_event(KEY_G)) _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)) var y0: float = body.position.y 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, - "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) 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), - Vector3(4.0, 10.0, 0.6))) + body.set("auto_move_dir", Vector3(0, 0, -1)) for i in 60: - body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, - "right": false, "run": false, "jump": false}) + await physics_frame + body.set("auto_move_dir", Vector3.ZERO) 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) - body.get("obstacles").clear() + _check(dist > 3.5, "Fliegen noclip: 6 m Weg ohne Block (%.2f m)" % dist) # 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). - var start2 := Vector3(body.position) + # in -z und -y (sin30 = 0.5, cos30 ≈ 0.866; 6 m/s → 6 m in 1 s). + var blick: Vector3 = body.call("view_direction") if body.has_method("view_direction") \ + else Vector3.ZERO body.set("yaw_deg", 0.0) body.set("pitch_deg", -30.0) - for i in 30: - body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false, - "right": false, "run": false, "jump": false}) + blick = body.call("view_direction") + _check(absf(blick.z + 0.866) < 0.01 and absf(blick.y + 0.5) < 0.01, + "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 - _check(absf(moved.z + 3.46) < 0.05 and absf(moved.y + 2.0) < 0.05, - "Blickrichtung inkl. Nickwinkel (Δz=%.2f, Δy=%.2f, Soll -3.46/-2.0)" % [moved.z, moved.y]) + _check(absf(moved.z + 5.19) < 0.1 and absf(moved.y + 3.0) < 0.1, + "Flug entlang der Blickrichtung (Δz=%.2f, Δy=%.2f, Soll -5.19/-3.0)" % [moved.z, moved.y]) # Space = steigen. var y_up: float = body.position.y + body.set("auto_input", {"fwd": false, "back": false, "left": false, + "right": false, "run": false, "jump": true}) for i in 15: - body.call("step", 1.0 / 30.0, {"fwd": false, "back": false, "left": false, - "right": false, "run": false, "jump": true}) + await physics_frame + body.set("auto_input", {}) _check(float(body.position.y) > y_up + 1.0, "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)) _check(not bool(body.get("fly_mode")), "Zweites G schaltet Fliegen AUS") var y_fall: float = body.position.y var landed := false - for i in 120: - var state: Dictionary = body.call("step", 1.0 / 30.0, NO_INPUT) - if bool(state["on_floor"]): + for i in 240: + await physics_frame + if body.is_on_floor(): landed = true break _check(landed and float(body.position.y) < y_fall, "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) - _check(is_equal_approx(float(body.position.y), terrain_y), - "Landung auf Terrainhöhe (%.3f == %.3f)" % [float(body.position.y), terrain_y]) + _check(absf(float(body.fuss_hoehe()) - terrain_y) <= 0.05, + "Landung auf Terrainhöhe (%.3f ≈ %.3f)" % [float(body.fuss_hoehe()), terrain_y]) ## --- Test 2: Sprung zu den Spawnpunkten -------------------------------------- @@ -216,7 +220,8 @@ func _test_spawn_jumps() -> void: continue var pos: Array = (pts[0] as Dictionary).get("pos", [0.0, 0.0, 0.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). body.set("position", Vector3(99.0, 5.0, 99.0)) var ok := bool(pt.call("jump_to_spawn", idx)) diff --git a/tests/test_playtest_identitaet.gd b/tests/test_playtest_identitaet.gd new file mode 100644 index 0000000..b946183 --- /dev/null +++ b/tests/test_playtest_identitaet.gd @@ -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 =` — 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]) diff --git a/tests/test_playtest_identitaet.gd.uid b/tests/test_playtest_identitaet.gd.uid new file mode 100644 index 0000000..cde0603 --- /dev/null +++ b/tests/test_playtest_identitaet.gd.uid @@ -0,0 +1 @@ +uid://bay4ioxemv0r2 diff --git a/tests/test_playtest_spawn.gd b/tests/test_playtest_spawn.gd index f3cfff8..6366472 100644 --- a/tests/test_playtest_spawn.gd +++ b/tests/test_playtest_spawn.gd @@ -9,7 +9,7 @@ extends SceneTree ## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm). ## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte). ## 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). ## 6. Marker existiert nur bei gesetztem Punkt. ## 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("pitch_deg", -45.0) 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 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) _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) @@ -322,14 +322,16 @@ func _test_playtest_teleport() -> void: var expected := eye + dir * th main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung) await process_frame + await physics_frame var np := body.position _check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15, "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) - _check(absf(np.y - ground_here) <= 0.01, - "Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf(np.y - ground_here)) - _check(absf(float(cam.position.y) - 1.7) < 0.001, "Augenhöhe 1,70 m (look_cam-Offset)") + _check(absf((np.y - PlaytestBody.HALF_H) - ground_here) <= 0.05, + "Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf((np.y - PlaytestBody.HALF_H) - ground_here)) + _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") _check(stored != null and absf((stored as Vector3).x - np.x) < 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 _check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg) - # b) Kollision: hohe Wand vor dem Spieler, flacher Blick (−10°) zielt HINTER - # die Wand ins Terrain — der Wand-AABB-Treffer kommt zuerst → ABGELEHNT. - # (Wand ragt weit über die Augenhöhe am Ramp-Terrain: Augen ≈ 7,5 m dort.) + # b) Kollision: hohe Wand vor dem Spieler (echter StaticBody in der + # Physik-Welt), flacher Blick (−10°) trifft die Wand → ABGELEHNT + # (Kapsel am Zielpunkt nicht frei — wie im Spiel). + var welt: Node3D = main.get("world3d") 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("pitch_deg", -10.0) body.call("_apply_look") main._unhandled_key_input(_press_key(KEY_T)) await process_frame + await physics_frame _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 _check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"), "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, - # 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)) - body.obstacles.append(crate) - var ccenter := crate.get_center() + # c) Kisten-Dach: echter Kisten-StaticBody → oben drauf landen (Boden + # DARAUFD, nie im Objekt). Strahl von oben senkrecht auf die Kiste. + var cground: float = body.call("terrain_height", np.x + 5.0, np.z) + var kiste := StaticBody3D.new() + 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", - Vector3(ccenter.x, crate.end.y + 2.0, ccenter.z), Vector3(0.0, -1.0, 0.0)) - _check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - crate.end.y) < 0.01, + 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")) - (cground + 1.0)) < 0.01, "Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [ - float(roof.get("ground")), crate.end.y]) - body.obstacles.pop_back() + float(roof.get("ground")), cground + 1.0]) + kiste.queue_free() + await physics_frame # d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt). var doc_before := str(doc)