scopedvr-map-editor/tests/test_playtest.gd
Hermes (scopedvr-dev) 52919b36b4 Playtest: WASD folgt der Blickrichtung (Sly-Befund 20.10.)
Bug: Die Laufrichtung kam aus transform.basis des Körperknotens — die
Kamera-Drehung steckt aber im look_cam-Kindknoten, der Körper rotiert
nicht → WASD lief stur in eine Himmelsrichtung. Auf der Quest rotiert
der ganze Origin mit dem Kopf, dort trat der Bug nicht auf.

Fix: forward/right aus yaw_deg bauen (identisch zu view_direction(),
identisch zum Kopf-Yaw des Clients). Regression in test_playtest:
yaw 0° + W → -Z, yaw 90° + W → -X. Suite 39/39 grün.
2026-10-20 21:10:09 +00:00

493 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

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

extends SceneTree
## 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 = 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
var main: Control
var camera: Camera3D
var subvp: SubViewport
const HALF_H := 0.9
func _initialize() -> void:
_run_all.call_deferred()
func _run_all() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
_check(scene != null, "scenes/main.tscn laedt")
if scene == null:
_finish()
return
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
camera = main.get("camera")
_check(camera != null, "Editor-Kamera gefunden")
var container := _find_viewport_container(main)
if container != null:
subvp = container.get_child(0) as SubViewport
_check(subvp != null, "SubViewport gefunden")
# Echte Map anlegen (Neu-Dialog-Konvention wie _on_new_map_confirmed).
var doc: Dictionary = SvmapDocument.create_new("Testlauf-Karte", 100.0, "klar")
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
_test_start_state()
await _test_terrain_height_matches_document()
await _test_wall_blocks_player()
await _test_step_up_semantics()
await _test_run_and_jump()
await _test_yaw_relatives_laufen()
await _test_bauteile_treppe_tuer()
_test_exit_restores_editor()
_finish()
func _finish() -> void:
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _find_viewport_container(node: Node) -> SubViewportContainer:
if node is SubViewportContainer:
return node as SubViewportContainer
for child in node.get_children():
var found := _find_viewport_container(child)
if found != null:
return found
return null
## PlaytestMode-Instanz der aktuellen Karte (main.playtest).
func _pt() -> Node:
return main.get("playtest")
func _body() -> Node:
var pt := _pt()
if pt == null:
return null
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, Kollider)")
main.call("_on_test_run")
await process_frame
var pt := _pt()
_check(pt != null, "PlaytestMode angelegt (main.playtest)")
if pt == null:
return
_check(bool(pt.get("active")), "Modus-Flag active == true")
_check(pt.get("body") != null, "Spieler-Body existiert")
_check(pt.wants_capture(), "Maus-Capture-Wunsch gesetzt")
# Panels AUS: Top-Bar, linke/rechte Spalte, Statusleiste.
for pair in [["ui_top_bar", "Top-Bar"], ["ui_left_panel", "linke Spalte"],
["ui_right_panel", "rechte Spalte"], ["ui_status_bar", "Statusleiste"]]:
var panel: Control = main.get(pair[0])
_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: 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")
_check(not camera.current, "Editor-Kamera abgegeben")
# Start = erster Spawnpunkt (Team Blau) — sonst Kartenmitte.
var doc: Dictionary = main.get("doc")
var zones: Dictionary = doc.get("spawn_zones", {})
var team0: Dictionary = zones.get("0", {})
var pts: Array = team0.get("points", [])
if pts.is_empty():
_check(false, "Testmap braucht Spawn-Punkte (Team 0)")
return
var first: Dictionary = pts[0]
var pos: Array = first.get("pos", [0.0, 0.0, 0.0])
var expected := Vector3(float(pos[0]), 0.0, float(pos[2]))
var body_pos: Vector3 = body.position
_check(is_equal_approx(body_pos.x, expected.x) and is_equal_approx(body_pos.z, expected.z),
"Start == erster Spawnpunkt (%.1f, %.1f)" % [expected.x, expected.z])
# Kein History-Eintrag durch den Start (Dokument/Undo-Stapel unangetastet).
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 (Physik-Landung auf dem Trimesh)")
var pt := _pt()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)")
return
var body: Node = pt.get("body")
var doc: Dictionary = main.get("doc")
# 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(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: 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: Node = pt.get("body")
var welt: Node3D = main.get("world3d")
var start_x: float = body.position.x
var start_z: float = body.position.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)
_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 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
# 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
# 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 5: Gehen/Rennen/Springen (Client-Geschwindigkeiten) ---------------
func _test_run_and_jump() -> void:
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: Node = pt.get("body")
# Gehen: 1 s bei 6 m/s → ~6 m Wegstrecke.
var start := Vector3(body.position)
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 > 5.0 and walk_dist < 7.0,
"Gehen ≈ 6 m/s (gemessen %.2f m in 1 s)" % walk_dist)
# Rennen: gleiche Zeit, deutlich mehr Strecke (14 m/s).
var start2 := Vector3(body.position)
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 > 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 feet0: float = body.fuss_hoehe()
var airborne := false
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 airborne and body.is_on_floor():
break
body.set("auto_jump", false)
_check(airborne, "Sprung verlässt den Boden")
_check(absf(body.fuss_hoehe() - feet0) < 0.05, "Landung wieder auf Terrainhöhe")
## --- Test 5b: Laufrichtung folgt der Blickrichtung (Sly-Befund 20.10.) ------
## Bug: WASD benutzte transform.basis des Körpers, aber die Kamera-Drehung
## steckt im look_cam-Kindknoten → WASD lief stur in eine Himmelsrichtung.
## Fix: Richtung aus yaw_deg bauen (wie view_direction(), wie Quest-Kopf-Yaw).
func _test_yaw_relatives_laufen() -> void:
print("Testgruppe: Laufrichtung folgt Blickrichtung (yaw)")
var body := _body()
if body == null:
_check(false, "Playtest-Body aktiv (Vorbedingung)")
return
var start := Vector3(body.position)
# Blick nach -Z (yaw 0): W muss nach -Z laufen.
body.set("yaw_deg", 0.0)
body.set("auto_input", {"fwd": true})
for i in 20:
await physics_frame
body.set("auto_input", {})
var d0 := Vector3(body.position) - start
_check(d0.z < -1.0 and absf(d0.x) < 0.6,
"yaw 0° + W → Bewegung nach -Z (d=%.1f, %.1f)" % [d0.x, d0.z])
# Blick um 90° gedreht (nach -X): W muss nach -X laufen, NICHT weiter -Z.
body.set("position", start)
body.set("velocity", Vector3.ZERO)
body.set("yaw_deg", 90.0)
await physics_frame
body.set("auto_input", {"fwd": true})
for i in 20:
await physics_frame
body.set("auto_input", {})
var d1 := Vector3(body.position) - start
_check(d1.x < -1.0 and absf(d1.z) < 0.6,
"yaw 90° + W → Bewegung folgt Blick nach -X (d=%.1f, %.1f)" % [d1.x, d1.z])
## --- 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()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return
var doc_before: Dictionary = main.get("doc")
var doc_before_str := str(doc_before)
var hist: RefCounted = main.get("history")
var undo_before: bool = hist.call("can_undo")
var redo_before: bool = hist.call("can_redo")
var tool_before: String = main.get("tool_mode")
var cam_target: Variant = camera.get("target")
var cam_yaw: float = camera.get("yaw_deg")
pt.call("handle_escape")
await process_frame
_check(main.get("playtest") == null, "PlaytestMode abgeräumt")
for pair in [["ui_top_bar", "Top-Bar"], ["ui_left_panel", "linke Spalte"],
["ui_right_panel", "rechte Spalte"], ["ui_status_bar", "Statusleiste"]]:
var panel: Control = main.get(pair[0])
_check(panel != null and panel.visible, "%s wieder sichtbar" % pair[1])
_check(camera.current, "Editor-Kamera zurück im Bild")
_check(is_equal_approx(camera.get("yaw_deg"), cam_yaw), "Kamera-Yaw unverändert")
_check(camera.get("target") == cam_target, "Kamera-Ziel unverändert")
_check(str(main.get("doc")) == doc_before_str, "Dokument byte-identisch")
_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(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.