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.
366 lines
14 KiB
GDScript
366 lines
14 KiB
GDScript
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")
|