scopedvr-map-editor/tests/test_playtest.gd
Hermes (scopedvr-dev) fabbbcce20 Playtest-Kollision: echter Registry-Fußabdruck statt Pick-Radius-Kugel
Sly-Befund 20.10.: Im Editor-Testmodus hing man an unsichtbaren Wänden weit
VOR den Gebäuden. Ursache: _collect_obstacles nutzte die Viewport-Metas
pick_radius (Kugel-Näherung = volle Raumdiagonale + Zuschlag) — bei der
Kaserne (17×35 m) blockte das ~40×40 m. Die Trimesh-Fixes aus t_9558038d
wirken nur im Spiel-Client, der Editor-Playtest baut seine Blockierung
selbst (keine Physik-Welt, nur AABBs).

Fix: Hindernis-AABB aus der Registry (collision.size × scale, um rot_y_deg
konservativ gedreht, Y vom Fußpunkt bis Fußpunkt + Höhe — gleiche
Konvention wie der Client-Kollider). Fallback: Kugel-Näherung bleibt für
Objekte ohne Registry-Eintrag.

Regression in test_playtest.gd: gedreht/ungedreht, Scale, Fallback
(20 checks, alle grün). Suiten: playtest_comfort 51, playtest_spawn 56,
resize_dialog 44, terrain 153 — ALL OK.
2026-10-20 20:11:11 +00:00

366 lines
14 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 — 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
## 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).
var failures := 0
var checks := 0
var main: Control
var camera: Camera3D
var subvp: SubViewport
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()
_test_terrain_height_matches_document()
_test_obstacle_blocks_player()
_test_run_and_jump()
_test_obstacles_use_registry_footprint()
_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")
## --- Test 1: Start/Zurück-Zustand -------------------------------------------
func _test_start_state() -> void:
print("Testgruppe: Start-Zustand (Modus-Flags, Panels, Maus, Kamera)")
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: Node3D = pt.get("body")
_check(body != null and body.get_parent() == pt, "Body ist Kind des PlaytestMode")
_check(pt.get_parent() == main.get("world3d"), "PlaytestMode lebt im world3d")
# 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: Node = 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")
var pt := _pt()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)")
return
var body: Node3D = 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
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(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")
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 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))
_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)
## --- 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")
var pt := _pt()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return
# 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)
# 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()
## --- Test 4: Rennen/Springen ------------------------------------------------
func _test_run_and_jump() -> void:
print("Testgruppe: Rennen/Springen wirkt korrekt")
var pt := _pt()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return
var body: Node3D = pt.get("body")
# Gehen: 1 s bei 4 m/s → ~4 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})
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)
# Rennen: gleiche Zeit, Verdopplung → deutlich mehr Strecke.
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})
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])
# Springen: y-Impuls, Spieler verlässt den Boden und landet wieder.
var y0: float = body.position.y
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"]):
airborne = true
if i > 5 and bool(state["on_floor"]):
break
_check(airborne, "Sprung verlässt den Boden")
_check(is_equal_approx(float(body.position.y), y0), "Landung wieder auf Terrainhöhe")
## --- Test 5: 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(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")