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. ## - Kollider-Bau via KolliderBau (core/svmap/kollider_bau.gd — 1:1-Spiegel ## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider ## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen) ## + Bauteil-Trimeshe (bau_*-Objekte: Treppen/Türen/Wände begehbar ## wie echte Geometrie). ## - PlaytestBody (CharacterBody3D, IDENTISCHE Parameter wie der ## Testmap-Spieler inkl. Step-Up 0.45 m) 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://core/editor/playtest/playtest_body.gd" const KOLLIDER_SCRIPT := "res://core/svmap/kollider_bau.gd" const AVATAR_SCRIPT := "res://core/editor/playtest/playtest_avatar.gd" ## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik) var body: Node3D = null ## Welt-Kollider (Terrain + Objekte + Bauteile) — Kind dieses Modus, ## wird mit exit() abgeräumt. var _kollider_root: 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 var hud_msg: Label = null # Klartext-Nachricht (z. B. Test-Start via T) var hud_dir: Label = null # Blickrichtung + Höhe (Karte t_aaff5dcc) ## Hilfe-Overlay (F1, Karte t_aaff5dcc): alle Tasten im Klartext. var help_layer: CanvasLayer = null ## Kartenmitte/Achsen-Linien (Taste X, Karte t_aaff5dcc — praktisch beim ## gespiegelten Bauen, Karte M1): X-Rot/Z-Blau/Y-Grün durch die Kartenmitte. var _axis_node: Node3D = 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 ## Avatar-Modelle waren beim Start verfügbar? (Karte t_17a35c59 — ohne ## Client-Klon bleibt Third Person gesperrt, graceful Fallback.) var _avatar_da := false signal exit_requested ## Starten: übernimmt die Sicht (Panels aus, Maus-Capture-Wunsch an) und ## 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, start_override: Variant = null) -> void: if active: return active = true _set_ui_visible(false) _set_status_capture(true) body = load(BODY_SCRIPT).new() body.call("set_terrain", doc) var start := _spawn_start(doc) if start_override != null: var sp := start_override as Vector3 start = Vector3(sp.x, 0.0, sp.z) # Spawn AUF der Terrainhöhe: Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz, # die Physik lässt den Body sanft auf den Trimesh fallen — wie im Spiel). body.position = Vector3(start.x, body.terrain_height(start.x, start.z) + PlaytestBody.HALF_H + 0.05, start.z) if start_override == null: var first := _first_spawn(doc) if first.has(SpawnOps.YAW_KEY): body.yaw_deg = float(first[SpawnOps.YAW_KEY]) # Kollider 1:1 vom Quest-Client (Karte t_1da96275): Terrain-Trimesh + # Objekt-Kollider aus der Registry + Bauteil-Trimeshe. Alles als Kind # dieses Modus — exit() räumt es mit ab. _baue_kollider(doc) add_child(body) # Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der # gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis. body.call("set_sensitivity", PlaytestSettings.load_sens()) # Playtest-Avatar (Karte t_17a35c59): derselbe Rig wie die Bots im Spiel # (GLB aus dem Nachbar-Klon ../scopedvr-client). Fehlt der Klon/Modell: # graceful — First Person bleibt, Third Person wird per Meldung gesperrt. var avatar: Node3D = load(AVATAR_SCRIPT).call("lade_avatar") as Node3D _avatar_da = avatar != null if _avatar_da: body.call("set_avatar", avatar) # Persistierte Perspektive (Taste V): Third nur mit Avatar — sonst # First Person wie bisher + Klartext-Hinweis. if PlaytestSettings.load_third(): if _avatar_da: body.call("set_perspektive", true) else: show_message("Gespeicherte Third-Person-Einstellung ist ohne Avatar-Modelle nicht verfügbar (Client-Klon ../scopedvr-client fehlt) — First Person aktiv.") _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 _kollider_root != null: _kollider_root.queue_free() _kollider_root = null if hud != null: hud.queue_free() hud = null if help_layer != null: help_layer.queue_free() help_layer = null if _axis_node != null: _axis_node.queue_free() _axis_node = 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 _first_spawn(doc: Dictionary) -> Dictionary: var team0 = (doc.get("spawn_zones", {}) as Dictionary).get("0", {}) if team0 is Dictionary: var points: Array = (team0 as Dictionary).get("points", []) if not points.is_empty() and points[0] is Dictionary: return points[0] return {} 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) ## Echter Kollider-Bau (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3 ## identisch"): der alte AABB-Blocking-Code ist ENTFERNT. Es gibt genau EINEN ## Kollider-Pfad, denselben wie der Quest-Client: ## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel ## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl. ## library/kollisions-Quelldateien). ## - Bauteile (bau_*) sind normale Registry-Objekte mit Trimesh aus ## library/kollisions — Türen/Fenster bleiben Durchgänge, Treppen begehbar. func _baue_kollider(doc: Dictionary) -> void: var kb := load(KOLLIDER_SCRIPT) _kollider_root = kb.call("baue_welt_kollider", doc) add_child(_kollider_root) # ------------------------------------------------------------------- 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_msg = Label.new() hud_dir = Label.new() hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen/steigen · G = Fliegen · V = 1./3.-Person · 1-4 = Spawnpunkt · T = Test-Start · H = HUD · X = Achsen · F1 = Hilfe · ESC = zurück zum Editor" hud_msg.text = "" for l: Label in [hud_pos, hud_dir, 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 _speise_avatar() # 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_dir != null: # Höhe = FUSSPUNKT (Kapsel-Mitte minus Halbhöhe) — direkt vergleichbar # mit der Terrainhöhe unter den Füßen (Karte t_1da96275). hud_dir.text = "Blick: %.0f° / %.0f° · Höhe: %.1f m" % [ float(body.get("yaw_deg")), float(body.get("pitch_deg")), body.position.y - PlaytestBody.HALF_H] 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() # ------------------------------------------------- Komfort (t_aaff5dcc) ----- ## Tasten im Lauf-Modus (Karte t_aaff5dcc): G = Fliegen/God-Mode, 1–4 = ## Sprung zu Spawnpunkt, H = HUD an/aus, F1 = Hilfe, X = Achsen. ESC/T bleiben ## bewusst bei main.gd (Esc-Kette + Test-Start-Vertrag). Die Editor-Tasten ## sind im Lauf-Modus eingefroren (main gibt hier nichts anderes durch). func _unhandled_input(event: InputEvent) -> void: if not active or body == null: return var key_evt := event as InputEventKey if key_evt == null or not key_evt.is_pressed() or key_evt.echo: return var key := key_evt.keycode var handled := true match key: KEY_G: toggle_fly() KEY_V: toggle_perspektive() KEY_1: jump_to_spawn(1) KEY_2: jump_to_spawn(2) KEY_3: jump_to_spawn(3) KEY_4: jump_to_spawn(4) KEY_H: toggle_hud() KEY_F1: toggle_help() KEY_X: toggle_axis() _: handled = false if handled: get_viewport().set_input_as_handled() ## Empfindlichkeit live setzen (aus dem Einstellungen-Panel, Registry-Weg — ## main.gd bleibt unangetastet). mult = Multiplikator 0,1–5,0. func apply_sensitivity(mult: float) -> void: if body != null: body.call("set_sensitivity", mult) ## Taste V (Karte t_17a35c59): First/Third-Person umschalten. Der neue ## Zustand wird persistiert (PlaytestSettings, wirksam über Neustarts). ## Ohne Avatar-Modelle (kein Client-Klon ../scopedvr-client) bleibt First ## Person aktiv und der Grund steht im HUD — kein Crash (graceful Fallback). func toggle_perspektive() -> bool: if body == null: return false var neu: bool = body.call("toggle_perspektive") if not _avatar_da: show_message("Kein Avatar-Modell gefunden (Client-Klon ../scopedvr-client fehlt) — Third Person nicht verfügbar.") return false PlaytestSettings.save_third(neu) if neu: show_message("Dritte Person AN — derselbe Avatar wie die Bots im Spiel. V = zurück zur ersten Person.") else: show_message("Erste Person AN (Standard). V = dritte Person.") return neu ## Avatar-Gait aus der ECHTEN Bewegung speisen (Kartenvorgabe: Stillstand/ ## Walk/Jog aus der tatsächlichen Geschwindigkeit, Blickrichtung == ## Bewegungsrichtung über die Body-Rotation). Jeden Frame — die Kadenz- ## Mathe des Avatars glättet selbst (BLEND_SMOOTH_S, wie die Bots). func _speise_avatar() -> void: if body == null: return var av: Node3D = body.get("avatar") as Node3D if av == null or not is_instance_valid(av): return var speed: float = body.call("horizontale_geschwindigkeit") av.call("apply_state", PlaytestAvatar.zustand_fuer(speed), speed) ## Fliegen / God-Mode umschalten (Taste G). Rückgabe: neuer Zustand. func toggle_fly() -> bool: if body == null: return false var new_state: bool = not bool(body.get("fly_mode")) body.set("fly_mode", new_state) body.set("velocity", Vector3.ZERO) # kein Fall-Übernehmen aus dem Gang if new_state: show_message("Fliegen AN (God-Mode): keine Gravitation — Blickrichtung fliegen, Space = steigen, G = aus.") else: show_message("Fliegen AUS — wieder mit Gravitation.") return new_state ## Spawnpunkt-Punkt für Taste index (1 = Team A/Zone 0, 2 = Team B/Zone 1, ## 3/4 = weitere Zonen 2/3): erster Punkt der Zone. Rückgabe {} = keine Zone. ## pos = [x,y,z] aus dem Dokument. func _spawn_point_for(index: int) -> Dictionary: if body == null: return {} var zones: Dictionary = (body.get("doc") as Dictionary).get("spawn_zones", {}) var zone: Variant = zones.get(str(index - 1)) if not (zone is Dictionary): return {} var points: Array = (zone as Dictionary).get("points", []) if points.is_empty() or not (points[0] is Dictionary): return {} var p := points[0] as Dictionary var pos: Array = p.get("pos", []) if pos.size() < 3: return {} return {"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])), "label": str((zone as Dictionary).get("label", "Zone %d" % index)), "yaw": p.get(SpawnOps.YAW_KEY, null)} ## Sprung zu einem Spawnpunkt (Tasten 1–4): exakt auf den Punkt gesetzt ## (x/z aus dem Dokument, Höhe vom Terrain — dieselbe Konvention wie der ## Lauf-Modus-Start). Rückgabe true = gelandet. func jump_to_spawn(index: int) -> bool: var sp := _spawn_point_for(index) if sp.is_empty() or body == null: show_message("Kein Spawnpunkt für Taste %d — die Karte hat diese Zone nicht." % index) return false var target: Vector3 = sp["pos"] var y: float = body.terrain_height(target.x, target.z) # Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz) — die Physik lässt den Body # sanft auf den Trimesh fallen (wie beim Start, Karte t_1da96275). body.set("position", Vector3(target.x, y + PlaytestBody.HALF_H + 0.02, target.z)) body.set("velocity", Vector3.ZERO) body.set("on_floor", true) if sp["yaw"] != null: body.set("yaw_deg", float(sp["yaw"])) show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [ index, str(sp["label"]), target.x, target.z, y]) return true ## HUD (Position/Blick/FPS/Hinweis) an/aus (Taste H). Rückgabe: sichtbar? func toggle_hud() -> bool: if hud == null: return false hud.visible = not hud.visible if hud.visible: show_message("HUD an.") # aus: Meldung wäre selbst unsichtbar return hud.visible ## Hilfe-Overlay an/aus (Taste F1). Rückgabe: sichtbar? func toggle_help() -> bool: if help_layer == null: _build_help() help_layer.visible = not help_layer.visible return help_layer.visible ## Kartenmitte/Achsen-Linien an/aus (Taste X). Rückgabe: sichtbar? func toggle_axis() -> bool: if _axis_node == null: _build_axis() _axis_node.visible = not _axis_node.visible if _axis_node.visible: show_message("Kartenmitte/Achsen AN (X = aus): X-Rot, Z-Blau, Mitte Grün.") else: show_message("Kartenmitte/Achsen AUS.") return _axis_node.visible ## Hilfe-Overlay (F1): Panel mit allen Tasten im Klartext, mittig unten. func _build_help() -> void: help_layer = CanvasLayer.new() help_layer.layer = 101 var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) center.mouse_filter = Control.MOUSE_FILTER_IGNORE help_layer.add_child(center) var panel := PanelContainer.new() center.add_child(panel) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 16) margin.add_theme_constant_override("margin_right", 16) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_bottom", 12) panel.add_child(margin) var label := Label.new() label.text = "TESTLAUF — ALLE TASTEN WASD — bewegen · Maus — umsehen Shift — rennen · Space — springen / im Flug steigen G — Fliegen (God-Mode) an/aus: keine Gravitation, Blickrichtung fliegen V — Perspektive umschalten (erste / dritte Person) 1 — Sprung zu Team A (Spawnpunkt) · 2 — Team B 3 / 4 — weitere Spawnpunkte (Zonen 2/3) T — Test-Start auf den Blickpunkt setzen (sofort dort) H — HUD an/aus (Position, Blick, FPS, Höhe) X — Kartenmitte/Achsen ein/aus (X-Rot, Z-Blau) F1 — diese Hilfe · ESC — zurück zum Editor" label.add_theme_font_size_override("font_size", 18) margin.add_child(label) add_child(help_layer) help_layer.visible = false ## Achsen-Linien durch die Kartenmitte (Taste X): X-Achse rot, Z-Achse blau, ## senkrechte Mitte grün — beim gespiegelten Bauen (M1) auf einen Blick. func _build_axis() -> void: var doc: Dictionary = (body.get("doc") if body != null else {}) as Dictionary 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 var y0: float = body.terrain_height(cx, cz) if body != null else 0.0 var mesh := ImmediateMesh.new() mesh.surface_begin(Mesh.PRIMITIVE_LINES) # X-Achse (rot): ganze Kartenbreite durch die Mitte. mesh.surface_set_color(Color(1.0, 0.25, 0.25)) mesh.surface_add_vertex(Vector3(float(mn[0]), y0, cz)) mesh.surface_add_vertex(Vector3(float(mx[0]), y0, cz)) # Z-Achse (blau): ganze Kartentiefe durch die Mitte. mesh.surface_set_color(Color(0.3, 0.5, 1.0)) mesh.surface_add_vertex(Vector3(cx, y0, float(mn[2]))) mesh.surface_add_vertex(Vector3(cx, y0, float(mx[2]))) # Senkrechte Mitte (grün): vom Terrain bis zur oberen Grenze. mesh.surface_set_color(Color(0.3, 1.0, 0.4)) mesh.surface_add_vertex(Vector3(cx, y0, cz)) mesh.surface_add_vertex(Vector3(cx, float(mx[1]), cz)) mesh.surface_end() var mi := MeshInstance3D.new() mi.name = "PlaytestAchsen" mi.mesh = mesh # Vertexfarben sichtbar machen: unshaded + vertex_color_use_as_albedo # (ohne das ignoriert der Default-Material-Shader die Linien-Farben). var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.vertex_color_use_as_albedo = true mat.no_fog = true mi.material_override = mat mi.position = Vector3.ZERO _axis_node = mi add_child(_axis_node)