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_splitter_lagen_merken() _test_start_state() _test_splitter_lagen_verbiegen() 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() await _test_splitter_lagen_pruefen() _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 barrel_001 := _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) barrel_001.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 6b: Splitter-Lagen überleben den Playtest (Sly-Befund 20.10.) ----- ## Beim Verstecken der Panels klappen die Trenner zusammen; der Exit muss die ## gemerkten Lagen zurücksetzen. Der Test verbiegt die Lagen bewusst hart ## (wie der Clamp es tut) und verlangt nach dem Exit die Original-Lagen. var _split_vor_start: Dictionary = {} func _split_by_name(sname: String) -> SplitContainer: var found := root.find_children(sname, "SplitContainer", true, false) return found[0] if not found.is_empty() else null func _test_splitter_lagen_merken() -> void: # Bezugspunkt = VOR dem Start (nach dem Start klappen die Trenner bereits). for sname in ["MainSplit", "BottomSplit"]: var sp := _split_by_name(sname) if sp != null: _split_vor_start[sname] = Array(sp.split_offsets) func _test_splitter_lagen_verbiegen() -> void: var ms := _split_by_name("MainSplit") if ms != null: ms.split_offsets = PackedInt32Array([9999, 9999]) var bs := _split_by_name("BottomSplit") if bs != null: bs.split_offsets = PackedInt32Array([-9999]) func _test_splitter_lagen_pruefen() -> void: for i in 3: await process_frame var ok := true for sname in _split_vor_start.keys(): var sp := _split_by_name(str(sname)) if sp == null or Array(sp.split_offsets) != _split_vor_start[sname]: ok = false print(" DIFF ", sname, ": ", str(sp.split_offsets if sp != null else null), " statt ", str(_split_vor_start[sname])) _check(ok, "Splitter-Lagen nach Playtest unverändert (Sly-Befund)") ## --- 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 doc: Dictionary = SvmapDocument.create_new("Bauteil-Karte", 100.0, "klar") var t0: float = SvmapDocument.terrain_height_at(doc, 0.0, 0.0) # Treppe (4 m lang, 3 m hoch, Lauf +x) und Tür-Wand als NORMALE Objekte (bau_*). var treppe := {"id": "bau_treppe", "pos": [6.0, t0, 0.0], "scale": 1.0, "rot_y_deg": 0.0} var tuer := {"id": "bau_wand_tuer", "pos": [1.0, t0, 14.0], "scale": 1.0, "rot_y_deg": 0.0} doc["objects"] = [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).contains("_bau_"): bauteile += 1 _check(bauteile == 2, "2 Bauteil-Kollider gebaut (Treppe + Tür, ist %d)" % bauteile) # a) Treppe (4 m lang, 3 m hoch, 15 Stufen à 0.2 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 >= t0 + 2.5, "Treppe (3.0 m, Stufen à 0.2) erklommen (max feet %.2f, Start %.2f)" % [feet_max, t0]) # b) Tür (Wand 2 m entlang x, Öffnung 1,2 × 2,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.