scopedvr-map-editor/src/editor/playtest/playtest_mode.gd
2026-10-31 15:37:09 +01:00

536 lines
21 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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://src/editor/playtest/playtest_body.gd"
const KOLLIDER_SCRIPT := "res://core/svmap/kollider_bau.gd"
const AVATAR_SCRIPT := "res://src/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)