From a5d8f7f79db1ad526a6c346575e5040ed24fbff4 Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 08:22:47 +0000 Subject: [PATCH] =?UTF-8?q?T1b=20t=5F96e3c4ee:=20Test-Startpunkt=20per=20K?= =?UTF-8?q?lick=20=E2=80=94=20Toolbar/Taste=20T=20setzt=20den=20Punkt,=20w?= =?UTF-8?q?o=20F5=20den=20Testlauf=20startet=20(H=C3=B6he=20automatisch=20?= =?UTF-8?q?vom=20Terrain,=20Klick=20auf=20Objekt/Bauteil=20=3D=20Boden=20d?= =?UTF-8?q?arunter).=20Sidecar=20user://scopedvr=5Fteststart.cfg=20je=20Ka?= =?UTF-8?q?rtenpfad,=20KEIN=20.svmap-Eintrag,=20kein=20Undo,=20kein=20Spaw?= =?UTF-8?q?n-Check-Kontakt.=20F5-Fallback=20mit=20Klartext:=20Test-Start?= =?UTF-8?q?=20=E2=86=92=20erster=20Team-Spawnpunkt=20=E2=86=92=20Kartenmit?= =?UTF-8?q?te.=20T=20im=20Testlauf=20versetzt=20sofort=20zum=20Blickpunkt?= =?UTF-8?q?=20(Augenh=C3=B6he=201,70=20m,=20Kollision=20gepr=C3=BCft)=20un?= =?UTF-8?q?d=20setzt=20den=20Punkt=20neu.=20Marker=20(S=C3=A4ule+Ring,=20A?= =?UTF-8?q?kzentfarbe)=20sichtbar=20solange=20gesetzt.=20Suite=20tests/tes?= =?UTF-8?q?t=5Fplaytest=5Fspawn.gd=2056/56,=20Voll-Batterie=2025/25=20gr?= =?UTF-8?q?=C3=BCn,=20Renderprobe=20(4=20PNGs)=20unter=20/opt/data/scopedv?= =?UTF-8?q?r/work/t96e3=5Fbeweise/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tasten.md | 3 +- src/editor/playtest/playtest_body.gd | 64 ++++ src/editor/playtest/playtest_mode.gd | 46 ++- src/editor/playtest/test_start.gd | 92 +++++ src/editor/playtest/test_start.gd.uid | 1 + src/editor/playtest/test_start_store.gd | 64 ++++ src/editor/playtest/test_start_store.gd.uid | 1 + src/editor/playtest/test_start_tool.gd | 272 ++++++++++++++ src/editor/playtest/test_start_tool.gd.uid | 1 + src/ui/help_text.gd | 3 +- src/ui/main.gd | 193 +++++++++- tests/probe_m3_partner_render.gd.uid | 1 + tests/probe_t1b_render.gd | 141 +++++++ tests/probe_t1b_render.gd.uid | 1 + tests/test_playtest_spawn.gd | 383 ++++++++++++++++++++ tests/test_playtest_spawn.gd.uid | 1 + 16 files changed, 1256 insertions(+), 11 deletions(-) create mode 100644 src/editor/playtest/test_start.gd create mode 100644 src/editor/playtest/test_start.gd.uid create mode 100644 src/editor/playtest/test_start_store.gd create mode 100644 src/editor/playtest/test_start_store.gd.uid create mode 100644 src/editor/playtest/test_start_tool.gd create mode 100644 src/editor/playtest/test_start_tool.gd.uid create mode 100644 tests/probe_m3_partner_render.gd.uid create mode 100644 tests/probe_t1b_render.gd create mode 100644 tests/probe_t1b_render.gd.uid create mode 100644 tests/test_playtest_spawn.gd create mode 100644 tests/test_playtest_spawn.gd.uid diff --git a/docs/tasten.md b/docs/tasten.md index 56103ce..708d1e9 100644 --- a/docs/tasten.md +++ b/docs/tasten.md @@ -202,7 +202,8 @@ fixiert sie mit **L** oder dem Button „≈ Höhenlinien“ oben. Das Relief-Sh | Eingabe | Wirkung | | --- | --- | | **H** | Kurzhilfe-Overlay an/aus — alle Tasten dieser Datei als Panel über der Karte; Esc schließt ebenfalls. Nicht-blockierend: der Editor bleibt bedienbar | -| **F5** | ▶ Karte testen (aktuelle Map sofort als Spiel durchlaufen; der Editor bleibt offen) | +| **T** | Werkzeug: Test-Startpunkt setzen (Karte t_96e3c4ee): Klick in die Karte = dort startet F5. Höhe kommt automatisch vom Terrain; Klick auf Objekt/Bauteil = Boden darunter; Entf im Werkzeug löscht den Punkt. **KEIN Spiel-Spawnpunkt** — er landet in einer Editor-Sidecar-Datei, nicht in der `.svmap` (Spawn-Checks/Analysen unberührt). Im Testlauf versetzt **T** dich sofort zum Blickpunkt und setzt dort den Test-Start | +| **F5** | ▶ Karte testen (aktuelle Map sofort als Spiel durchlaufen; der Editor bleibt offen). Start-Reihenfolge: gesetzter Test-Start → erster Team-Spawnpunkt → Kartenmitte (je mit Klartext-Hinweis) | Die Statuszeile unten zeigt PERMANENT das aktive Werkzeug („Werkzeug: …“) plus den Hinweis „Umschalt+F = Flugmodus“ — man sieht immer, wo man ist. diff --git a/src/editor/playtest/playtest_body.gd b/src/editor/playtest/playtest_body.gd index a2fb247..97ab07f 100644 --- a/src/editor/playtest/playtest_body.gd +++ b/src/editor/playtest/playtest_body.gd @@ -268,6 +268,70 @@ func _ground_height_at(x: float, z: float, feet_y: float) -> float: return g +## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im +## Testlauf): Strahl aus dem Auge → erster Treffer auf Terrain ODER Hindernis- +## AABB; gelandet wird auf dem Boden DARUNTER (Terrain + begehbare Block-Decken +## bis zum Ziel-Höhenpunkt). Nie im Objekt landen: ist der Zielpunkt von einem +## BLOCKIERENDEN Hindernis belegt, wird der Teleport abgelehnt (ok=false). +## Rückgabe: {ok, x, z, ground, msg} — msg im Klartext für HUD/Statuszeile. +func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary: + # Terrain-Treffer (derselbe Decoder wie im Editor-Werkzeug). + var t_hit: Variant = TerrainField.raycast(heights, res, size_x, size_z, origin, dir) + var t_dist := INF + if t_hit != null: + t_dist = origin.distance_to(t_hit as Vector3) + # Hindernis-Treffer: nähester AABB-Einstiegspunkt gewinnt gegen Terrain. + var o_dist := INF + var o_point := Vector3.ZERO + var d := dir + if d.length_squared() > 0.000000001: + d = d.normalized() + for ab in obstacles: + var box: AABB = ab + var t := TestStart.ray_aabb(origin, d, box) + if t >= 0.0 and t < o_dist: + o_dist = t + o_point = origin + d * t + # Ziel = der nähere der beiden Treffer. + var tx := 0.0 + var tz := 0.0 + var hit_y := 0.0 + var found := false + if o_dist < t_dist: + tx = o_point.x + tz = o_point.z + hit_y = o_point.y + found = true + elif t_hit != null: + var th := t_hit as Vector3 + tx = th.x + tz = th.z + hit_y = th.y + found = true + if not found: + return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, + "msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."} + # Boden unter dem Zielpunkt: Terrain + begehbare Block-Decken bis zum + # Ziel-Höhenpunkt (Ziel auf einem Kisten-Dach = dort oben landen). + var ground := terrain_height(tx, tz) + var ceiling := maxf(hit_y, ground) + step_height + for ab in obstacles: + var box: AABB = ab + if box.end.y > ceiling or box.end.y <= ground: + continue + if tx < box.position.x or tx > box.end.x or tz < box.position.z or tz > box.end.z: + continue + ground = box.end.y + if _blocked_at(tx, tz, ground): + return {"ok": false, "x": tx, "z": tz, "ground": ground, + "msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]} + position = Vector3(tx, ground, tz) + velocity = Vector3.ZERO + on_floor = true + _apply_look() + return {"ok": true, "x": tx, "z": tz, "ground": ground, "msg": ""} + + ## 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: diff --git a/src/editor/playtest/playtest_mode.gd b/src/editor/playtest/playtest_mode.gd index 2776f5d..cc3d2bf 100644 --- a/src/editor/playtest/playtest_mode.gd +++ b/src/editor/playtest/playtest_mode.gd @@ -35,6 +35,7 @@ var hud: CanvasLayer = null var hud_pos: Label = null var hud_fps: Label = null var hud_hint: Label = null +var hud_msg: Label = null # Klartext-Nachricht (z. B. Test-Start via T) ## FPS-Smoothing (Anzeige, keine Physik) var _fps_smooth := 0.0 ## Ist der Lauf-Modus gerade aktiv? (Reentrancy-Schutz für enter/exit) @@ -44,10 +45,13 @@ 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. +## legt den Spieler am Startpunkt an. Fallback-Reihenfolge (Karte t_96e3c4ee, +## je mit Klartext-Hinweis): gesetzter Test-Start (start_override) → erster +## Spawnpunkt (Team Blau) → Kartenmitte. start_override = Vector3 (nur x/z +## zählen, Höhe kommt automatisch vom Terrain) oder null (= altes Verhalten). ## 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: +func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null) -> void: if active: return active = true @@ -58,6 +62,9 @@ func enter(doc: Dictionary, view_builder: Node3D) -> void: body.call("set_terrain", doc) body.obstacles = _collect_obstacles(doc, view_builder) var start := _spawn_start(doc) + if start_override != null: + var sp := start_override as Vector3 + start = Vector3(sp.x, 0.0, sp.z) body.position = Vector3(start.x, body.terrain_height(start.x, start.z), start.z) add_child(body) @@ -163,13 +170,44 @@ func _build_hud() -> void: 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]: + hud_msg = Label.new() + hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen · T = zum Blickpunkt versetzen · ESC = zurück zum Editor" + hud_msg.text = "" + for l: Label in [hud_pos, hud_fps, hud_hint, hud_msg]: l.add_theme_font_size_override("font_size", 16) + l.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0)) vbox.add_child(l) add_child(hud) +## Klartext-Nachricht im HUD (die Editor-Statuszeile ist im Lauf-Modus +## ausgeblendet — hier landet das Feedback für Taste T, Karte t_96e3c4ee). +func show_message(text: String) -> void: + if hud_msg != null and is_instance_valid(hud_msg): + hud_msg.text = text + + +## Taste T im Testlauf (Karte t_96e3c4ee, „jederzeit“): setzt den Test-Start +## auf den Punkt unterm Fadenkreuz und versetzt den Spieler SOFORT dorthin +## (Augenhöhe 1,70 m via look_cam-Offset, Kollision geprüft — nie in Objekten). +## Das Speichern (Sidecar) + Marker-Update macht main.gd mit der Rückgabe. +## Rückgabe: {ok, x, z, ground, msg} aus PlaytestBody.teleport_to_ground. +func teleport_crosshair() -> Dictionary: + if not active or body == null or body.look_cam == null: + return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, "msg": ""} + var vp: Viewport = body.look_cam.get_viewport() + var center: Vector2 = (vp.get_visible_rect().size if vp != null else Vector2(800, 600)) * 0.5 + var origin: Vector3 = body.look_cam.project_ray_origin(center) + var dir: Vector3 = body.look_cam.project_ray_normal(center) + var res: Dictionary = body.call("teleport_to_ground", origin, dir) + if bool(res.get("ok")): + show_message("Test-Start gesetzt: x=%.0f z=%.0f — du bist jetzt dort (Höhe automatisch)." % [ + float(res.get("x")), float(res.get("z"))]) + else: + show_message(str(res.get("msg", ""))) + return res + + func _process(delta: float) -> void: if not active or body == null: return diff --git a/src/editor/playtest/test_start.gd b/src/editor/playtest/test_start.gd new file mode 100644 index 0000000..0918428 --- /dev/null +++ b/src/editor/playtest/test_start.gd @@ -0,0 +1,92 @@ +extends RefCounted +class_name TestStart +## Test-Startpunkt (Karte t_96e3c4ee): der Punkt, an dem „▶ Karte testen“ (F5) +## den Spieler absetzt — KEIN Spiel-Spawnpunkt (taucht nicht in doc, +## Spawn-Checks/Analysen oder Validator auf, ändert keine Zonen). +## +## Bewusste Entscheidungen dieser Karte: +## - Der Punkt lebt NUR editor-seitig (Sidecar, test_start_store.gd) — das +## .svmap-Format (Validator V1–V16, Client/Server) bleibt UNBERÜHRT. +## - Gespeichert wird nur x/z. Die Höhe kommt IMMER automatisch aus dem +## Terrain an der Stelle (SvmapDocument.terrain_height_at) — Terrain +## formen lässt den Punkt nicht schweben und versinken. +## - Fallback-Reihenfolge F5 (je mit Klartext-Hinweis): gesetzter Test-Start +## → erster Team-Spawnpunkt (Team Blau, wie bisher) → Kartenmitte. +## - Trifft der Klick ein Objekt/Bauteil: landet der Punkt auf dem BODEN +## DARUNTER (nie im Objekt schwebend) — das Tool meldet es im Klartext. +## +## Alles pure Statics (kein UI, kein Node-Zugriff) → headless testbar in +## tests/test_playtest_spawn.gd. + +## Kein Punkt gesetzt (Sentinel-Typ für Variant-Vergleiche in main.gd). + +## Start-Punkt für den Testlauf auflösen (Fallback-Kette). test_point ist +## Vector3 (nur x/z zählen) oder null. Rückgabe: +## {pos: Vector3 (y = Terrainhöhe), quelle: "test"|"team"|"mitte", +## status: Klartext für die Statuszeile} +static func resolve_start(doc: Dictionary, test_point: Variant) -> Dictionary: + if test_point != null: + var p: Vector3 = test_point + var y := SvmapDocument.terrain_height_at(doc, p.x, p.z) + return { + "pos": Vector3(p.x, y, p.z), + "quelle": "test", + "status": "Start an deinem gesetzten Test-Startpunkt (%.0f, %.0f, Höhe automatisch)." % [p.x, p.z], + } + # Erster Team-Spawnpunkt (Team Blau = „0“) — exakt wie bisher (t_1cdc1a72). + 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 { + "pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])), + "quelle": "team", + "status": "Kein Test-Start gesetzt — Start am ersten Team-Blau-Spawnpunkt.", + } + # Kartenmitte aus den Bounds. + 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]) + var cx := (float(mn[0]) + float(mx[0])) * 0.5 + var cz := (float(mn[2]) + float(mx[2])) * 0.5 + return { + "pos": Vector3(cx, SvmapDocument.terrain_height_at(doc, cx, cz), cz), + "quelle": "mitte", + "status": "Weder Test-Start noch Team-Spawnpunkte — Start in der Kartenmitte.", + } + + +## Strahl → erster Treffer auf einer AABB (Slab-Methode) oder -1.0. +## Für „Klick trifft Bauteil“ im Editor-Tool und „Fadenkreuz trifft Hindernis“ +## im Testlauf (kein Physik-Server im Editor/SubViewport). +## Rückgabe: Distanz t >= 0 (origin + dir * t) oder -1.0 = kein Treffer. +static func ray_aabb(origin: Vector3, dir: Vector3, box: AABB) -> float: + var d := dir + if d.length_squared() < 0.000000001: + return -1.0 + d = d.normalized() + var tmin := 0.0 + var tmax := INF + for axis in 3: + var o := origin[axis] + var dv := d[axis] + var lo := box.position[axis] + var hi := box.end[axis] + if absf(dv) < 0.0000001: + if o < lo or o > hi: + return -1.0 + continue + var t1 := (lo - o) / dv + var t2 := (hi - o) / dv + if t1 > t2: + var tmp := t1 + t1 = t2 + t2 = tmp + tmin = maxf(tmin, t1) + tmax = minf(tmax, t2) + if tmin > tmax: + return -1.0 + return tmin diff --git a/src/editor/playtest/test_start.gd.uid b/src/editor/playtest/test_start.gd.uid new file mode 100644 index 0000000..2a04600 --- /dev/null +++ b/src/editor/playtest/test_start.gd.uid @@ -0,0 +1 @@ +uid://b4barijsv6qs2 diff --git a/src/editor/playtest/test_start_store.gd b/src/editor/playtest/test_start_store.gd new file mode 100644 index 0000000..6023828 --- /dev/null +++ b/src/editor/playtest/test_start_store.gd @@ -0,0 +1,64 @@ +extends RefCounted +class_name TestStartStore +## Sidecar-Speicher für den Test-Startpunkt (Karte t_96e3c4ee) — bewusst +## NICHT im .svmap-Format (Validator V1–V16 + Client/Server bleiben unberührt; +## eine Map ohne den Punkt verhält sich exakt wie bisher). +## +## Ablage: user://scopedvr_teststart.cfg, Abschnitt [test_start], je Kartenpfad +## ein Schlüssel (globalisierter Pfad). Ungespeicherte Maps landen unter dem +## Sammel-Schlüssel UNGESPEICHERT (Session-Speicher über Neustarts hinaus nur, +## wenn die Map gespeichert wird — der Punkt wandert beim Speichern mit). +## Gespeichert werden nur x/z: die Höhe kommt immer automatisch vom Terrain. + +const CFG_PATH := "user://scopedvr_teststart.cfg" +const SECTION := "test_start" +const UNGESPEICHERT := "__ungespeichert__" + + +## Schlüssel für einen Kartenpfad (leer = ungespeicherte Map). +static func key_for(map_path: String) -> String: + if map_path.is_empty(): + return UNGESPEICHERT + return ProjectSettings.globalize_path(map_path) + + +## Gespeicherten Punkt lesen (Vector3 mit y=0 — Höhe kommt vom Terrain) +## oder null, wenn keiner gesetzt ist. +static func load_point(map_path: String) -> Variant: + var cfg := ConfigFile.new() + if cfg.load(CFG_PATH) != OK: + return null + var k := key_for(map_path) + var x: Variant = cfg.get_value(SECTION, k + "/x", null) + var z: Variant = cfg.get_value(SECTION, k + "/z", null) + if x == null or z == null: + return null + return Vector3(float(x), 0.0, float(z)) + + +## Punkt speichern (nur x/z — Höhe automatisch). +static func save_point(map_path: String, pos: Vector3) -> void: + var cfg := ConfigFile.new() + cfg.load(CFG_PATH) # Fehler → leere Config, Punkt wird neu angelegt. + var k := key_for(map_path) + cfg.set_value(SECTION, k + "/x", pos.x) + cfg.set_value(SECTION, k + "/z", pos.z) + cfg.save(CFG_PATH) + + +## Punkt löschen (Datei bleibt — andere Maps sind unberührt). +## Bewusst Sektions-Neuaufbau statt erase_section_key: Godots +## erase_section_key löscht Keys mit "/" im Namen still NICHT (Probe 20.10.). +static func clear_point(map_path: String) -> void: + var cfg := ConfigFile.new() + if cfg.load(CFG_PATH) != OK: + return + var k := key_for(map_path) + var keep := {} + for key in cfg.get_section_keys(SECTION): + if not key.begins_with(k + "/"): + keep[key] = cfg.get_value(SECTION, key) + cfg.erase_section(SECTION) + for key in keep: + cfg.set_value(SECTION, key, keep[key]) + cfg.save(CFG_PATH) diff --git a/src/editor/playtest/test_start_store.gd.uid b/src/editor/playtest/test_start_store.gd.uid new file mode 100644 index 0000000..6109a49 --- /dev/null +++ b/src/editor/playtest/test_start_store.gd.uid @@ -0,0 +1 @@ +uid://b3gc8f1aecsjl diff --git a/src/editor/playtest/test_start_tool.gd b/src/editor/playtest/test_start_tool.gd new file mode 100644 index 0000000..5fe43a6 --- /dev/null +++ b/src/editor/playtest/test_start_tool.gd @@ -0,0 +1,272 @@ +extends Node3D +## Test-Startpunkt-Werkzeug (Karte t_96e3c4ee): „hinklicken, wo man beim +## Testen spawnt“ — ein Klick auf Boden/Terrain setzt den Test-Startpunkt. +## KEIN Spiel-Spawnpunkt: der Punkt lebt NICHT im .svmap (Sidecar via +## TestStartStore), taucht in keinem Spawn-Check/Keiner Analyse auf und hat +## KEINEN Undo-Eintrag (editor-seitiger Session-Zustand, wie die UI-Größe). +## +## Bedienung (Einbindung in main.gd, wie T1 den Lauf-Modus angemeldet hat): +## Toolbar-Knopf „🎯 Test-Start“ oder Taste T → Werkzeug an +## Linksklick auf den Boden → Test-Start setzen (Höhe automatisch) +## Linksklick auf Objekt/Bauteil → Punkt auf den Boden DARUNTER (Klartext- +## Hinweis: „auf dem Boden unter dem Objekt“) +## Entf (im Werkzeug) / „Löschen“-Knopf rechts → Test-Start löschen +## ESC → Werkzeug beenden +## Rechte Maustaste: bewusst NICHT behandelt — sie gehört dem Freelook-Flug +## (Karte t_2470de89, absolute Priorität wie in jedem anderen Werkzeug). +## +## Marker (schmale Säule + Ring, Editor-Akzentfarbe) bleibt sichtbar, solange +## ein Punkt gesetzt ist — auch außerhalb des Werkzeugs. Vorschau-Ring folgt +## dem Cursor nur IM Werkzeug (wie der Spawn-Zonen-Ghost). + +## Editor-Akzentfarbe (wie Statuszeile/Kurzhilfe in main.gd) — „Farbe aus dem +## Theme“ laut Karte; dieselbe Konstante, keine neue Farbwelt. +const ACCENT := Color(0.55, 0.8, 1.0) + +var main # Hauptfenster (script-erweitert, Variant) +var camera: Camera3D +var view_builder # Welt-Bauer (script-erweitert, Variant) +var interaction # Objekt-Interaktion (für Objekt-Treffer-Test, nur LESEND) + +var active := false + +# Terrain-Cache für Cursor-Raycast (nur LESEN — dieses Werkzeug formt nichts) +var _heights := PackedFloat32Array() +var _res := 0 +var _sx := 0.0 +var _sz := 0.0 + +var _ghost: Node3D +var _marker: Node3D +var _cursor_valid := false +var _cursor := Vector3.ZERO + + +func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void: + main = p_main + camera = p_camera + view_builder = p_builder + interaction = p_interaction + _ensure_ghost() + _ensure_marker() + + +# ------------------------------------------------------------ Aktivierung --- + +func activate() -> bool: + active = true + refresh_from_doc() + return true # nie verweigert — der Test-Start braucht kein Auflösungs-Limit + + +func deactivate() -> void: + active = false + _cursor_valid = false + if _ghost != null and is_instance_valid(_ghost): + _ghost.visible = false + + +## Nach Laden/Neu/Speichern (Pfadwechsel → anderer Sidecar-Schlüssel) und +## nach Terrain-Änderungen: Cache + Marker aus main.test_start_point frisch. +func refresh_from_doc() -> void: + var terrain: Dictionary = main.doc.get("terrain", {}) + _res = int(terrain.get("resolution", 0)) + _sx = float(terrain.get("size_x_m", 0.0)) + _sz = float(terrain.get("size_z_m", 0.0)) + if _res >= 2 and _sx > 0.0 and _sz > 0.0: + _heights = SvmapIO.decode_heights(terrain) + else: + _heights = PackedFloat32Array() + _update_marker() + + +# --------------------------------------------------------------- Eingaben --- + +func _unhandled_input(event: InputEvent) -> void: + if not active: + return + if event is InputEventMouseMotion: + _update_cursor((event as InputEventMouseMotion).position) + return + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + get_viewport().set_input_as_handled() + _click(mb.position) + + +func _click(viewport_pos: Vector2) -> void: + # 1) Objekt-Treffer? (Kugel-Approx der interaction — derselbe Pfad wie die + # Auswahl; bewusst nur GERUFEN, interaction.gd bleibt unangetastet.) + var obj_idx: Variant = interaction.call("_pick_object", viewport_pos) + var hit_obj := false + if obj_idx is int and int(obj_idx) >= 0: + hit_obj = true + # 2) Bauteil-Treffer? (AABB-Strahl über BuildOps.piece_aabb — build_ops.gd + # wird nur gelesen/nutzend gerufen, nicht geändert.) + var origin := camera.project_ray_origin(viewport_pos) + var dir := camera.project_ray_normal(viewport_pos) + var hit_build := false + if not hit_obj: + var t_terrain := INF + var ground: Variant = _ray_hit(viewport_pos) + if ground != null: + t_terrain = origin.distance_to(ground as Vector3) + var best_t := INF + for teil in main.doc.get("gebaeude", []): + if not (teil is Dictionary): + continue + var ab: AABB = BuildOps.piece_aabb(teil) + if ab.size.length_squared() <= 0.0: + continue + var t := TestStart.ray_aabb(origin, dir, ab) + if t >= 0.0 and t < best_t and t < t_terrain + 0.01: + best_t = t + hit_build = true + # 3) Punkt setzen: x/z vom Strahl-Treffer, Höhe IMMER vom Terrain (nie + # im Objekt schwebend, nie versunken). + var hit: Variant = _ray_hit(viewport_pos) + if hit == null: + main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) + return + var p: Vector3 = _clamped(hit as Vector3) + var hinweis := "" + if hit_obj: + hinweis = "auf dem Boden unter dem Objekt" + elif hit_build: + hinweis = "auf dem Boden unter dem Bauteil" + main.call("_test_start_apply", p.x, p.z, hinweis) + + +func _update_cursor(viewport_pos: Vector2) -> void: + var hit: Variant = _ray_hit(viewport_pos) + if hit == null: + _cursor_valid = false + if _ghost != null and is_instance_valid(_ghost): + _ghost.visible = false + return + _cursor_valid = true + _cursor = _clamped(hit as Vector3) + if _ghost != null and is_instance_valid(_ghost): + _ghost.position = Vector3(_cursor.x, _cursor.y + 0.06, _cursor.z) + _ghost.visible = true + + +## Strahl → Punkt auf dem Terrain (wie Terrain-/Spawn-Werkzeug; +## TerrainField.raycast — favorisiert echte Geländeberührung). +func _ray_hit(viewport_pos: Vector2) -> Variant: + if _res >= 2 and _heights.size() >= _res * _res: + return TerrainField.raycast(_heights, _res, _sx, _sz, + camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos)) + var terrain: Dictionary = main.doc.get("terrain", {}) + if terrain.is_empty(): + return null + var plane_y := float(terrain.get("min_height_m", 0.0)) + var origin := camera.project_ray_origin(viewport_pos) + var dir := camera.project_ray_normal(viewport_pos) + if absf(dir.y) < 0.0001: + return null + var t := (plane_y - origin.y) / dir.y + if t <= 0.0: + return null + return origin + dir * t + + +func _clamped(p: Vector3) -> Vector3: + var half := Vector2(_sx, _sz) * 0.5 + p.x = clampf(p.x, -half.x, half.x) + p.z = clampf(p.z, -half.y, half.y) + p.y = SvmapDocument.terrain_height_at(main.doc, p.x, p.z) + return p + + +# ----------------------------------------------------------------- Marker --- + +## Marker an den gesetzten Punkt hängen (oder ausblenden). Höhe IMMER +## automatisch: Terrainhöhe an (x, z) — nach Terrain-Formen bleibt der +## Punkt auf dem Boden (kein Schweben, kein Versinken). +func _update_marker() -> void: + _ensure_marker() + var point: Variant = main.get("test_start_point") + if point == null: + _marker.visible = false + return + var p: Vector3 = point + p.y = SvmapDocument.terrain_height_at(main.doc, p.x, p.z) + _marker.position = p + _marker.visible = true + + +# ------------------------------------------------------- Optik (Ghost/Marker) -- + +func _accent_mat(alpha: float) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(ACCENT.r, ACCENT.g, ACCENT.b, alpha) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.emission_enabled = true + mat.emission = ACCENT * 0.35 + return mat + + +func _ensure_ghost() -> void: + if _ghost != null and is_instance_valid(_ghost): + return + _ghost = Node3D.new() + _ghost.visible = false + add_child(_ghost) + var ring := MeshInstance3D.new() + var torus := TorusMesh.new() + torus.inner_radius = 0.65 + torus.outer_radius = 0.85 + torus.rings = 48 + torus.ring_segments = 6 + ring.mesh = torus + ring.position.y = 0.05 + ring.material_override = _accent_mat(0.6) + _ghost.add_child(ring) + + +## Marker: schmale Säule + Ring am Boden (Karten-Vorgabe), Akzentfarbe. +func _ensure_marker() -> void: + if _marker != null and is_instance_valid(_marker): + return + _marker = Node3D.new() + _marker.visible = false + add_child(_marker) + var pillar := MeshInstance3D.new() + var cyl := CylinderMesh.new() + cyl.top_radius = 0.12 + cyl.bottom_radius = 0.12 + cyl.height = 4.0 + pillar.mesh = cyl + pillar.position.y = 2.0 # Fußpunkt auf dem Terrain + pillar.material_override = _accent_mat(0.55) + _marker.add_child(pillar) + var ring := MeshInstance3D.new() + var torus := TorusMesh.new() + torus.inner_radius = 0.55 + torus.outer_radius = 0.75 + torus.rings = 48 + torus.ring_segments = 6 + ring.mesh = torus + ring.position.y = 0.05 + ring.material_override = _accent_mat(0.85) + _marker.add_child(ring) + + +# ------------------------------------------------------------ Demo/Tests ---- + +## Demo-Hook (Suite/Renderprobe): Punkt programmatisch setzen — macht exakt, +## was ein Klick machen würde (ohne echte Maus-Ereignisse). hinweis = derselbe +## Klartext wie beim echten Klick ("" = freier Boden, sonst „unter dem Objekt“). +func demo_set(x: float, z: float, hinweis := "") -> void: + main.call("_test_start_apply", x, z, hinweis) + + +## Demo-Hook: Ghost an Position parken (Renderprobe ohne Maus). +func demo_park_ghost(x: float, z: float) -> void: + _cursor = _clamped(Vector3(x, 0.0, z)) + _cursor_valid = true + _ghost.position = Vector3(_cursor.x, _cursor.y + 0.06, _cursor.z) + _ghost.visible = true diff --git a/src/editor/playtest/test_start_tool.gd.uid b/src/editor/playtest/test_start_tool.gd.uid new file mode 100644 index 0000000..228ca33 --- /dev/null +++ b/src/editor/playtest/test_start_tool.gd.uid @@ -0,0 +1 @@ +uid://byjsw857bu1k8 diff --git a/src/ui/help_text.gd b/src/ui/help_text.gd index 43a050c..ed9d074 100644 --- a/src/ui/help_text.gd +++ b/src/ui/help_text.gd @@ -65,7 +65,8 @@ static func text() -> String: ["F2", "Werkzeug: Objekte platzieren/auswählen"], ["F3", "Werkzeug: Bauen (Häuser)"], ["F4", "Werkzeug: Spawn-Zonen malen"], - ["F5", "▶ Karte testen"], + ["T", "Werkzeug: Test-Startpunkt setzen — Klick in die Karte = dort startet F5 (KEIN Spiel-Spawnpunkt; im Testlauf versetzt T dich sofort zum Blickpunkt)"], + ["F5", "▶ Karte testen (Start: Test-Start → erster Team-Spawnpunkt → Kartenmitte)"], ["H", "Diese Kurzhilfe an/aus (Esc schließt auch)"], ["L", "Höhenlinien FIXIEREN (immer sichtbar/unsichtbar) — ohne Fix erscheinen sie nur im Terrain-Werkzeug (F1)"], ])) diff --git a/src/ui/main.gd b/src/ui/main.gd index 410f602..92db4d3 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -163,6 +163,18 @@ var ui_right_panel: Control # rechte Werkzeug-Panel-Spalte (im Lauf-Modus a var ui_status_bar: Control # Statusleiste (im Lauf-Modus aus) var world3d: Node3D # 3D-Wurzel im SubViewport (Spieler lebt hier) +# Test-Startpunkt (Karte t_96e3c4ee): „hinklicken, wo man beim Testen spawnt“. +# Editor-seitiger Session-Punkt — Sidecar-Datei (TestStartStore), bewusst +# NICHT im .svmap (Validator V1–V16 + Client/Server unberührt) und KEIN +# Spiel-Spawnpunkt (kein Undo, keine Spawn-Checks/Analysen). null = keiner +# gesetzt → F5 verhält sich exakt wie bisher (erster Team-Spawn, sonst Mitte). +var test_start_point: Variant = null # Vector3 (nur x/z) oder null +var test_start_tool # src/editor/playtest/test_start_tool.gd +var test_start_btn: Button # „🎯 Test-Start“ in der Top-Bar +var test_start_panel: VBoxContainer # rechte Spalte: Info + Löschen +var test_start_info: Label +var test_start_delete_btn: Button + # 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 @@ -333,6 +345,17 @@ func _build_ui() -> void: test_btn.pressed.connect(_on_test_run) top_h.add_child(test_btn) + # Test-Startpunkt (Karte t_96e3c4ee): Taste T oder dieser Knopf aktiviert + # das Werkzeug — ein Klick in die Karte setzt den Punkt, an dem F5 den + # Testlauf startet (KEIN Spiel-Spawnpunkt, kein .svmap-Eintrag). + test_start_btn = Button.new() + test_start_btn.text = "🎯 Test-Start" + test_start_btn.toggle_mode = true + test_start_btn.custom_minimum_size = UIScale.v2(160, 40) + test_start_btn.tooltip_text = "Test-Startpunkt setzen (Taste T): Klicke in die Karte, wo der Testlauf (F5) starten soll. Kein Spiel-Spawnpunkt — gilt nur fürs Testen im Editor." + test_start_btn.pressed.connect(_on_test_start_btn_pressed) + top_h.add_child(test_start_btn) + # UI-Größe (Karte t_297221b5): Klein/Mittel/Groß — Sly stellt das selbst um, # es wirkt SOFORT (kompletter UI-Neuaufbau mit dem neuen Faktor) und bleibt # in user://scopedvr_editor.cfg gespeichert. @@ -869,6 +892,13 @@ func _build_center_viewport() -> SubViewportContainer: world.add_child(build_tool) build_tool.setup(self, camera, view_builder, interaction) + # Test-Startpunkt-Werkzeug (Karte t_96e3c4ee) — ditto eigener Eingabe-Knoten + # (Taste T / Toolbar): Klick setzt den Test-Start, Marker bleibt sichtbar. + test_start_tool = Node3D.new() + test_start_tool.set_script(load("res://src/editor/playtest/test_start_tool.gd")) + world.add_child(test_start_tool) + test_start_tool.setup(self, camera, view_builder, interaction) + # Bedien-Hinweis im Viewport (Overlay) help_label = Label.new() help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR / Mausrad: drehen · Strg+D: duplizieren · Bild↑/↓: anheben/absenken · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" @@ -1279,6 +1309,26 @@ func _build_right_panel() -> PanelContainer: + "(Wände aufs Fundament stapeln, Dach auf die Wände). Jedes Bauteil ist " + "EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Bauen-Modus.")) + # --- Test-Startpunkt (Karte t_96e3c4ee): Info + Löschen im Werkzeug --- + test_start_panel = VBoxContainer.new() + test_start_panel.add_theme_constant_override("separation", UIScale.px(6)) + test_start_panel.visible = false + vbox.add_child(test_start_panel) + + test_start_info = Label.new() + test_start_info.text = "" + test_start_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + test_start_panel.add_child(test_start_info) + test_start_delete_btn = Button.new() + test_start_delete_btn.text = "🗑 Test-Start löschen" + test_start_delete_btn.custom_minimum_size = UIScale.v2(0, 40) + test_start_delete_btn.tooltip_text = "Test-Startpunkt entfernen — F5 startet dann wieder am ersten Team-Spawnpunkt (sonst Kartenmitte). Entf geht auch." + test_start_delete_btn.pressed.connect(_on_test_start_delete_pressed) + test_start_panel.add_child(test_start_delete_btn) + test_start_panel.add_child(_muted_label( + "Der Test-Start ist KEIN Spiel-Spawnpunkt: er landet nur in einer " + + "Editor-Datei, nicht in der Map — Spawn-Checks und Analysen bleiben unberührt.")) + # Signale (Änderungen → Dokument + Ansicht) prop_name.text_changed.connect(_on_prop_changed.bind("name")) prop_author.text_changed.connect(_on_prop_changed.bind("author")) @@ -1410,6 +1460,11 @@ func _rebuild_ui_for_scale() -> void: if str(_update_result.get("status")) == "update_available": _show_update_banner(str(_update_result.get("local", "")), str(_update_result.get("remote", ""))) _sync_size_menu() + # Karte t_96e3c4ee: Werkzeug + Marker für den Test-Start neu aufsetzen + # (das Rebuild hat einen frischen Tool-Node ohne Marker angelegt). + if test_start_tool != null: + test_start_tool.call("refresh_from_doc") + _sync_test_start_panel() ## Fenster beim Start: maximiert (Default, kein Vollbild-Zwang) oder die @@ -1761,14 +1816,17 @@ func _start_playtest() -> void: 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.") + # Karte t_96e3c4ee: Fallback-Reihenfolge mit Klartext-Hinweis — gesetzter + # Test-Start → erster Team-Spawnpunkt → Kartenmitte. + var resolved: Dictionary = TestStart.resolve_start(doc, test_start_point) + playtest.enter(doc, view_builder, resolved["pos"]) + _set_status("▶ Testlauf läuft: WASD laufen, Shift rennen, Space springen, Maus umsehen, T = zum Blickpunkt versetzen — ESC zurück zum Editor. %s" % resolved["status"]) ## 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]: + for t in [camera, interaction, terrain_tool, spawn_tool, build_tool, test_start_tool]: if t == null: continue t.set_process_unhandled_input(not on) @@ -1803,6 +1861,7 @@ func _on_new_map_confirmed() -> void: history.begin(doc) _clear_edit_state() _refresh_all() + _test_start_reload() # Karte t_96e3c4ee: neue Map → kein Test-Start gesetzt _set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s). Wähle links ein Objekt, um zu bauen!" % [ new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()]) @@ -1819,6 +1878,7 @@ func _on_open_selected(path: String) -> void: history.begin(doc) _clear_edit_state() _refresh_all() + _test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen var map_name := str(doc.get("info", {}).get("name", "?")) _set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)]) @@ -1856,6 +1916,7 @@ func _save_to(path: String) -> void: return current_path = path dirty = false + _test_start_reload() # Karte t_96e3c4ee: Punkt wandert beim Speichern mit _refresh_file_label() _set_status("Map gespeichert: %s" % path) _refresh_tree() @@ -1926,6 +1987,7 @@ func _update_tool_label() -> void: "terrain": "Terrain (F1)", "bauen": "Bauen (F3)", "spawns": "Spawn-Zonen (F4)", + "teststart": "Test-Start (T)", } tool_label.text = "Werkzeug: %s · Umschalt+F = Flugmodus" % str(names.get(tool_mode, tool_mode)) @@ -2006,12 +2068,20 @@ 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. + # Lauf-Modus (t_1cdc1a72): nur ESC + T 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() + elif key == KEY_T: + # Karte t_96e3c4ee, „jederzeit“: T im Testlauf = Test-Start auf den + # Punkt unterm Fadenkreuz setzen + SOFORT dorthin versetzen + # (Augenhöhe 1,70 m, Kollision geprüft). Speichern + Marker: unten. + var tp: Dictionary = playtest.call("teleport_crosshair") + if bool(tp.get("ok")): + _test_start_apply(float(tp.get("x")), float(tp.get("z")), "") + get_viewport().set_input_as_handled() return match key: KEY_Z: @@ -2061,6 +2131,12 @@ func _unhandled_key_input(event: InputEvent) -> void: # (Karte t_b84af40f) — F5 ruft ihn direkt auf. _on_test_run() get_viewport().set_input_as_handled() + KEY_T: + # Karte t_96e3c4ee: T = Test-Start-Werkzeug an/aus (Toggle, wie F1–F4). + # Nur ohne Modifier + gleiche Freigabe wie F1–F5/H. + if not ctrl and _tool_key_allowed(): + _set_tool_mode("objects" if tool_mode == "teststart" else "teststart") + get_viewport().set_input_as_handled() KEY_H: # Karte 3/3: Kurzhilfe-Overlay an/aus (Esc schließt ebenfalls). if _tool_key_allowed(): @@ -2126,6 +2202,14 @@ func _unhandled_key_input(event: InputEvent) -> void: if gizmo != null and gizmo.cancel_drag_if_active(): get_viewport().set_input_as_handled() return + # Karte t_96e3c4ee: Das Test-Start-Werkzeug hat keinen ShortcutPolicy- + # Eintrag (Datei-Grenze der Karte) — Esc verlässt es direkt. Der + # gesetzte Punkt bleibt dabei erhalten (nur das Werkzeug endet). + if tool_mode == "teststart": + _set_tool_mode("objects") + _set_status("Test-Start-Werkzeug beendet — der gesetzte Punkt bleibt gesetzt (F5 startet dort).") + get_viewport().set_input_as_handled() + return # Esc-Priorität über pure Funktion (Karte t_c46d31c2, testbar): # Werkzeug verlassen → Greifen zurückrollen → Ghost-Preview # abbrechen → Auswahl aufheben (Greifen: Karte t_55257792). @@ -2161,6 +2245,10 @@ func _unhandled_key_input(event: InputEvent) -> void: spawn_tool.call("delete_selected") elif tool_mode == "bauen": build_tool.call("delete_selected") + elif tool_mode == "teststart": + # Karte t_96e3c4ee: Entf im Test-Start-Werkzeug = Punkt löschen. + _test_start_delete() + get_viewport().set_input_as_handled() else: interaction.delete_selected() get_viewport().set_input_as_handled() @@ -2320,24 +2408,34 @@ func _set_tool_mode(mode: String) -> void: _sync_terrain_button() _sync_spawn_button() _sync_build_button() + _sync_test_start_button() _update_tool_label() return if mode == "terrain": _leave_spawn_mode() _leave_build_mode() + _leave_test_start_mode() _enter_terrain_mode() elif mode == "spawns": _leave_terrain_mode() _leave_build_mode() + _leave_test_start_mode() _enter_spawn_mode() elif mode == "bauen": _leave_terrain_mode() _leave_spawn_mode() + _leave_test_start_mode() _enter_build_mode() + elif mode == "teststart": + _leave_terrain_mode() + _leave_spawn_mode() + _leave_build_mode() + _enter_test_start_mode() else: _leave_terrain_mode() _leave_spawn_mode() _leave_build_mode() + _leave_test_start_mode() _enter_objects_mode() _update_tool_label() # Karte t_89c0ec95: Werkzeugwechsel = Höhenlinien-Umschalter — nach dem @@ -2441,6 +2539,91 @@ func _sync_build_button() -> void: build_edit_btn.button_pressed = tool_mode == "bauen" +# ---------------------------------------------------- Test-Startpunkt ------- +# (Karte t_96e3c4ee — „hinklicken, wo man beim Testen spawnt“. Editor-seitig, +# Sidecar statt .svmap, KEIN Spiel-Spawnpunkt: kein Undo, keine Spawn-Checks.) + +func _on_test_start_btn_pressed() -> void: + if test_start_btn.button_pressed: + _set_tool_mode("teststart") + else: + _set_tool_mode("objects") + + +func _enter_test_start_mode() -> void: + interaction.call("cancel_placement") + interaction.call("clear_selection", true) + tool_mode = "teststart" + test_start_panel.visible = true + help_label.text = "Linksklick: Test-Start setzen (Höhe automatisch aufs Terrain) · Auf Objekt/Bauteil: Boden darunter\nEntf: Test-Start löschen · Mittlere Maustaste: Kamera · ESC: Werkzeug beenden" + test_start_btn.button_pressed = true + test_start_tool.call("activate") + _sync_test_start_panel() + _set_status("Test-Start-Werkzeug an: Klicke in die Karte, wo der Testlauf (F5) starten soll. Der Punkt ist KEIN Spiel-Spawnpunkt — er landet nur in einer Editor-Datei, nicht in der Map.") + + +func _leave_test_start_mode() -> void: + if test_start_tool != null: + test_start_tool.call("deactivate") + if test_start_panel != null: + test_start_panel.visible = false + _sync_test_start_button() + + +func _sync_test_start_button() -> void: + test_start_btn.button_pressed = tool_mode == "teststart" + + +## Panel-Zeile rechts (Info + Löschen-Knopf) mit dem Punkt-Zustand syncen. +func _sync_test_start_panel() -> void: + if test_start_info == null or test_start_delete_btn == null: + return + if test_start_point == null: + test_start_info.text = "Kein Test-Start gesetzt.\nF5 startet dann am ersten Team-Spawnpunkt (sonst Kartenmitte)." + test_start_delete_btn.disabled = true + else: + var p := test_start_point as Vector3 + test_start_info.text = "Test-Start: x=%.0f z=%.0f\n(Höhe kommt automatisch vom Terrain)" % [p.x, p.z] + test_start_delete_btn.disabled = false + + +## Klick (oder Taste T im Testlauf): Test-Start setzen. NUR x/z — die Höhe +## kommt immer automatisch vom Terrain (Marker holt sie je nach Stand). +## Kein History-Eintrag, kein doc-Eintrag, kein Spawn-Check-Kontakt. +func _test_start_apply(x: float, z: float, hinweis: String) -> void: + test_start_point = Vector3(x, 0.0, z) + TestStartStore.save_point(current_path, test_start_point) + test_start_tool.call("refresh_from_doc") + _sync_test_start_panel() + var ort := " (Höhe automatisch)" if hinweis.is_empty() else " (Höhe automatisch — %s)" % hinweis + _set_status("Test-Start: x=%.0f z=%.0f%s — F5 startet den Testlauf genau dort." % [x, z, ort]) + + +## Test-Start löschen (Entf im Werkzeug oder Knopf rechts). +func _on_test_start_delete_pressed() -> void: + _test_start_delete() + + +func _test_start_delete() -> void: + if test_start_point == null: + _set_status("Es ist kein Test-Startpunkt gesetzt — klicke zuerst mit dem Test-Start-Werkzeug (T) in die Karte.", true) + return + test_start_point = null + TestStartStore.clear_point(current_path) + test_start_tool.call("refresh_from_doc") + _sync_test_start_panel() + _set_status("Test-Start gelöscht — F5 startet wieder am ersten Team-Spawnpunkt (sonst Kartenmitte).") + + +## Nach Neu/Laden/Speichern (Pfadwechsel → anderer Sidecar-Schlüssel): den +## gespeicherten Punkt für DIESE Map wiederherstellen. Eine Map ohne Punkt +## verhält sich exakt wie bisher. +func _test_start_reload() -> void: + test_start_point = TestStartStore.load_point(current_path) + test_start_tool.call("refresh_from_doc") + _sync_test_start_panel() + + func _selected_terrain_mode() -> int: if terrain_mode_lower.button_pressed: return TerrainField.Mode.LOWER diff --git a/tests/probe_m3_partner_render.gd.uid b/tests/probe_m3_partner_render.gd.uid new file mode 100644 index 0000000..878f8c0 --- /dev/null +++ b/tests/probe_m3_partner_render.gd.uid @@ -0,0 +1 @@ +uid://dk6pg5j82wv4n diff --git a/tests/probe_t1b_render.gd b/tests/probe_t1b_render.gd new file mode 100644 index 0000000..725520a --- /dev/null +++ b/tests/probe_t1b_render.gd @@ -0,0 +1,141 @@ +extends SceneTree +## WEGWERF-Renderprobe (Karte t_96e3c4ee, Test-Startpunkt per Klick) — +## (a) Editor mit sichtbarem Marker am gesetzten Test-Start +## (b) F5 startet den Testlauf GENAU dort (HUD + Startposition) +## (c) T im Testlauf versetzt zum Blickpunkt (+ setzt den Test-Start neu) +## Ausführen: +## xvfb-run -a godot4 --path . --rendering-driver opengl3 -s res://tests/probe_t1b_render.gd +## Wegwerf-Probe (nicht im Repo-Commit), Beweise nach /opt/data/scopedvr/work/t96e3_beweise/. + +var main: Control +var subvp: SubViewport + +func _initialize() -> void: + _run.call_deferred() + +func _shot(name: String) -> void: + await process_frame + await process_frame + await RenderingServer.frame_post_draw + # ROOT-Viewport = das ganze Fenster (Editor-UI + 3D-Ansicht + HUD) — + # exakt das, was Sly auf dem Desktop sieht. + var img := root.get_texture().get_image() + img.save_png(_png(name)) + print("PROBE_PNG=%s (%dx%d)" % [_png(name), img.get_width(), img.get_height()]) + +func _png(name: String) -> String: + return "/opt/data/scopedvr/work/t96e3_beweise/" + name + +func _find_subvp(node: Node) -> SubViewport: + if node is SubViewport: + return node as SubViewport + for c in node.get_children(): + var f := _find_subvp(c) + if f != null: + return f + return null + +func _press_key(code: Key) -> InputEventKey: + var key := InputEventKey.new() + key.keycode = code + key.physical_keycode = code + key.pressed = true + return key + +func _run() -> void: + DirAccess.make_dir_recursive_absolute("/opt/data/scopedvr/work/t96e3_beweise") + var scene: PackedScene = load("res://scenes/main.tscn") + main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + subvp = _find_subvp(main) + var container: SubViewportContainer = subvp.get_parent() + container.stretch = false + subvp.size = Vector2i(1600, 900) + await process_frame + + # Test-Map: hügeliges Ramp-Terrain, V11-konforme Spawns, Objekte zum Ansehen. + var doc: Dictionary = SvmapDocument.create_new("T1b-Probe", 100.0, "mittag") + var terrain: Dictionary = doc.get("terrain", {}) + var res := int(terrain.get("resolution", 0)) + var heights := PackedFloat32Array() + heights.resize(res * res) + for zi in res: + for xi in res: + heights[zi * res + xi] = 2.0 + float(xi) * 0.06 + terrain["min_height_m"] = 2.0 + terrain["max_height_m"] = 2.0 + float(res - 1) * 0.06 + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 2.0, 2.0 + float(res - 1) * 0.06) + doc["objects"] = [ + {"id": "crate_wood", "pos": [26.0, 0.0, -30.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "crate_wood", "pos": [14.0, 0.0, -24.0], "scale": 1.0, "rot_y_deg": 30.0}, + {"id": "watchtower_wood", "pos": [-30.0, 0.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0}, + ] + doc["spawn_zones"] = { + "0": {"points": [ + {"id": "A1", "pos": [-20.0, 0.0, 0.0]}, {"id": "A2", "pos": [-24.0, 0.0, 2.0]}, + {"id": "A3", "pos": [-22.0, 0.0, -3.0]}, {"id": "A4", "pos": [-26.0, 0.0, -2.0]}, + ]}, + "1": {"points": [ + {"id": "B1", "pos": [20.0, 0.0, 0.0]}, {"id": "B2", "pos": [24.0, 0.0, 2.0]}, + {"id": "B3", "pos": [22.0, 0.0, -3.0]}, {"id": "B4", "pos": [26.0, 0.0, -2.0]}, + ]}, + } + 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 + await process_frame + + # --- (a) Test-Start setzen (Demo-Klick) + Editor-Ansicht auf den Marker --- + var tool: Node = main.get("test_start_tool") + tool.call("demo_set", 20.0, -30.0) + var marker: Node3D = tool.get("_marker") + var h: float = SvmapDocument.terrain_height_at(doc, 20.0, -30.0) + print("ZAHLEN (a) Marker: pos=", marker.position, " sichtbar=", marker.visible, + " | Terrainhöhe dort=", h) + print("ZAHLEN (a) Status: ", main.status_label.text) + main.call("_demo_camera", Vector3(20.0, h, -30.0) + Vector3(9.0, 7.0, 9.0), 16.0, 30.0, -45.0) + await _shot("t1b_a_editor_marker.png") + + # --- (b) F5: Testlauf startet GENAU am Test-Start -------------------------- + main.call("_on_test_run") + await process_frame + await process_frame + var pt: Node = main.get("playtest") + var body: Node3D = pt.get("body") + print("ZAHLEN (b) Start: body.position=", body.position, + " | erwartet x=20 z=-30 Höhe=", body.call("terrain_height", 20.0, -30.0)) + print("ZAHLEN (b) Marker sichtbar im Spiel=", marker.visible) + print("ZAHLEN (b) Status: ", main.status_label.text) + await _shot("t1b_b_test_start_dort.png") + + # --- (c) T im Testlauf: Blick anheben → Teleport zum Blickpunkt ------------ + body.set("yaw_deg", 20.0) + body.set("pitch_deg", -25.0) + body.call("_apply_look") + var eye: Vector3 = body.position + Vector3(0.0, 1.7, 0.0) + var cam: Camera3D = body.get("look_cam") + var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0) + print("ZAHLEN (c) vor T: pos=", body.position, " blick_dir=", dir) + main._unhandled_key_input(_press_key(KEY_T)) + await process_frame + await process_frame + print("ZAHLEN (c) nach T: pos=", body.position, + " | main.test_start_point=", main.get("test_start_point")) + print("ZAHLEN (c) HUD: ", (pt.get("hud_msg") as Label).text) + await _shot("t1b_c_teleport.png") + + # --- (d) ESC: zurück, Marker zeigt auf die neue Stelle --------------------- + main._unhandled_key_input(_press_key(KEY_ESCAPE)) + await process_frame + await process_frame + print("ZAHLEN (d) playtest=null: ", main.get("playtest") == null, + " | Marker pos=", marker.position, " sichtbar=", marker.visible) + await _shot("t1b_d_editor_marker_neu.png") + print("PROBE_FERTIG") + quit(0) diff --git a/tests/probe_t1b_render.gd.uid b/tests/probe_t1b_render.gd.uid new file mode 100644 index 0000000..107c37d --- /dev/null +++ b/tests/probe_t1b_render.gd.uid @@ -0,0 +1 @@ +uid://eg5vli2gshth diff --git a/tests/test_playtest_spawn.gd b/tests/test_playtest_spawn.gd new file mode 100644 index 0000000..f3cfff8 --- /dev/null +++ b/tests/test_playtest_spawn.gd @@ -0,0 +1,383 @@ +extends SceneTree +## Editor-Test (headless): Karte t_96e3c4ee — Test-Startpunkt per Klick +## („hinklicken, wo man beim Testen spawnt", statt nur Team-Spawnpunkte). +## Ausführen: godot4 --headless --path . --script res://tests/test_playtest_spawn.gd +## Exit 0 = alles grün. +## +## Deckt die Karten-Pflichten ab: +## 1. Setzen/Löschen ändert die .svmap NICHT (Byte-Vergleich) + kein Undo. +## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm). +## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte). +## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren). +## 5. T im Testlauf versetzt genau dorthin, Augenhöhe 1,70 m, Kollision +## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben). +## 6. Marker existiert nur bei gesetztem Punkt. +## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf. + +var failures := 0 +var checks := 0 +var main: Control +var subvp: SubViewport + + +func _initialize() -> void: + _run_all.call_deferred() + + +func _run_all() -> void: + # Sauberer Sidecar-Start (Suite darf von früheren Läufen nichts erben). + TestStartStore.clear_point("") + 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 + main.call("_test_start_reload") # frischer Zustand nach dem Key-Clear + + var container := _find_viewport_container(main) + if container != null: + subvp = container.get_child(0) as SubViewport + subvp.size = Vector2i(800, 600) # deterministisches Fadenkreuz + await process_frame + + # Test-Map: hügeliges Terrain (Höhe steigt mit x), Team-0-Spawn, Objekt. + var doc: Dictionary = SvmapDocument.create_new("Test-Start-Karte", 100.0, "mittag") + var terrain: Dictionary = doc.get("terrain", {}) + var res := int(terrain.get("resolution", 0)) + var heights := PackedFloat32Array() + heights.resize(res * res) + for zi in res: + for xi in res: + heights[zi * res + xi] = 2.0 + float(xi) * 0.1 # 2.0 … +Ramp + terrain["min_height_m"] = 2.0 + terrain["max_height_m"] = 2.0 + float(res - 1) * 0.1 + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 2.0, 2.0 + float(res - 1) * 0.1) + doc["objects"] = [ + {"id": "crate_wood", "pos": [10.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + ] + # V11-konform: 4 Punkte pro Team (sonst lehnt der Validator das Speichern ab). + doc["spawn_zones"] = { + "0": {"points": [ + {"id": "A1", "pos": [-20.0, 0.0, 0.0]}, {"id": "A2", "pos": [-24.0, 0.0, 2.0]}, + {"id": "A3", "pos": [-22.0, 0.0, -3.0]}, {"id": "A4", "pos": [-26.0, 0.0, -2.0]}, + ]}, + "1": {"points": [ + {"id": "B1", "pos": [20.0, 0.0, 0.0]}, {"id": "B2", "pos": [24.0, 0.0, 2.0]}, + {"id": "B3", "pos": [22.0, 0.0, -3.0]}, {"id": "B4", "pos": [26.0, 0.0, -2.0]}, + ]}, + } + 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") + main.call("_test_start_reload") + await process_frame + await process_frame + + _test_svmap_unchanged() + _test_height_snaps_to_terrain() + _test_fallback_order() + _test_sidecar_roundtrip() + _test_marker_visibility() + _test_t_key_toggles_tool() + _test_not_a_game_spawn() + await _test_playtest_teleport() + + _finish() + + +func _finish() -> void: + TestStartStore.clear_point("") # Suite hinterlässt keinen Müll + 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 + + +func _tool() -> Node: + return main.get("test_start_tool") + + +func _marker() -> Node3D: + var t := _tool() + if t == null: + return null + return t.get("_marker") + + +## --- 1: Setzen/Löschen ändert die .svmap nicht ------------------------------ +func _test_svmap_unchanged() -> void: + print("Testgruppe: Setzen/Löschen ändert die .svmap NICHT (Byte-Vergleich)") + var path := "user://test_playtest_spawn_a.svmap" + var r1 := SvmapIO.save_to_file(path, main.get("doc")) + _check(bool(r1.get("ok")), "Map vor dem Setzen speicherbar (Validator ok)") + var bytes_before := FileAccess.get_file_as_bytes(path) + var doc_before := str(main.get("doc")) + var undo_before: bool = main.get("history").call("can_undo") + + _tool().call("demo_set", 20.0, -30.0) + _check(main.get("test_start_point") != null, "Test-Start gesetzt (main.test_start_point)") + var r2 := SvmapIO.save_to_file(path, main.get("doc")) + _check(bool(r2.get("ok")), "Map nach dem Setzen speicherbar") + var bytes_after_set := FileAccess.get_file_as_bytes(path) + _check(bytes_after_set == bytes_before, "Setzen: .svmap byte-identisch") + _check(str(main.get("doc")) == doc_before, "Setzen: Dokument unverändert") + _check(not main.get("history").call("can_undo"), "Setzen: KEIN Undo-Eintrag") + + main.call("_test_start_delete") + _check(main.get("test_start_point") == null, "Test-Start gelöscht") + var bytes_after_del := FileAccess.get_file_as_bytes(path) + _check(bytes_after_del == bytes_before, "Löschen: .svmap byte-identisch") + _check(str(main.get("doc")) == doc_before, "Löschen: Dokument unverändert") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +## --- 2: Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm) -------------------- +func _test_height_snaps_to_terrain() -> void: + print("Testgruppe: Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm)") + var doc: Dictionary = main.get("doc") + _tool().call("demo_set", 30.0, 5.0) + var expected := SvmapDocument.terrain_height_at(doc, 30.0, 5.0) + var marker := _marker() + _check(marker != null and marker.visible, "Marker sichtbar nach dem Setzen") + _check(absf(marker.position.y - expected) <= 0.01, + "Marker-Höhe == Terrainhöhe (%.3f == %.3f, Δ %.4f m)" % [ + marker.position.y, expected, absf(marker.position.y - expected)]) + _check(absf(marker.position.x - 30.0) < 0.001 and absf(marker.position.z - 5.0) < 0.001, + "Marker x/z exakt am Klickpunkt") + # Klick auf ein Objekt: Punkt auf dem Boden DARUNTER (gleiche Höhe-Regel). + _tool().call("demo_set", 10.0, 0.0, "auf dem Boden unter dem Objekt") + var expected_obj := SvmapDocument.terrain_height_at(doc, 10.0, 0.0) + _check(absf(marker.position.y - expected_obj) <= 0.01, + "Objekt-Treffer: Marker auf dem Boden DARUNTER (%.3f == %.3f)" % [ + marker.position.y, expected_obj]) + var status: String = main.status_label.text + _check(status.contains("Boden darunter") or status.contains("unter dem Objekt"), + "Statuszeile meldet Klartext „unter dem Objekt“: '%s'" % status) + main.call("_test_start_delete") + + +## --- 3: Fallback-Reihenfolge ------------------------------------------------- +func _test_fallback_order() -> void: + print("Testgruppe: Fallback-Reihenfolge (Test-Start → Team-Spawn → Mitte)") + var doc: Dictionary = main.get("doc") + # a) Test-Start gewinnt. + var r: Dictionary = TestStart.resolve_start(doc, Vector3(33.0, 0.0, -44.0)) + _check(str(r.get("quelle")) == "test", "Fallback 1: gesetzter Test-Start gewinnt") + _check(absf((r.get("pos") as Vector3).x - 33.0) < 0.001 + and absf((r.get("pos") as Vector3).z + 44.0) < 0.001, + "Fallback 1: Position == Test-Start (x/z)") + _check(absf((r.get("pos") as Vector3).y - SvmapDocument.terrain_height_at(doc, 33.0, -44.0)) <= 0.01, + "Fallback 1: Höhe automatisch vom Terrain") + # b) Kein Test-Start → erster Team-Spawnpunkt (Team Blau = „0“). + r = TestStart.resolve_start(doc, null) + _check(str(r.get("quelle")) == "team", "Fallback 2: erster Team-Spawnpunkt") + _check(absf((r.get("pos") as Vector3).x + 20.0) < 0.001, + "Fallback 2: Position == Team-Blau-Spawn A1 (-20, 0)") + # c) Weder noch → Kartenmitte. + var ohne: Dictionary = doc.duplicate(true) + ohne["spawn_zones"] = {} + r = TestStart.resolve_start(ohne, null) + _check(str(r.get("quelle")) == "mitte", "Fallback 3: Kartenmitte") + var bmin: Array = doc.get("bounds", {}).get("min", []) + var bmax: Array = doc.get("bounds", {}).get("max", []) + _check(absf((r.get("pos") as Vector3).x - (float(bmin[0]) + float(bmax[0])) * 0.5) < 0.001, + "Fallback 3: Mitte == Bounds-Mitte") + # Klartext-Hinweise je Stufe. + _check(str(r.get("status")).length() > 0 and str(TestStart.resolve_start(doc, Vector3.ZERO)["status"]).length() > 0, + "Jede Fallback-Stufe liefert einen Klartext-Hinweis") + + +## --- 4: Nach Neustart wiederhergestellt (Sidecar) ---------------------------- +func _test_sidecar_roundtrip() -> void: + print("Testgruppe: Sidecar — nach Neustart wiederhergestellt") + var pfad := "/tmp/scopedvr_test_karte.svmap" + TestStartStore.save_point(pfad, Vector3(12.5, 0.0, -7.25)) + var loaded: Variant = TestStartStore.load_point(pfad) + _check(loaded != null, "Sidecar: Punkt gespeichert + lesbar") + if loaded != null: + var v := loaded as Vector3 + _check(absf(v.x - 12.5) < 0.001 and absf(v.z + 7.25) < 0.001, + "Sidecar: x/z exakt (%.2f, %.2f)" % [v.x, v.z]) + # editor-Flow: setzen → main verliert den Zustand („Neustart") → reload holt ihn zurück. + _tool().call("demo_set", 3.0, 4.0) + main.set("test_start_point", null) + main.call("_test_start_reload") + var p: Variant = main.get("test_start_point") + _check(p != null, "Neustart-Simulation: Punkt aus dem Sidecar wieder da") + if p != null: + var pv := p as Vector3 + _check(absf(pv.x - 3.0) < 0.001 and absf(pv.z - 4.0) < 0.001, + "Neustart-Simulation: gleiche Position (%.1f, %.1f)" % [pv.x, pv.z]) + main.call("_test_start_delete") + _check(TestStartStore.load_point("") == null, "Löschen entfernt den Sidecar-Eintrag") + + +## --- 6: Marker existiert nur bei gesetztem Punkt ------------------------------ +func _test_marker_visibility() -> void: + print("Testgruppe: Marker existiert nur bei gesetztem Punkt") + var marker := _marker() + _check(marker != null and not marker.visible, "Ohne Punkt: Marker unsichtbar") + _tool().call("demo_set", -5.0, 8.0) + _check(marker.visible, "Mit Punkt: Marker sichtbar") + main.call("_test_start_delete") + _check(not marker.visible, "Nach Löschen: Marker wieder unsichtbar") + + +## --- 6b: Taste T schaltet das Werkzeug im Editor um --------------------------- +func _test_t_key_toggles_tool() -> void: + print("Testgruppe: Taste T schaltet das Test-Start-Werkzeug um") + _check(str(main.get("tool_mode")) == "objects", "Start im Objekt-Werkzeug (Vorbedingung)") + main._unhandled_key_input(_press_key(KEY_T)) + _check(str(main.get("tool_mode")) == "teststart", "T: Test-Start-Werkzeug an") + var btn: Button = main.get("test_start_btn") + _check(btn != null and btn.button_pressed, "Toolbar-Knopf leuchtet mit") + var panel: Control = main.get("test_start_panel") + _check(panel != null and panel.visible, "Panel (Info + Löschen) sichtbar") + main._unhandled_key_input(_press_key(KEY_T)) + _check(str(main.get("tool_mode")) == "objects", "T nochmal: zurück zum Objekt-Werkzeug") + _check(not panel.visible, "Panel wieder zu") + # ESC im Werkzeug beendet es, der Punkt bleibt. + main._unhandled_key_input(_press_key(KEY_T)) + _tool().call("demo_set", 1.0, 2.0) + main._unhandled_key_input(_press_key(KEY_ESCAPE)) + _check(str(main.get("tool_mode")) == "objects", "ESC: Werkzeug beendet") + _check(main.get("test_start_point") != null, "ESC: gesetzter Test-Start bleibt") + main.call("_test_start_delete") + + +## --- 7: Test-Start taucht in keiner Spawn-Zone/Analyse auf -------------------- +func _test_not_a_game_spawn() -> void: + print("Testgruppe: Kein Spiel-Spawnpunkt (Spawn-Zonen/Analyse unberührt)") + var doc: Dictionary = main.get("doc") + var zones_before := str(doc.get("spawn_zones", {})) + var check_before := SpawnOps.ready_report(doc, main.get("manifest")) + _tool().call("demo_set", 44.0, 44.0) + _check(str(doc.get("spawn_zones", {})) == zones_before, + "Setzen: spawn_zones im Dokument byte-identisch") + var check_after := SpawnOps.ready_report(doc, main.get("manifest")) + _check(str(check_after) == str(check_before), "Setzen: Map-Check-Ergebnis unverändert") + var live_before := SpawnOps.live_status(doc, main.get("manifest")) + _tool().call("demo_set", -44.0, 33.0) + _check(SpawnOps.live_status(doc, main.get("manifest")) == live_before, + "Weiteres Setzen: Live-Spawn-Status unverändert") + main.call("_test_start_delete") + _check(str(doc.get("spawn_zones", {})) == zones_before, + "Löschen: spawn_zones im Dokument byte-identisch") + + +## --- 5: T im Testlauf versetzt genau dorthin (Augenhöhe, Kollision) ---------- +func _test_playtest_teleport() -> void: + print("Testgruppe: T im Testlauf — versetzt genau dorthin, Augenhöhe, Kollision") + main.set("test_start_point", null) + main.call("_test_start_reload") + main.call("_on_test_run") + await process_frame + var pt: Node = main.get("playtest") + _check(pt != null and bool(pt.get("active")), "Testlauf läuft (Vorbedingung)") + if pt == null: + return + var body: Node3D = pt.get("body") + var doc: Dictionary = main.get("doc") + + # a) Fadenkreuz-Treffer: Blick 45° nach vorn-unten. Das Ramp-Terrain hängt + # nur von x ab — der Strahl (yaw 0) bleibt auf konstantem x, also ist + # die Ziel-Höhe analytisch exakt. + body.set("yaw_deg", 0.0) # Blick nach -Z + body.set("pitch_deg", -45.0) + body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera + var eye: Vector3 = body.position + Vector3(0.0, float(body.get("eye_height")), 0.0) + var cam: Camera3D = body.get("look_cam") + var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0) + _check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)") + var h0: float = body.call("terrain_height", eye.x, eye.z) + var th := absf((eye.y - h0) / dir.y) + var expected := eye + dir * th + main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung) + await process_frame + var np := body.position + _check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15, + "Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [ + np.x, np.z, expected.x, expected.z]) + var ground_here: float = body.call("terrain_height", np.x, np.z) + _check(absf(np.y - ground_here) <= 0.01, + "Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf(np.y - ground_here)) + _check(absf(float(cam.position.y) - 1.7) < 0.001, "Augenhöhe 1,70 m (look_cam-Offset)") + var stored: Variant = main.get("test_start_point") + _check(stored != null and absf((stored as Vector3).x - np.x) < 0.01 + and absf((stored as Vector3).z - np.z) < 0.01, + "T hat den Test-Start AUF den Fadenkreuz-Punkt gesetzt") + var msg: String = (pt.get("hud_msg") as Label).text + _check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg) + + # b) Kollision: hohe Wand vor dem Spieler, flacher Blick (−10°) zielt HINTER + # die Wand ins Terrain — der Wand-AABB-Treffer kommt zuerst → ABGELEHNT. + # (Wand ragt weit über die Augenhöhe am Ramp-Terrain: Augen ≈ 7,5 m dort.) + var vor := Vector3(body.position) + body.obstacles.append(AABB(Vector3(vor.x - 2.0, -5.0, vor.z - 4.0), Vector3(4.0, 20.0, 0.6))) + body.set("yaw_deg", 0.0) + body.set("pitch_deg", -10.0) + body.call("_apply_look") + main._unhandled_key_input(_press_key(KEY_T)) + await process_frame + _check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert") + var blocked_msg: String = (pt.get("hud_msg") as Label).text + _check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"), + "Abgelehnt: Klartext im HUD: '%s'" % blocked_msg) + body.obstacles.pop_back() + + # c) Kisten-Dach: begehbare Block-Decke → oben drauf landen (Boden DARONTER, + # nie im Objekt). Strahl von oben senkrecht auf die Kiste. + var crate := AABB(Vector3(np.x + 5.0, np.y, np.z), Vector3(1.0, 1.0, 1.0)) + body.obstacles.append(crate) + var ccenter := crate.get_center() + var roof: Dictionary = body.call("teleport_to_ground", + Vector3(ccenter.x, crate.end.y + 2.0, ccenter.z), Vector3(0.0, -1.0, 0.0)) + _check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - crate.end.y) < 0.01, + "Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [ + float(roof.get("ground")), crate.end.y]) + body.obstacles.pop_back() + + # d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt). + var doc_before := str(doc) + pt.call("handle_escape") + await process_frame + _check(main.get("playtest") == null, "ESC: zurück im Editor") + _check(str(main.get("doc")) == doc_before, "ESC: Dokument byte-identisch") + _check(main.get("test_start_point") != null, "ESC: gesetzter Test-Start bleibt") + main.call("_test_start_delete") + + +func _press_key(code: Key) -> InputEventKey: + var key := InputEventKey.new() + key.keycode = code + key.physical_keycode = code + key.pressed = true + return key diff --git a/tests/test_playtest_spawn.gd.uid b/tests/test_playtest_spawn.gd.uid new file mode 100644 index 0000000..c7a060b --- /dev/null +++ b/tests/test_playtest_spawn.gd.uid @@ -0,0 +1 @@ +uid://c3eq6pgogm8ns