diff --git a/src/editor/playtest/playtest_body.gd b/src/editor/playtest/playtest_body.gd new file mode 100644 index 0000000..a2fb247 --- /dev/null +++ b/src/editor/playtest/playtest_body.gd @@ -0,0 +1,297 @@ +extends Node3D +class_name PlaytestBody +## Lauf-Modus Spieler (Karte t_1cdc1a72): 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. +## +## 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. +## +## 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“). + +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 + +const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze + +## 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 +var mouse_sens: float = DEFAULT_MOUSE_SENS + +## Map-Dokument (nur LESEND — Terrain-Höhenabfrage) +var doc: Dictionary = {} +## Vorgecachte Hindernis-AABBs (Weltkoordinaten) — von PlaytestMode gefüllt +var obstacles: Array = [] # Array[AABB] +## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain() +## gefüllt, damit die Boden-Abfrage NICHT jeden Schritt das Base64 dekodiert. +var heights: PackedFloat32Array = PackedFloat32Array() +var res: int = 0 +var size_x: float = 0.0 +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 + + +func _ready() -> void: + look_cam = Camera3D.new() + look_cam.near = 0.1 + look_cam.far = 6000.0 + look_cam.current = true + add_child(look_cam) + _apply_look() + + +## Blickrichtung auf die Kamera übertragen (first person: Kamera schaut -Z). +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) + + +## 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)) + + 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() + + # 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 + 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 + else: + on_floor = false + else: + # Sprung-Phase: kein Boden-Greifen; auf_floaten lassen + on_floor = false + next.y = ny + + position = next + _apply_look() + return {"pos": position, "vel": velocity, "on_floor": on_floor} + + +## Maus-Bewegung (nur der Spieler selbst fängt sie — die Editor-Werkzeuge +## sind im Lauf-Modus eingefroren). +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + yaw_deg = wrapf(yaw_deg - mm.relative.x * mouse_sens, -180.0, 180.0) + pitch_deg = clampf(pitch_deg - mm.relative.y * mouse_sens, + -PITCH_LIMIT, PITCH_LIMIT) + _apply_look() + get_viewport().set_input_as_handled() + + +## ESC-Request — wird von main.gd angerufen (die Esc-Priorität gehört dort +## hin, damit die normale Esc-Kette NICHT auch noch zieht). +func request_exit() -> void: + exit_requested.emit() + + +## 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. +func set_terrain(d: Dictionary) -> void: + doc = d + heights = PackedFloat32Array() + res = 0 + size_x = 0.0 + size_z = 0.0 + hmin = 0.0 + hmax = 0.0 + if d.is_empty(): + return + var terrain: Dictionary = d.get("terrain", {}) + res = int(terrain.get("resolution", 0)) + size_x = float(terrain.get("size_x_m", 0.0)) + size_z = float(terrain.get("size_z_m", 0.0)) + hmin = float(terrain.get("min_height_m", 0.0)) + hmax = float(terrain.get("max_height_m", 0.0)) + if res < 2 or size_x <= 0.0 or size_z <= 0.0: + return + var decoded: PackedFloat32Array = SvmapIO.decode_heights(terrain) + if decoded.size() < res * res: + return + 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 + + +## Terrain-Höhe an Welt-X/Z — aus dem DOCUMENT-Raster (nichts neu rechnen). +func terrain_height(x: float, z: float) -> float: + if res < 2 or size_x <= 0.0 or size_z <= 0.0: + return hmin + if heights.size() < res * res: + return hmin + var last := float(res - 1) + var fx := clampf((x / size_x + 0.5) * last, 0.0, last) + var fz := clampf((z / size_z + 0.5) * last, 0.0, last) + var xi := int(floor(fx)) + var zi := int(floor(fz)) + var tx := fx - float(xi) + var tz := fz - float(zi) + var xi1 := mini(xi + 1, res - 1) + var zi1 := mini(zi + 1, res - 1) + var h00 := _sample(xi, zi) + var h10 := _sample(xi1, zi) + var h01 := _sample(xi, zi1) + var h11 := _sample(xi1, zi1) + return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) + + +func _sample(xi: int, zi: int) -> float: + var idx := zi * res + xi + if idx < 0 or idx >= heights.size(): + return hmin + return heights[idx] diff --git a/src/editor/playtest/playtest_body.gd.uid b/src/editor/playtest/playtest_body.gd.uid new file mode 100644 index 0000000..035ed25 --- /dev/null +++ b/src/editor/playtest/playtest_body.gd.uid @@ -0,0 +1 @@ +uid://csv8o2g8wdtue diff --git a/src/editor/playtest/playtest_mode.gd b/src/editor/playtest/playtest_mode.gd new file mode 100644 index 0000000..2776f5d --- /dev/null +++ b/src/editor/playtest/playtest_mode.gd @@ -0,0 +1,196 @@ +extends Node +## Lauf-Modus (Karte t_1cdc1a72): erste Person IM Map-Builder selbst — +## kein Client-Ordner, kein zweiter Prozess, kein VR/OpenXR, kein Server. +## +## Verantwortung dieses Zustands: +## - 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. +## - 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. +## +## Bewusst KEIN _physics_process hier: alle Bewegung/Eingabe lebt im +## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD. + +const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd" +const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd" + +## Der laufende Spieler (PlaytestBody, step-basiert) +var body: Node3D = null + +## Editor-UI-Verweise (vom main.gd-Handler gesetzt): die Panels, die +## AUSGEBLENDET werden sollen (Variant statt Control-Typ — keine UI-Kopie). +var ui_panels: Array = [] # Array[Control] +var ui_status: Variant = null # Statuszeilen-Label (nur visible umschalten) +var help_label: Variant = null # Hilfe-Label (nur visible umschalten) + +## Maus-Capture-Wunsch (Suite testet diesen Zustand) +var _want_capture := false +## HUD-Elemente +var hud: CanvasLayer = null +var hud_pos: Label = null +var hud_fps: Label = null +var hud_hint: Label = null +## FPS-Smoothing (Anzeige, keine Physik) +var _fps_smooth := 0.0 +## Ist der Lauf-Modus gerade aktiv? (Reentrancy-Schutz für enter/exit) +var active := false + +signal exit_requested + + +## Starten: übernimmt die Sicht (Panels aus, Maus-Capture-Wunsch an) und +## legt den Spieler am ersten Spawnpunkt (Team Blau) an — sonst Kartenmitte. +## view_builder = der map_view_builder-Node (Marker-Tree mit pick_radius/ +## half_height-Metas bleibt stehen und liefert die Hindernis-Blöcke). +func enter(doc: Dictionary, view_builder: Node3D) -> void: + if active: + return + active = true + _set_ui_visible(false) + _set_status_capture(true) + + body = load(BODY_SCRIPT).new() + body.call("set_terrain", doc) + body.obstacles = _collect_obstacles(doc, view_builder) + var start := _spawn_start(doc) + body.position = Vector3(start.x, body.terrain_height(start.x, start.z), start.z) + add_child(body) + + _build_hud() + exit_requested.connect(_on_exit_requested, CONNECT_ONE_SHOT) + + +## Zurück in den Editor: HUD weg, Spieler weg, Panels sichtbar. +## Editor-Zustand (Kamera, Auswahl, Dokument, Undo-Stapel) wird NICHT hier +## angefasst — das macht main.gd nach dem Signal. +func exit() -> void: + if not active: + return + active = false + _set_status_capture(false) + _set_ui_visible(true) + if body != null: + body.queue_free() + body = null + if hud != null: + hud.queue_free() + hud = null + + +func _on_exit_requested() -> void: + exit() + + +## Status/Maus-Capture-Wunsch: nur der Zustand (Suite testet wants_capture). +func _set_status_capture(want: bool) -> void: + _want_capture = want + + +## Panelselemente ausblenden/einblenden. Nur die als ui_panels übergebenen +## Controls werden angefasst — keine weiteren. +func _set_ui_visible(vis: bool) -> void: + for p in ui_panels: + if p is Control: + (p as Control).visible = vis + if ui_status is Control: + (ui_status as Control).visible = vis + if help_label is Control: + (help_label as Control).visible = vis + + +## Start-Punkt: erster Spawn-Punkt aus dem Dokument (spawn_zones-Team „0", +## erster Eintrag der points-Liste); sonst Kartenmitte aus den Bounds. +## doc-Format (svmap): {"spawn_zones": {"0": {"points": [{"id", "pos": [x,y,z]}]}, ...}} +func _spawn_start(doc: Dictionary) -> Vector3: + var zones: Dictionary = doc.get("spawn_zones", {}) + var team0 = zones.get("0", {}) + if team0 is Dictionary: + var points: Array = (team0 as Dictionary).get("points", []) + if not points.is_empty() and points[0] is Dictionary: + var pos = (points[0] as Dictionary).get("pos", []) + if pos is Array and (pos as Array).size() >= 3: + return Vector3(float(pos[0]), 0.0, float(pos[2])) + var bounds: Dictionary = doc.get("bounds", {}) + var mn: Array = bounds.get("min", [-50.0, -10.0, -50.0]) + var mx: Array = bounds.get("max", [50.0, 80.0, 50.0]) + return Vector3((float(mn[0]) + float(mx[0])) * 0.5, 0.0, + (float(mn[2]) + float(mx[2])) * 0.5) + + +## Hindernis-AABBs sammeln (nur LESEND, keine neue Physik-Welt): +## - Objekt-Marker: die VORHANDENEN Viewport-Metas — pick_radius (x/z- +## Halbausdehnung, Kugel-Approximation konservativ) + half_height +## (Center-Höhe über dem Fußpunkt → Objekt-Top = 2× half_height). +## - Bau-Teile: exakt über BuildOps.piece_aabb (Dokument-Teile → AABB). +func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array: + var out: Array = [] + 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 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))) + 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 + + +# ------------------------------------------------------------------- HUD ---- + +func _build_hud() -> void: + hud = CanvasLayer.new() + hud.layer = 100 + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_top", 16) + margin.set_anchors_preset(Control.PRESET_TOP_LEFT) + hud.add_child(margin) + var vbox := VBoxContainer.new() + margin.add_child(vbox) + hud_pos = Label.new() + hud_fps = Label.new() + hud_hint = Label.new() + hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen · ESC = zurück zum Editor" + for l: Label in [hud_pos, hud_fps, hud_hint]: + l.add_theme_font_size_override("font_size", 16) + vbox.add_child(l) + add_child(hud) + + +func _process(delta: float) -> void: + if not active or body == null: + return + # FPS glätten (Anzeige-Genauigkeit, keine Physik-Betroffenheit) + if _fps_smooth <= 0.0: + _fps_smooth = 1.0 / maxf(delta, 0.0001) + else: + _fps_smooth = lerpf(_fps_smooth, 1.0 / maxf(delta, 0.0001), 0.1) + if hud_pos != null: + hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z] + if hud_fps != null: + hud_fps.text = "FPS: %d" % int(round(_fps_smooth)) + + +## Maus-Capture-Wunsch für die Suite/Debug sichtbar machen. +func wants_capture() -> bool: + return _want_capture + + +## ESC → Signal an main.gd (die Esc-Priorität gehört in main.gd, damit die +## normale Esc-Kette im Editor-Modus unangetastet bleibt). +func handle_escape() -> void: + if active and body != null: + exit_requested.emit() diff --git a/src/editor/playtest/playtest_mode.gd.uid b/src/editor/playtest/playtest_mode.gd.uid new file mode 100644 index 0000000..76bf77a --- /dev/null +++ b/src/editor/playtest/playtest_mode.gd.uid @@ -0,0 +1 @@ +uid://spymwdq2qcvr diff --git a/src/editor/test_run.gd b/src/editor/test_run.gd index df7d7de..adef088 100644 --- a/src/editor/test_run.gd +++ b/src/editor/test_run.gd @@ -1,6 +1,13 @@ class_name TestRun extends RefCounted -## „▶ Karte testen" (Karte t_b84af40f): Export der AKTUELLEN Map nach Temp +## ALT (Karte t_1cdc1a72): dieser Client-Start ist seit dem Lauf-Modus im +## Map-Builder selbst NICHT mehr der Standard-Weg — „▶ Karte testen" startet +## jetzt IN DIESER APP (src/editor/playtest/, kein Client-Ordner, kein +## zweiter Prozess, kein VR). Der Rückbau dieser Datei kommt in einer +## späteren Karte; bis dahin bleibt die Export-/Such-Logik als Fallback +## erhalten und getestet (tests/test_testrun.gd). +## +## Ursprünglicher Zweck (Karte t_b84af40f): Export der AKTUELLEN Map nach Temp ## + Start des scopedvr-client als EIGENES Fenster im Testlauf-Modus ## (--testmap ). Der Editor bleibt offen — der Client ist ein eigener ## Prozess; das Spiel beendet sich selbst sauber (ESC = „Zurück zum Editor"). diff --git a/src/ui/main.gd b/src/ui/main.gd index aa9b6ce..410f602 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -151,6 +151,18 @@ var check_text: Label # Karte testen (Karte t_b84af40f) var test_btn: Button +# Lauf-Modus „▶ Karte testen" im Builder selbst (Karte t_1cdc1a72): erste +# Person, WASD + Maus — OHNE Client-Ordner, ohne zweiten Prozess, ohne +# VR/OpenXR, ohne Server (Slys verbindliche Vorgabe). Der alte Client-Start +# bleibt als „alt" in src/editor/test_run.gd; der Rúckbau kommt später. +var playtest: Node = null # src/editor/playtest/playtest_mode.gd Instanz +var _playtest_tool_mode := "" # gemerkter Werkzeug-Modus für die Rückkehr +var ui_top_bar: Control # Top-Bar (im Lauf-Modus aus) +var ui_left_panel: Control # linke Bibliothek (im Lauf-Modus aus) +var ui_right_panel: Control # rechte Werkzeug-Panel-Spalte (im Lauf-Modus aus) +var ui_status_bar: Control # Statusleiste (im Lauf-Modus aus) +var world3d: Node3D # 3D-Wurzel im SubViewport (Spieler lebt hier) + # Karte 3/3 (t_11b5c25b): Dirty-Schutz + Kurzhilfe + Werkzeug-Statuszeile. var dirty_dialog: ConfirmationDialog # „Ungespeicherte Änderungen verwerfen?“ var _pending_after_dirty := Callable() # was nach „Verwerfen“ ausgeführt wird @@ -230,6 +242,7 @@ func _build_ui() -> void: top_h.add_theme_constant_override("separation", UIScale.px(10)) top.add_child(top_h) vbox.add_child(top) + ui_top_bar = top # Lauf-Modus (t_1cdc1a72): Top-Bar ausblenden _update_banner_build(vbox) var file_menu := MenuButton.new() @@ -371,16 +384,23 @@ func _build_ui() -> void: split.split_offset = UIScale.px(-320) vbox.add_child(split) - split.add_child(_build_left_panel()) + # Lauf-Modus (t_1cdc1a72): linke/rechte Spalte als Member — werden im + # Lauf-Modus ausgeblendet, die 3D-Ansicht (Mitte) bleibt stehen. + var left := _build_left_panel() + ui_left_panel = left + split.add_child(left) split.add_child(_build_center_viewport()) - split.add_child(_build_right_panel()) + var right := _build_right_panel() + ui_right_panel = right + split.add_child(right) - # --- Statusleiste: Hilfs-Meldungen + Budget --- + # --- Statusleiste: Hilfs-Meldungen + Werkzeug-Info --- var status_panel := PanelContainer.new() var status_h := HBoxContainer.new() status_h.add_theme_constant_override("separation", UIScale.px(16)) status_panel.add_child(status_h) vbox.add_child(status_panel) + ui_status_bar = status_panel # Lauf-Modus (t_1cdc1a72): Statusleiste aus status_label = Label.new() status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -803,6 +823,7 @@ func _build_center_viewport() -> SubViewportContainer: var world := Node3D.new() viewport.add_child(world) + world3d = world # Lauf-Modus (t_1cdc1a72): Spieler-Body lebt in diesem Baum view_builder = Node3D.new() view_builder.set_script(load("res://src/viewport/map_view_builder.gd")) @@ -1705,50 +1726,74 @@ func _update_finished(ok: bool, title: String, details: Array) -> void: get_tree().quit() -## „▶ Karte testen" (Karte t_b84af40f): aktuelle Map (auch ungespeichert!) -## nach Temp exportieren und den scopedvr-client als EIGENES Fenster starten. -## Der Editor bleibt offen; das Spiel beendet sich selbst sauber (ESC). +## „▶ Karte testen" (Karte t_1cdc1a72): Lauf-Modus IM Map-Builder selbst — +## erste Person, WASD + Maus, ohne Client-Ordner/VR/Server (Slys verbindlicher +## Wunsch: der Map-Builder ist überall installierbar und testet die Map ohne +## jegliche ScopedVR-Client-Abhängigkeit). Der ALTE Client-Start (Export nach +## Temp + eigener Client-Prozess, Karte t_b84af40f) bleibt als „alt" in +## src/editor/test_run.gd — der Rückbau kommt in einer späteren Karte. func _on_test_run() -> void: if doc.is_empty(): _set_status("Erst eine Map anlegen (Datei → Neue Map), dann testen.", true) return - var cfg := TestRun.load_cfg() - var client_dir := TestRun.find_client(ProjectSettings.globalize_path("res://"), cfg) - if client_dir.is_empty(): - _show_errors("Karte testen — das Spiel wurde nicht gefunden:", [ - "Der ScopedVR-Client (Ordner „scopedvr-client“) wurde nicht neben dem Editor gefunden.", - "So geht es: Den Client-Ordner neben den Editor-Ordner legen (gleicher Überordner),", - "ODER in der Datei scopedvr_editor.cfg im Ordner „ScopedVR Map Editor“ (Godot-App-Daten) eintragen:", - " [test]", - " client_dir=\"/pfad/zu/scopedvr-client\"", - " godot_path=\"/pfad/zu/godot\" (nur nötig, wenn der Editor NICHT über Godot gestartet wird)", - ]) - _set_status("Karte testen: Spiel nicht gefunden — siehe Fenster.", true) + _start_playtest() + + +## Lauf-Modus anwerfen: Werkzeuge einfrieren, Panels ausblenden, Maus einfangen, +## PlaytestMode in der 3D-Welt anlegen (Spieler am ersten Spawnpunkt der Map). +func _start_playtest() -> void: + if playtest != null: return - var godot := TestRun.find_godot(cfg) - if godot.is_empty(): - _show_errors("Karte testen — Godot-Programm nicht gefunden:", [ - "Der Editor läuft gerade nicht über ein Godot-Programm, deshalb weiß er nicht,", - "wie er das Spiel-Fenster starten soll. Trage in scopedvr_editor.cfg ein:", - " [test]", - " godot_path=\"/pfad/zu/godot\"", - ]) - _set_status("Karte testen: Godot-Programm nicht gefunden — siehe Fenster.", true) + # Editor-Zustand einfrieren (ohne Auswahl/Dokument/History anzufassen): + # laufende Ghost-Platzierung zurückrollen, Gizmo-Zug abbrechen (beides + # rollt ohne History-Eintrag zurück), Werkzeug-Modus auf Auswahl umschalten. + interaction.call("cancel_placement") + if gizmo != null and gizmo.has_method("cancel_drag_if_active"): + gizmo.call("cancel_drag_if_active") + _playtest_tool_mode = tool_mode + tool_mode = "objects" + _playtest_set_frozen(true) + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + playtest = load("res://src/editor/playtest/playtest_mode.gd").new() + playtest.ui_panels = [ui_top_bar, ui_left_panel, ui_right_panel, ui_status_bar] + playtest.ui_status = status_label + playtest.help_label = help_label + playtest.exit_requested.connect(_on_playtest_exit_requested) + world3d.add_child(playtest) + camera.current = false # Editor-Orbit abgeben — die Spiel-Kamera übernimmt + playtest.enter(doc, view_builder) + _set_status("▶ Testlauf läuft: WASD laufen, Shift rennen, Space springen, Maus umsehen — ESC zurück zum Editor.") + + +## Editor-Input einfrieren/freigeben (Kamera-Flug, Werkzeuge, Gizmo). +## Nur auf die Werkzeug-Nodes — die Sicht (view_builder) bleibt 1:1 stehen. +func _playtest_set_frozen(on: bool) -> void: + for t in [camera, interaction, terrain_tool, spawn_tool, build_tool]: + if t == null: + continue + t.set_process_unhandled_input(not on) + t.set_process(not on) + if gizmo != null: + gizmo.set_process_unhandled_input(not on) + gizmo.set_process(not on) + gizmo.visible = not on + + +## ESC im Lauf-Modus: sauber zurück in den Editor — Werkzeuge + Panels wieder +## an, Maus frei, Kamera + Auswahl unverändert, Dokument + Undo-Stapel unverändert. +func _on_playtest_exit_requested() -> void: + if playtest == null: return - var exported := TestRun.export_temp_map(doc) - if not bool(exported["ok"]): - _show_errors("Karte testen — die Map ist noch nicht lauffähig:", exported["errors"]) - _set_status("Testlauf nicht möglich — siehe Fenster für die fehlenden Punkte (z. B. Spawn-Zonen).", true) - return - var map_name := str(doc.get("info", {}).get("name", "?")) - var args := TestRun.launch_args(client_dir, str(exported["path"])) - var pid := OS.create_process(godot, args) - if pid <= 0: - _show_errors("Karte testen — Start fehlgeschlagen:", [ - "Das Spiel-Fenster konnte nicht gestartet werden (Programm: %s)." % godot]) - _set_status("Karte testen: Start fehlgeschlagen.", true) - return - _set_status("▶ Karte „%s“ läuft im Test-Fenster — WASD laufen, ESC zurück zum Editor. Der Editor bleibt offen." % map_name) + var mode: Node = playtest + playtest = null + mode.exit() + mode.queue_free() + _playtest_set_frozen(false) + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + camera.current = true + tool_mode = _playtest_tool_mode + _update_tool_label() + _set_status("Testlauf beendet — zurück im Editor (Auswahl und Dokument unverändert).") func _on_new_map_confirmed() -> void: @@ -1961,6 +2006,13 @@ func _unhandled_key_input(event: InputEvent) -> void: # bei jeder Taste, GUI-Start-Befund 15.10.). Die Godot-4-Ersatz-API ist # is_command_or_control_pressed(): Ctrl auf Linux/Windows, Cmd auf macOS. var ctrl: bool = key_evt.is_command_or_control_pressed() + # Lauf-Modus (t_1cdc1a72): nur ESC behandeln — ALLE anderen Editor-Tasten + # (F1-F5, Undo/Redo, Werkzeuge) sind im Spiel eingefroren. + if playtest != null and playtest.active: + if key == KEY_ESCAPE: + playtest.handle_escape() + get_viewport().set_input_as_handled() + return match key: KEY_Z: if ctrl: diff --git a/tests/test_playtest.gd b/tests/test_playtest.gd new file mode 100644 index 0000000..e9f59eb --- /dev/null +++ b/tests/test_playtest.gd @@ -0,0 +1,288 @@ +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_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 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") diff --git a/tests/test_playtest.gd.uid b/tests/test_playtest.gd.uid new file mode 100644 index 0000000..9a0afb5 --- /dev/null +++ b/tests/test_playtest.gd.uid @@ -0,0 +1 @@ +uid://be0euw1c2aq60