M2: Map-Editor UX-Fundament — Undo/Redo-History, Ghost-Preview, Auswahl-Anfassen, helfende Meldungen (t_40d855d0)

This commit is contained in:
Hermes (godot-vr) 2026-10-15 09:45:48 +00:00
parent 84bc08145b
commit 602cbc3402
9 changed files with 1267 additions and 109 deletions

View file

@ -40,35 +40,48 @@ Konkret heißt das für JEDE Editor-Karte:
M2 (siehe PLAN.md §6): Map-Paket-Format → Editor-MVP → erste offizielle Map
(offenes Gelände) → Map-Repo/Release-Workflow über Forgejo (M3).
## Status (M2, Karte t_5badf371 — App-Skelett FERTIG)
## Status (M2, Karte t_40d855d0 — UX-Fundament FERTIG)
- **Godot-4.x-Desktop-App:** startet als eigenständiges Fenster (Hauptfenster mit
Menüleiste „Datei“, 3D-Viewport mit Orbit-Kamera, Objekt-Baum links,
Map-Eigenschaften rechts, Statusleiste unten). Kein Spiel-Code-Import.
- **Map-Datenmodell:** 1:1 das svmap-Format v1 (`docs/map_format.md` im
scopedvr-match-server-Repo) — Terrain-Parameter (Heightmap R16/LE/Base64),
Objekt-Liste, Spawn-Zonen (Team 0/1), Metadaten + Wetter-Preset.
Implementierung: `src/svmap/svmap_io.gd` (Validierung V1–V14 wie der
Server-Loader, identische Kanonisierung/Prüfsumme — interop-getestet in
beide Richtungen), `src/svmap/map_document.gd` (Neue-Map-Defaults).
- **Laden/Speichern:** `.svmap` über Datei-Dialog; speichern validiert VOR dem
Schreiben und signiert (sha256 über kanonisierte Payload). Speichern →
Laden → Speichern ist byte-identisch. Ungültige Maps werden mit klaren,
deutschsprachigen Meldungen abgelehnt.
- **Viewport-Grundgerüst:** Orbit/Zoom/Pan per Maus (kein Tastenkürzel nötig),
Maßstabsgitter + Boden-Referenz in Terrain-Größe, Platzhalter-Meshes für
Bibliotheks-Objekte, Spawn-Marker teamfarbig mit Beschriftung,
Wetter-Preset färbt Licht/Himmel.
- **Neue-Map-Dialog:** Name, Größe (512/1024/2048 m), Wetter-Preset. Neue Maps
sind sofort speicherbar (4 Default-Spawn-Punkte pro Team, V11 erfüllt).
- **Tests:** `tests/test_editor_svmap.gd` (31 Checks: Roundtrip byte-identisch,
V2–V13-Negativfälle, Poly-Budget, Server-Kompatibilität der Prüfsumme).
Lauf: `godot --headless --path . --script res://tests/test_editor_svmap.gd`
UX-Fundament auf dem App-Skelett (t_5badf371), nach dem Chef-Grundsatz gebaut:
*(Unverändert darunter: svmap laden/speichern V1–V14 mit Prüfsummen-Interop,
Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/
Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)*
- **Undo/Redo (`src/editor/action_history.gd`):** zentrale ActionHistory über
die svmap-Ober-Blöcke (`objects`/`terrain`/`spawn_zones` — tiefe Kopien vor
und nach jeder Änderung, merge_tags gegen Mausrad-Tick-Fluten, Max-Stufe 100).
Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben.
Spätere Werkzeuge melden sich mit `snapshot(keys)` vor und
`commit(desc, before, after)` nach der Änderung an — Terrain-Strokes und
Spawn-Zonen landen damit automatisch in derselben History.
- **Objekt-Platzierung mit Ghost-Preview (`src/editor/interaction.gd`):**
Bibliothek links (deutsche Namen + Poly-Zahl), Klick startet den
Platzier-Modus — eine transparente Vorschau schwebt am Cursor auf dem Boden
(Höhe kommt aus dem Terrain), Klick setzt, Rechtsklick/ESC bricht ab,
Mausrad dreht die Vorschau VOR dem Setzen (15°-Raster). Modus bleibt an
für schnelles Mehrfachsetzen. Poly-Budget wird VOR dem Platzieren geprüft.
- **Auswahl-Anfassen:** Klick wählt ein Objekt (orange + Emission), Ziehen
verschiebt es (auf Terrain-Höhe geclampt in der Map), R-Taste ODER Mausrad
dreht (15°-Raster, Shift+R zurück), Entf löscht. Auch über den Objekt-Baum
wählbar (beide Wege bleiben synchron). Panel „Auswahl“ rechts zeigt Position/
Richtung + Bedienhinweise.
- **Helfende Meldungen:** Statusleiste unten erklärt nach jeder Aktion den
NÄCHSTEN Schritt („Klicke in die Karte“, „Strg+Z holt es zurück“,
Budget-Warnung ab 80 %). Fehler-Dialoge bleiben Klartext, keine Godot-Texte
im sichtbaren UI.
- **Deutsche, große UI:** Schrift 16 px Basis (≥ 14 px überall), Buttons/
Klickflächen ≥ 40 px Höhe, Mausrad-Konflikt gelöst: Rad dreht Objekt/
Vorschau, wenn eines aktiv ist — sonst zoomt es die Kamera.
- **Tests:** `tests/test_ux.gd` (History-Roundtrips setzen/löschen/verschieben/
drehen, Merge-Tag-Verhalten, Alias-Sicherheit der Snapshots, Kapazität,
Terrain-Höhen-Abfrage, Platzhalter-Meshes).
Lauf: `godot --headless --path . --script res://tests/test_ux.gd`
**Bibliothek:** `library/objects.json` ist die Editor-Kopie der offiziellen
Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier
synchron ziehen (IDs sind der Vertrag, Entscheidung 20).
**Noch offen (folgende Karten der MVP-Kette):** Terrain-Werkzeuge
(heben/senken/glätten), Objekt-Platzierung (Bibliothek-Browser),
(heben/senken/glätten — nutzen ActionHistory mit `["terrain"]`),
Spawn-Zonen-Editor, Performance-Check-UI, Export-Workflow.

View file

@ -0,0 +1,100 @@
extends RefCounted
class_name ActionHistory
## Zentrale Undo/Redo-Historie des Map-Editors (M2, Karte t_40d855d0).
##
## Die Map IST das svmap-Dictionary (1:1-Format, keine Kopie). Jede Aktion
## sichert die betroffenen OBER-Blöcke (z. B. "objects", "terrain",
## "spawn_zones") als tiefe Kopie VOR und NACH der Änderung. Undo setze die
## Blöcke zurück, Redo wendet "after" erneut an.
##
## So melden spätere Werkzeuge ihre Aktionen an (Terrain-Strokes, Spawn-Zonen):
## var before := history.snapshot(["terrain"]) # VOR der Änderung
## doc["terrain"][...] = ... # Änderung machen
## history.commit("Hügel geformt", before, history.snapshot(["terrain"]))
##
## Ober-Blöcke sind klein (Objekt-Liste/Spawn-Punkte) oder Base64-Strings
## (heightmap) — tiefe Kopien sind hier billig und bombenfest gegen
## Alias-Verwechslung, weil Godot-Arrays/Dicts sonst gegenseitig referenzieren.
const MAX_STEPS := 100
var doc: Dictionary = {}
var entries: Array = [] # Eintrag = {desc:String, merge:String, before:{Blk:Variante}, after:{Blk:Variante}}
var index := -1 # zuletzt angewandter Eintrag; -1 = nichts
func begin(document: Dictionary) -> void:
doc = document # Referenz — wir ändern dieselbe Map, keine Kopie
clear()
func clear() -> void:
entries.clear()
index = -1
## Tiefe Kopie der angegebenen Blöcke (VOR oder NACH einer Änderung).
func snapshot(keys: Array) -> Dictionary:
var out := {}
for key in keys:
if doc.has(key):
out[key] = doc[key].duplicate(true)
return out
func can_undo() -> bool:
return index >= 0
func can_redo() -> bool:
return index + 1 < entries.size()
func undo_desc() -> String:
if not can_undo():
return ""
return str(entries[index].get("desc", ""))
func redo_desc() -> String:
if not can_redo():
return ""
return str(entries[index + 1].get("desc", ""))
## Eine fertige Änderung in die History einhängen.
## merge_tag: ungleich "" → identisch getagte Folge-Aufrufe (z. B. Drehen
## per Mausrad in schnellen Ticks) überschreiben den letzten Eintrag,
## statt die History zu fluten. commit immer direkt NACH der Doc-Änderung aufrufen.
func commit(desc: String, before: Dictionary, after: Dictionary, merge_tag: String = "") -> void:
if merge_tag != "" and index >= 0 and str(entries[index].get("merge", "")) == merge_tag:
entries[index]["after"] = after
return
entries.resize(index + 1) # redo-Zweig verwerfen (Standard-Undo-Semantik)
entries.append({"desc": desc, "merge": merge_tag, "before": before, "after": after})
if entries.size() > MAX_STEPS:
entries.remove_at(0) # älteste Aktion verdrängen
index = entries.size() - 1
## Letzte Aktion zurücknehmen. Rückgabe: Beschreibung ("" = nichts da).
func undo() -> String:
if not can_undo():
return ""
var entry: Dictionary = entries[index]
for key in entry["before"]:
doc[key] = entry["before"][key].duplicate(true) if entry["before"][key] != null else null
index -= 1
return str(entry.get("desc", ""))
## Zurückgenommenes wiederherstellen. Rückgabe: Beschreibung ("" = nichts da).
func redo() -> String:
if not can_redo():
return ""
index += 1
var entry: Dictionary = entries[index]
for key in entry["after"]:
doc[key] = entry["after"][key].duplicate(true) if entry["after"][key] != null else null
return str(entry.get("desc", ""))

374
src/editor/interaction.gd Normal file
View file

@ -0,0 +1,374 @@
extends Node3D
## Maus-Interaktion im Editor-Viewport (M2 UX-Fundament, Karte t_40d855d0):
## - Ghost-Preview: beim Platzieren schwebt eine transparente Vorschau am
## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis).
## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt,
## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht.
## Alle Meldungen laufen über main._set_status — Klartext + nächsten Schritt
## vorschlagen (Chef-Grundsatz, map-editor-README).
##
## Lebt im selben SubViewport-Welt-Knoten wie Kamera + Viewport-Bauer;
## main.gd verdrahtet Referenzen per setup().
const ROT_STEP_DEG := 15.0
const DRAG_THRESHOLD_PX := 4.0
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
var camera: Camera3D
var view_builder # script-erweitert, Variant
# Platzier-Modus
var placing := false
var place_oid := ""
var place_rot_deg := 0.0
var _ghost: MeshInstance3D
var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist
# Auswahl + Greifen
var selected_index := -1
var _dragging := false
var _drag_moved := false
var _press_pos := Vector2.ZERO
var _grab_offset := Vector3.ZERO
func setup(p_main, p_camera: Camera3D, p_builder) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau
## (dann zoomt das Rad NICHT)?
func wheel_blocked() -> bool:
return placing or selected_index >= 0
func _ground_point(viewport_pos: Vector2) -> Variant:
# Cursor-Strahl → Punkt auf der Boden-Referenzebene (Terrain-Basis).
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 _terrain_half() -> Vector2:
var terrain: Dictionary = main.doc.get("terrain", {})
return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5
## Cursor-Strahl → getroffenes Objekt (Kugel-Approximation der Marker).
## Rückgabe: Index in doc.objects oder -1.
func _pick_object(viewport_pos: Vector2) -> int:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var ground := _ground_point(viewport_pos)
var ground_t := INF
if ground != null:
ground_t = (ground as Vector3).distance_to(origin)
var best := -1
var best_t := INF
for marker in view_builder.object_markers:
if marker == null or not is_instance_valid(marker):
continue
var center: Vector3 = marker.global_position + Vector3.UP * float(marker.get_meta("half_height", 1.0))
var to_center := center - origin
var tca := to_center.dot(dir)
if tca <= 0.0:
continue
var d2 := to_center.length_squared() - tca * tca
var radius := float(marker.get_meta("pick_radius", 1.0))
if d2 > radius * radius:
continue
var t := tca - sqrt(radius * radius - d2)
if t < best_t and t < ground_t + 0.01:
best = int(marker.get_meta("obj_index", -1))
best_t = t
return best
# ------------------------------------------------------------ Platzieren ----
func start_placement(oid: String) -> void:
place_oid = oid
placing = true
place_rot_deg = 0.0
_ghost_active = false
_ensure_ghost()
_ensure_ghost_visible()
var label: String = main.object_label(oid)
main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % label)
main.call("_highlight_library", oid)
func cancel_placement() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
placing = false
place_oid = ""
_ghost_active = false
main.call("_highlight_library", "")
main.call("_base_tool_status")
func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
return
_ghost = MeshInstance3D.new()
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(0.2, 0.5, 0.8) * 0.4
_ghost.material_override = mat
add_child(_ghost)
_ghost.visible = false
func _ghost_refresh_mesh() -> void:
var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(place_oid, {})
var col: Dictionary = entry.get("collision", {})
var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0)
_ghost.mesh = mesh
_ghost.position.y = MeshShapes.half_height(mesh)
func _ensure_ghost_visible() -> void:
_ghost_refresh_mesh()
_ghost.rotation.y = deg_to_rad(place_rot_deg)
_ghost.visible = true
func _ghost_update(viewport_pos: Vector2) -> void:
var p := _ground_point(viewport_pos)
if p == null:
_ghost_active = false
_ghost.visible = false
return
_ghost_active = true
var pos := p as Vector3
var half := _terrain_half()
pos.x = clampf(pos.x, -half.x, half.x)
pos.z = clampf(pos.z, -half.y, half.y)
pos.y = SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)
_ghost.position = pos
_ghost.visible = true
func _place_object(viewport_pos: Vector2) -> void:
var p := _ground_point(viewport_pos)
if p == null:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var pos := p as Vector3
var half := _terrain_half()
if absf(pos.x) > half.x or absf(pos.z) > half.y:
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
return
# Poly-Budget prüfen, BEVOR etwas passiert (hilfreiche Meldung statt Fehler).
var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(place_oid, {})
var poly := float(entry.get("poly", 0))
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly > budget:
main.call("_set_status",
"Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Objekt weg oder drück Strg+Z, um etwas zurückzunehmen."
% [main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return
var before: Dictionary = main.history.snapshot(["objects"])
var pos_f := [float(pos.x), float(SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)), float(pos.z)]
(main.doc["objects"] as Array).append({
"id": place_oid,
"pos": pos_f,
"scale": 1.0,
"rot_y_deg": float(place_rot_deg),
})
main.history.commit("%s platzieren" % main.object_label(place_oid),
before, main.history.snapshot(["objects"]))
var new_index: int = (main.doc["objects"] as Array).size() - 1
main.call("_after_structure_change")
main.call("_set_status", "%s platziert. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad) — Entf löscht es." % main.object_label(place_oid))
# Platzier-Modus bleibt aktiv (Mehrfach-Platzieren ohne erneutes Klicken),
# Auswahl auf das frische Objekt legen? Nein — Ghost bleibt Vorrang; ESC/Rechtsklick beendet.
main.call("_mark_tree_selection", new_index)
# --------------------------------------------------------------- Auswahl ----
func select_object(idx: int, from_tree := false) -> void:
selected_index = idx
view_builder.call("set_selected", idx)
if not from_tree:
main.call("_mark_tree_selection", idx)
main.call("_sync_selection_panel")
main.call("_selection_status", idx)
func clear_selection(silent := false) -> void:
if selected_index != -1:
selected_index = -1
view_builder.call("set_selected", -1)
if not silent:
main.call("_set_status", "Auswahl aufgehoben.")
main.call("_sync_selection_panel")
func rotate_selected(delta_deg: float) -> void:
if selected_index < 0:
return
var objects: Array = main.doc.get("objects", [])
if selected_index >= objects.size():
return
var before: Dictionary = main.history.snapshot(["objects"])
var obj: Dictionary = objects[selected_index]
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
var new_rot := fmod(float(obj.get("rot_y_deg", 0.0)) + delta_deg + 3600.0, 360.0)
obj["rot_y_deg"] = float(new_rot)
if marker != null and is_instance_valid(marker):
marker.rotation.y = deg_to_rad(new_rot)
main.history.commit("Objekt gedreht", before, main.history.snapshot(["objects"]),
"rot:%d" % selected_index)
main.call("_refresh_history_buttons")
main.call("_refresh_stats")
func delete_selected() -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das gelöscht werden soll.", true)
return
var objects: Array = main.doc.get("objects", [])
if selected_index >= objects.size():
clear_selection(true)
return
var before: Dictionary = main.history.snapshot(["objects"])
var label: String = main.object_label(str(objects[selected_index].get("id", "")))
objects.remove_at(selected_index)
main.history.commit("%s löschen" % label, before, main.history.snapshot(["objects"]))
clear_selection(true)
main.call("_after_structure_change")
main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label)
# ------------------------------------------------------------ Eingaben ------
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if placing:
_ghost_update(mm.position)
if _dragging and not placing and selected_index >= 0:
var p := _ground_point(mm.position)
if p == null:
return
var new_pos := p as Vector3 + _grab_offset
var half := _terrain_half()
new_pos.x = clampf(new_pos.x, -half.x, half.x)
new_pos.z = clampf(new_pos.z, -half.y, half.y)
new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z)
var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length()
if dist > DRAG_THRESHOLD_PX:
_drag_moved = true
if _drag_moved:
var marker: Node3D = view_builder.object_markers[selected_index]
if marker != null and is_instance_valid(marker):
marker.position = new_pos
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
_on_left_press(mb)
get_viewport().set_input_as_handled()
elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed:
_on_left_release(mb)
elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
if placing:
cancel_placement()
main.call("_set_status", "Platzieren abgebrochen.")
get_viewport().set_input_as_handled()
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
if placing:
place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0)
_ghost.rotation.y = deg_to_rad(place_rot_deg)
get_viewport().set_input_as_handled()
elif selected_index >= 0:
rotate_selected(ROT_STEP_DEG)
get_viewport().set_input_as_handled()
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
if placing:
place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0)
_ghost.rotation.y = deg_to_rad(place_rot_deg)
get_viewport().set_input_as_handled()
elif selected_index >= 0:
rotate_selected(-ROT_STEP_DEG)
get_viewport().set_input_as_handled()
func _on_left_press(mb: InputEventMouseButton) -> void:
if placing:
_place_object(mb.position)
return
_press_pos = mb.position
var hit := _pick_object(mb.position)
if hit >= 0:
if selected_index != hit:
select_object(hit)
_dragging = true
_drag_moved = false
var p := _ground_point(mb.position)
var marker: Node3D = view_builder.object_markers[selected_index]
if p != null and marker != null and is_instance_valid(marker):
_grab_offset = marker.position - (p as Vector3)
_grab_offset.y = 0.0
else:
_grab_offset = Vector3.ZERO
else:
# Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen).
if selected_index != -1:
clear_selection()
func _on_left_release(_mb: InputEventMouseButton) -> void:
if _dragging:
_dragging = false
if _drag_moved and selected_index >= 0:
var objects: Array = main.doc.get("objects", [])
var marker: Node3D = null
if selected_index < view_builder.object_markers.size():
marker = view_builder.object_markers[selected_index]
if selected_index < objects.size() and marker != null and is_instance_valid(marker):
var before: Dictionary = main.history.snapshot(["objects"])
var pos := marker.position
objects[selected_index]["pos"] = [float(pos.x), float(pos.y), float(pos.z)]
main.history.commit("Objekt verschoben",
before, main.history.snapshot(["objects"]), "move:%d" % selected_index)
main.call("_refresh_history_buttons")
_drag_moved = false
# ---------------------------------------------------- Screenshot-Hilfe ------
## Demo-Hook (Desktop-Smoke): Ghost außerhalb echter Maus-Ereignisse parken,
## damit ein Screenshot die Vorschau zeigen kann.
func demo_park_ghost(world_pos: Vector3, oid: String) -> void:
start_placement(oid)
placing = true
place_oid = oid
_ghost_refresh_mesh()
_ghost.position = world_pos
_ghost.rotation.y = deg_to_rad(place_rot_deg)
_ghost.visible = true
_ghost_active = true
main.call("_highlight_library", oid)
main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % main.object_label(oid))

View file

@ -91,3 +91,42 @@ static func poly_usage(data: Dictionary, manifest: Dictionary) -> float:
var scale := float(obj.get("scale", 1.0))
total += poly * scale * scale
return total
## Höhe des Terrains an Welt-X/Z (Meter). Flache neue Maps → min_height.
## Heightmap R16/LE/Base64 (map_format.md): res×res Punkte über size_x/size_z,
## bilinear interpoliert. Objekt-Platzierung und spätere Terrain-Werkzeuge
## fragen hier nach, statt selbst über Bytes zu stolpern.
static func terrain_height_at(data: Dictionary, x: float, z: float) -> float:
var terrain: Dictionary = data.get("terrain", {})
var res := int(terrain.get("resolution", 0))
var size_x := float(terrain.get("size_x_m", 0.0))
var size_z := float(terrain.get("size_z_m", 0.0))
var hmin := float(terrain.get("min_height_m", 0.0))
var hmax := float(terrain.get("max_height_m", 0.0))
if res < 2 or size_x <= 0.0 or size_z <= 0.0 or hmax <= hmin:
return hmin
var bytes := Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", "")))
if bytes.size() < res * res * 2:
return hmin
var last := float(res - 1)
var fx := clampf((x / size_x + 0.5) * last, 0.0, last)
var fz := clampf((z / size_z + 0.5) * last, 0.0, last)
var xi := int(floor(fx))
var zi := int(floor(fz))
var tx := fx - float(xi)
var tz := fz - float(zi)
var xi1 := mini(xi + 1, res - 1)
var zi1 := mini(zi + 1, res - 1)
var h00 := _height_sample(bytes, res, xi, zi, hmin, hmax)
var h10 := _height_sample(bytes, res, xi1, zi, hmin, hmax)
var h01 := _height_sample(bytes, res, xi, zi1, hmin, hmax)
var h11 := _height_sample(bytes, res, xi1, zi1, hmin, hmax)
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
static func _height_sample(bytes: PackedByteArray, res: int, xi: int, zi: int,
hmin: float, hmax: float) -> float:
var i := (zi * res + xi) * 2
var raw := float(bytes[i]) + float(bytes[i + 1]) * 256.0
return hmin + (raw / 65535.0) * (hmax - hmin)

View file

@ -1,29 +1,61 @@
extends Control
## ScopedVR Map-Editor — Hauptfenster (M2 MVP-Skelett, Karte t_5badf371).
## ScopedVR Map-Editor — Hauptfenster (M2 UX-Fundament, Karte t_40d855d0).
##
## Alles UI wird hier per Code gebaut (eine einzige .tscn mit Root-Knoten).
## Struktur: Top-Bar (Datei-Menü) | links Objekt-Baum + Budget | Mitte 3D-Viewport
## | rechts Map-Eigenschaften | unten Statusleiste.
## Struktur: Top-Bar (Menü + Rückgängig/Wiederholen) | links Objekt-Bibliothek
## | Mitte 3D-Viewport | rechts Map-Eigenschaften + Auswahl | unten Statusleiste.
##
## MVP-Umfang: .svmap laden/speichern (V1–V14, wie im Server-Loader), neue Map,
## Map-Metadaten editieren, Orbit-Viewport mit Gitter/Boden. Terrain-Werkzeuge
## und Objekt-Platzierung kommen mit eigenen Karten (MVP-Kette im README).
## UX-Fundament dieser Karte (Chef-Grundsatz Sly — map-editor-README):
## 1. Undo/Redo (Strg+Z / Strg+Y) über ActionHistory — spätere Werkzeuge
## (Terrain, Spawn-Zonen) melden sich mit snapshot/commit einfach an.
## 2. Ghost-Preview: Platzier-Vorschau schwebt transparent am Cursor
## (interaction.gd), Klick setzt das Objekt.
## 3. Auswahl-Anfassen: Klick wählt (orange), Ziehen verschiebt,
## R ODER Mausrad dreht (15°-Raster), Entf löscht.
## 4. Helfende Meldungen: Klartext + DER NÄCHSTE SCHRITT, keine Godot-Texte.
## 5. Deutsch & groß: Schrift ≥ 14 px, klickbare Flächen ≥ 40 px Höhe.
const APP_TITLE := "ScopedVR Map-Editor"
## Deutsche Anzeige-Namen für Bibliotheks-IDs (IDs sind der Vertrag, Entscheidung 20)
const GERMAN_NAMES := {
"crate_wood": "Holzkiste",
"crate_metal": "Metallkiste",
"wall_concrete": "Betonmauer",
"barrier_sandbag": "Sandsack-Sperre",
"bunker_concrete": "Bunker",
"watchtower_wood": "Wachturm",
"fence_metal": "Zaun",
"rock_large": "Großer Felsen",
"boulder_small": "Kleiner Felsen",
"tree_pine": "Nadelbaum",
"bush_small": "Strauch",
}
var manifest: Dictionary = {}
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
var current_path := "" # leer = noch nie gespeichert
var dirty := false
var history := ActionHistory.new()
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
# UI-Referenzen
var content_tree: Tree
var stats_objects: Label
var stats_poly: Label
var status_label: Label
var file_label: Label
var view_builder: Node3D
var camera: Camera3D
var library_box: VBoxContainer
var library_buttons: Dictionary = {} # id → Button
var undo_btn: Button
var redo_btn: Button
# Auswahl-Panel
var sel_label: Label
var sel_rotate_btn: Button
var sel_delete_btn: Button
# Eigenschaften-Panel
var prop_name: LineEdit
@ -41,12 +73,14 @@ var error_dialog: AcceptDialog
var error_text: Label
var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder
var _tree_select_lock := false # verhindert Rückkopplung Auswahl ↔ Baum
func _ready() -> void:
manifest = _load_manifest()
_build_ui()
_start_with_new_map()
_maybe_start_demo_mode()
func _load_manifest() -> Dictionary:
@ -69,7 +103,7 @@ func _build_ui() -> void:
vbox.add_theme_constant_override("separation", 4)
add_child(vbox)
# --- Top-Bar ---
# --- Top-Bar: Menü + Rückgängig/Wiederholen ---
var top := PanelContainer.new()
var top_h := HBoxContainer.new()
top_h.add_theme_constant_override("separation", 10)
@ -84,13 +118,27 @@ func _build_ui() -> void:
popup.add_item("Neue Map…", 0)
popup.add_item("Öffnen…", 1)
popup.add_separator()
popup.add_item("Speichern", 2)
popup.add_item("Speichern (Strg+S)", 2)
popup.add_item("Speichern unter…", 3)
popup.add_separator()
popup.add_item("Beenden", 4)
popup.id_pressed.connect(_on_menu_action)
top_h.add_child(file_menu)
undo_btn = Button.new()
undo_btn.text = "↩ Rückgängig"
undo_btn.custom_minimum_size = Vector2(140, 40)
undo_btn.tooltip_text = "Letzte Aktion zurücknehmen (Strg+Z)"
undo_btn.pressed.connect(_do_undo)
top_h.add_child(undo_btn)
redo_btn = Button.new()
redo_btn.text = "↪ Wiederholen"
redo_btn.custom_minimum_size = Vector2(150, 40)
redo_btn.tooltip_text = "Zurückgenommene Aktion wiederherstellen (Strg+Y)"
redo_btn.pressed.connect(_do_redo)
top_h.add_child(redo_btn)
var title_label := Label.new()
title_label.text = " %s" % APP_TITLE
title_label.add_theme_font_size_override("font_size", 20)
@ -110,14 +158,13 @@ func _build_ui() -> void:
split.size_flags_vertical = Control.SIZE_EXPAND_FILL
split.size_flags_horizontal = Control.SIZE_EXPAND_FILL
split.split_offset = -320
split.collapsed = true # rechte Eigenschaften-Klappen via "expand"-Flag unten
vbox.add_child(split)
split.add_child(_build_left_panel())
split.add_child(_build_center_viewport())
split.add_child(_build_right_panel())
# --- Statusleiste ---
# --- Statusleiste: Hilfs-Meldungen + Budget ---
var status_panel := PanelContainer.new()
var status_h := HBoxContainer.new()
status_h.add_theme_constant_override("separation", 16)
@ -126,17 +173,13 @@ func _build_ui() -> void:
status_label = Label.new()
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
status_label.clip_text = true
status_label.clip_text = false
status_h.add_child(status_label)
stats_objects = Label.new()
stats_objects.modulate = Color(1, 1, 1, 0.75)
status_h.add_child(stats_objects)
stats_poly = Label.new()
stats_poly.modulate = Color(1, 1, 1, 0.75)
status_h.add_child(stats_poly)
# --- Dialoge ---
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
new_dialog.confirmed.connect(_on_new_map_confirmed)
@ -177,23 +220,50 @@ func _build_left_panel() -> PanelContainer:
vbox.add_theme_constant_override("separation", 8)
panel.add_child(vbox)
vbox.add_child(_header_label("Objekt-Bibliothek"))
vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken.\nDer Platzier-Modus bleibt an, bis du Rechtsklick/ESC drückst — so setzt du mehrere hintereinander."))
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.custom_minimum_size = Vector2(0, 160)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
vbox.add_child(scroll)
library_box = VBoxContainer.new()
library_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
library_box.add_theme_constant_override("separation", 4)
scroll.add_child(library_box)
_fill_library()
vbox.add_child(_separator())
vbox.add_child(_header_label("Map-Inhalt"))
content_tree = Tree.new()
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_tree.custom_minimum_size = Vector2(0, 200)
content_tree.allow_reselect = false
content_tree.allow_reselect = true
content_tree.hide_root = true
content_tree.item_selected.connect(_on_tree_selected)
vbox.add_child(content_tree)
var hint := Label.new()
hint.text = "Objekte platzieren kommt mit der\nnächsten Version — zuerst Terrain!"
hint.modulate = Color(1, 1, 1, 0.55)
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(hint)
return panel
func _fill_library() -> void:
library_buttons.clear()
var lib: Dictionary = manifest.get("objects", {})
for oid in lib.keys():
var entry: Dictionary = lib[oid]
var poly := int(entry.get("poly", 0))
var btn := Button.new()
btn.text = "%s · %d Polys" % [object_label(str(oid)), poly]
btn.custom_minimum_size = Vector2(240, 40)
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.toggle_mode = true
btn.button_pressed = false
btn.pressed.connect(_on_library_pressed.bind(str(oid)))
library_box.add_child(btn)
library_buttons[str(oid)] = btn
func _build_center_viewport() -> SubViewportContainer:
var container := SubViewportContainer.new()
container.stretch = true
@ -217,13 +287,19 @@ func _build_center_viewport() -> SubViewportContainer:
camera.current = true
world.add_child(camera)
interaction = Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
world.add_child(interaction)
interaction.setup(self, camera, view_builder)
camera.set("wheel_filter", Callable(interaction, "wheel_blocked"))
# Bedien-Hinweis im Viewport (Overlay)
var help := Label.new()
help.text = "Maus: Mitte ziehen = drehen · Mausrad = zoomen · Mitte + Shift = verschieben"
help.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
help.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
help.offset_left = 12
help.offset_bottom = -8
help.offset_top = -34
help.offset_top = -46
help.modulate = Color(1, 1, 1, 0.65)
container.add_child(help)
return container
@ -240,6 +316,31 @@ func _build_right_panel() -> PanelContainer:
vbox.add_theme_constant_override("separation", 8)
scroll.add_child(vbox)
# --- Auswahl (Anfassen/Greifen) ---
vbox.add_child(_header_label("Auswahl"))
sel_label = Label.new()
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(sel_label)
var sel_btns := HBoxContainer.new()
sel_btns.add_theme_constant_override("separation", 8)
vbox.add_child(sel_btns)
sel_rotate_btn = Button.new()
sel_rotate_btn.text = "Drehen (R)"
sel_rotate_btn.custom_minimum_size = Vector2(140, 40)
sel_rotate_btn.tooltip_text = "Ausgewähltes Objekt 15° drehen (auch: Mausrad)"
sel_rotate_btn.pressed.connect(func() -> void: interaction.rotate_selected(15.0))
sel_btns.add_child(sel_rotate_btn)
sel_delete_btn = Button.new()
sel_delete_btn.text = "Löschen (Entf)"
sel_delete_btn.custom_minimum_size = Vector2(140, 40)
sel_delete_btn.tooltip_text = "Ausgewähltes Objekt löschen (Strg+Z holt es zurück)"
sel_delete_btn.pressed.connect(interaction.delete_selected)
sel_btns.add_child(sel_delete_btn)
vbox.add_child(_separator())
# --- Map-Eigenschaften ---
vbox.add_child(_header_label("Map-Eigenschaften"))
vbox.add_child(_field_label("Name"))
@ -270,16 +371,13 @@ func _build_right_panel() -> PanelContainer:
prop_weather.add_item("Nacht (dunkel)")
vbox.add_child(prop_weather)
vbox.add_child(HSeparator.new())
vbox.add_child(_separator())
vbox.add_child(_header_label("Terrain"))
terrain_info = Label.new()
terrain_info.text = ""
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(terrain_info)
var note := Label.new()
note.text = "Terrain-Höhen bearbeiten kommt mit\nder nächsten Version."
note.modulate = Color(1, 1, 1, 0.55)
vbox.add_child(note)
vbox.add_child(_muted_label("Terrain-Höhen bearbeiten kommt mit der nächsten Version."))
# Signale (Änderungen → Dokument + Ansicht)
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
@ -305,14 +403,27 @@ func _field_label(text: String) -> Label:
return label
func _muted_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.modulate = Color(1, 1, 1, 0.55)
return label
func _separator() -> HSeparator:
return HSeparator.new()
# ------------------------------------------------------------ Map-Aktionen --
func _start_with_new_map() -> void:
doc = SvmapDocument.create_new("Neue Map", 1024.0, "mittag")
current_path = ""
dirty = false
history.begin(doc)
_refresh_all()
_set_status("Willkommen! Es wurde eine neue Map angelegt — über „Datei → Neue Map…“ kannst du sie neu starten.")
_set_status("Willkommen! Links ein Objekt aus der Bibliothek anklicken, dann kannst du es in der Karte platzieren. Hilfe gibt's unten in dieser Leiste.")
func _on_menu_action(id: int) -> void:
@ -333,23 +444,33 @@ func _on_new_map_confirmed() -> void:
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather())
current_path = ""
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s)." % [
_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()])
func _on_open_selected(path: String) -> void:
var result := SvmapIO.load_from_file(path)
if not result["ok"]:
_show_errors("Map konnte NICHT geladen werden:", result["errors"])
_set_status("Laden fehlgeschlagen: %s" % path.get_file(), true)
_show_errors("Map konnte NICHT geladen werden:\n(Falls sie in einem anderen Programm geändert wurde, prüfe die Datei.)", result["errors"])
_set_status("Laden fehlgeschlagen: %s — versuche eine .svmap-Datei aus dem Map-Editor oder vom Server." % path.get_file(), true)
return
doc = result["data"]
current_path = path
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
var map_name := str(doc.get("info", {}).get("name", "?"))
_set_status("Map „%s“ geladen (%d Objekte)." % [map_name, SvmapDocument.object_count(doc)])
_set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)])
func _clear_edit_state() -> void:
if interaction != null:
interaction.call("clear_selection", true)
interaction.call("cancel_placement")
func _save() -> void:
@ -393,7 +514,180 @@ func _show_errors(title: String, errors: Array) -> void:
error_dialog.popup_centered()
# -------------------------------------------------------- Eigenschaften ----
# --------------------------------------------------------------- History ----
func _do_undo() -> void:
if not history.can_undo():
_set_status("Nichts zum Rückgängigmachen — die History ist schon am Anfang.")
return
var desc := history.undo()
_after_structure_change()
_set_status("Rückgängig gemacht: %s. Mit Strg+Y kannst du es wiederherstellen." % desc)
func _do_redo() -> void:
if not history.can_redo():
_set_status("Nichts zum Wiederholen — schon am aktuellsten Stand.")
return
var desc := history.redo()
_after_structure_change()
_set_status("Wiederhergestellt: %s." % desc)
func _refresh_history_buttons() -> void:
undo_btn.disabled = not history.can_undo()
redo_btn.disabled = not history.can_redo()
undo_btn.tooltip_text = ("Rückgängig machen: %s (Strg+Z)" % history.undo_desc()) if history.can_undo() else "Nichts zum Rückgängigmachen"
redo_btn.tooltip_text = ("Wiederherstellen: %s (Strg+Y)" % history.redo_desc()) if history.can_redo() else "Nichts zum Wiederholen"
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
return
var key := (event as InputEventKey).keycode
var ctrl: bool = event.ctrl_pressed or event.command_pressed
match key:
KEY_Z:
if ctrl:
if event.shift_pressed:
_do_redo()
else:
_do_undo()
get_viewport().set_input_as_handled()
KEY_Y:
if ctrl:
_do_redo()
get_viewport().set_input_as_handled()
KEY_S:
if ctrl:
_save()
get_viewport().set_input_as_handled()
KEY_R:
if not ctrl:
var delta := -15.0 if event.shift_pressed else 15.0
if interaction.placing:
interaction.place_rot_deg = wrapf(interaction.place_rot_deg + delta, 0.0, 360.0)
else:
interaction.rotate_selected(delta)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
if interaction.placing:
interaction.cancel_placement()
_set_status("Platzieren abgebrochen.")
elif interaction.selected_index != -1:
interaction.clear_selection()
_set_status("Auswahl aufgehoben.")
get_viewport().set_input_as_handled()
KEY_DELETE:
if not ctrl:
if interaction.placing:
return
interaction.delete_selected()
get_viewport().set_input_as_handled()
# --------------------------------------------------------- Bibliothek ------
func _on_library_pressed(oid: String) -> void:
if interaction.placing and interaction.place_oid == oid:
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
interaction.cancel_placement()
_set_status("Platzieren abgebrochen.")
return
for other_oid in library_buttons:
(library_buttons[other_oid] as Button).button_pressed = false
library_buttons[oid].button_pressed = true
interaction.start_placement(oid)
## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf.
func _highlight_library(oid: String) -> void:
for other_oid in library_buttons:
var btn := library_buttons[other_oid] as Button
btn.button_pressed = other_oid == oid
btn.modulate = Color(1, 1, 1) if oid == other_oid else Color(1, 1, 1, 0.85)
func object_label(oid: String) -> String:
return GERMAN_NAMES.get(oid, oid.capitalize())
# ---------------------------------------------------- Werkzeug-Status ------
## Standard-Hilfstext, wenn gerade nichts "Im Gange" ist.
func _base_tool_status() -> void:
_set_status("Bereit. Links ein Objekt anklicken zum Platzieren oder in der Karte ein Objekt auswählen.")
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
func _on_tree_selected() -> void:
if _tree_select_lock:
return
var item := content_tree.get_selected()
if item == null or not item.has_meta("obj_index"):
return
var idx := int(item.get_meta("obj_index"))
var objects: Array = doc.get("objects", [])
if idx < 0 or idx >= objects.size():
return
interaction.select_object(idx, true)
_set_status("%s ausgewählt — ziehen zum Verschieben, R/Mausrad zum Drehen, Entf zum Löschen." % object_label(str(objects[idx].get("id", ""))))
## Auswahl (aus dem Viewport) im Baum mitmarkieren.
func _mark_tree_selection(idx: int) -> void:
_tree_select_lock = true
_mark_tree_selection_walk(content_tree.root, idx)
_tree_select_lock = false
func _mark_tree_selection_walk(item: TreeItem, idx: int) -> void:
if item == null:
return
if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx:
item.select(0)
content_tree.ensure_visible(item)
return
for child in item.get_children():
_mark_tree_selection_walk(child, idx)
## Rechte Panel-Sektion synchronisieren OHNE Statuszeilen-Text zu ändern
## (sonst überschreibt ein Stummsync die gerade gesetzte Hilfs-Meldung).
func _sync_selection_panel() -> void:
var objects: Array = doc.get("objects", [])
var idx: int = interaction.selected_index
if idx < 0 or idx >= objects.size():
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
sel_rotate_btn.disabled = true
sel_delete_btn.disabled = true
return
var obj: Dictionary = objects[idx]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Richtung: %.0f°\nVerschieben: Ziehen · Drehen: R oder Mausrad (15°)\nLöschen: Entf-Taste · Strg+Z nimmt zurück" % [
object_label(str(obj.get("id", ""))), idx + 1, objects.size(),
float(pos[0]), float(pos[2]), float(obj.get("rot_y_deg", 0.0))]
sel_rotate_btn.disabled = false
sel_delete_btn.disabled = false
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
func _selection_status(idx: int) -> void:
var objects: Array = doc.get("objects", [])
if idx < 0 or idx >= objects.size():
_base_tool_status()
return
var obj: Dictionary = objects[idx]
var used := int(SvmapDocument.poly_usage(doc, manifest))
var budget := int(SvmapIO.MAX_TOTAL_POLY)
var budget_warning := ""
if used > budget * 80 / 100:
budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget)
_set_status("%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Entf zum Löschen.%s" % [
object_label(str(obj.get("id", ""))), budget_warning])
# ------------------------------------------------------- Eigenschaften -----
func _on_prop_changed(text: String, field: String) -> void:
if _loading_ui_values or doc.is_empty():
@ -430,6 +724,20 @@ func _on_weather_changed(index: int) -> void:
_set_status("Wetter geändert auf „%s“." % weathers[index])
# ------------------------------------------------------- Struktur-Änderung --
## Nach undo/redo/platzieren/löschen: Struktur hat sich geändert → Ansicht neu.
func _after_structure_change() -> void:
dirty = true
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
if interaction != null:
interaction.call("clear_selection", true)
view_builder.call("rebuild", doc, manifest)
_refresh_tree()
# --------------------------------------------------------------- Anzeige ---
func _refresh_all() -> void:
@ -454,6 +762,7 @@ func _refresh_all() -> void:
_refresh_tree()
_refresh_stats()
_refresh_file_label()
_refresh_history_buttons()
_refresh_viewport()
@ -473,7 +782,8 @@ func _refresh_tree() -> void:
var obj = objects[i]
var item := content_tree.create_item(obj_item)
if obj is Dictionary:
item.set_text(0, "%s #%d" % [str(obj.get("id", "?")), i + 1])
item.set_text(0, "%s #%d" % [object_label(str(obj.get("id", "?"))), i + 1])
item.set_meta("obj_index", i)
else:
item.set_text(0, "ungültig #%d" % (i + 1))
@ -497,8 +807,9 @@ func _refresh_tree() -> void:
func _refresh_stats() -> void:
var count := SvmapDocument.object_count(doc)
var poly := SvmapDocument.poly_usage(doc, manifest)
stats_objects.text = "Objekte: %d / 5000" % count
stats_poly.text = "Poly-Budget: %s / 1.500.000" % _fmt_int(int(poly))
var budget := int(SvmapIO.MAX_TOTAL_POLY)
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [
count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget]
func _refresh_file_label() -> void:
@ -523,3 +834,26 @@ func _fmt_int(value: int) -> String:
out = "." + s.substr(s.length() - 3) + out
s = s.substr(0, s.length() - 3)
return s + out
# ---------------------------------------------------- Screenshot-Demo -------
## Desktop-Smoke-Hilfe: `godot ... -- --ghost-demo [x z objekt-id]` parkt die
## Platzier-Vorschau sichtbar in der Map, damit ein Screenshot sie zeigt.
func _maybe_start_demo_mode() -> void:
var args := OS.get_cmdline_user_args()
if not args.has("--ghost-demo"):
return
var oid := "crate_wood"
var x := 0.0
var z := 0.0
for i in args.size():
if args[i] == "--ghost-demo":
if i + 1 < args.size():
x = float(args[i + 1])
if i + 2 < args.size():
z = float(args[i + 2])
if i + 3 < args.size():
oid = args[i + 3]
var pos := Vector3(x, SvmapDocument.terrain_height_at(doc, x, z), z)
interaction.demo_park_ghost(pos, oid)

View file

@ -2,12 +2,16 @@ extends Node3D
## Baut die visuelle Repräsentation der Map im Editor-Viewport:
## Boden-Referenz (Plane in Terrain-Größe) + Maßstabsgitter + Himmel/Licht
## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung.
## Reine Ansicht — Interaktion/Werkzeuge kommen mit eigenen Karten.
## ##
## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index,
## pick_radius) — Auswahl/Werkzeuge (interaction.gd) greifen darauf zu,
## statt die Szene selbst zu durchsuchen.
const GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6)
const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9)
const GROUND_COLOR := Color(0.23, 0.32, 0.24)
const OBJECT_COLOR := Color(0.55, 0.75, 0.95)
const SELECT_COLOR := Color(1.0, 0.6, 0.15) # Auswahl klar orange
const TEAM_COLORS := [Color(0.3, 0.6, 1.0), Color(1.0, 0.35, 0.3)] # Blau / Rot
# Wetter → Licht (grobe Stimmung; Feintuning kommt mit dem Wetter-Konsum)
@ -18,6 +22,12 @@ const WEATHER_LIGHT := {
"nacht": {"energy": 0.25, "color": Color(0.6, 0.7, 1.0), "ambient": 0.12, "sky_top": Color(0.03, 0.05, 0.12)},
}
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
var _base_mat: StandardMaterial3D
var _select_mat: StandardMaterial3D
func _ready() -> void:
rebuild({}, {})
@ -27,6 +37,15 @@ func _ready() -> void:
func rebuild(data: Dictionary, manifest: Dictionary) -> void:
for child in get_children():
child.queue_free()
object_markers = []
_base_mat = StandardMaterial3D.new()
_base_mat.albedo_color = OBJECT_COLOR
_base_mat.roughness = 0.8
_select_mat = StandardMaterial3D.new()
_select_mat.albedo_color = SELECT_COLOR
_select_mat.emission_enabled = true
_select_mat.emission = SELECT_COLOR * 0.35
_select_mat.roughness = 0.7
if data.is_empty():
return
@ -132,60 +151,48 @@ func _add_line(mesh: ImmediateMesh, from: Vector3, to: Vector3, color: Color) ->
## Platzhalter je Objekt: Form aus dem Bibliotheks-Manifest (wie der Server-
## Kollider), Position = Objekt-Fußpunkt.
## Kollider), Position = Objekt-Fußpunkt. Marker-Root bekommt Metas für
## Auswahl/Anfassen: obj_index, pick_radius (Kugel-Approximation), half_height.
func _build_objects(objects: Array, manifest: Dictionary) -> void:
var lib: Dictionary = manifest.get("objects", {})
var mat := StandardMaterial3D.new()
mat.albedo_color = OBJECT_COLOR
mat.roughness = 0.8
for obj in objects:
for i in objects.size():
var obj = objects[i]
if not (obj is Dictionary):
object_markers.append(null)
continue
var oid := str(obj.get("id", ""))
if not lib.has(oid):
object_markers.append(null)
continue # Validierung fängt das — defensiv überspringen.
var entry: Dictionary = lib[oid]
var col: Dictionary = entry.get("collision", {})
var scale := float(obj.get("scale", 1.0))
var mi := MeshInstance3D.new()
var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, scale)
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
mi.mesh = _placeholder_mesh(str(col.get("shape", "box")), col, scale)
mi.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
mi.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
mi.position.y += _mesh_half_height(mi.mesh)
mi.material_override = mat
add_child(mi)
var marker := Node3D.new()
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
marker.set_meta("obj_index", i)
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.position.y = MeshShapes.half_height(mesh)
mi.material_override = _select_mat if i == selected_index else _base_mat
marker.add_child(mi)
var half := (mesh.get_aabb().size * 0.5) as Vector3
marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25))
marker.set_meta("half_height", MeshShapes.half_height(mesh))
object_markers.append(marker)
add_child(marker)
func _placeholder_mesh(shape: String, col: Dictionary, scale: float) -> Mesh:
match shape:
"sphere":
var s := SphereMesh.new()
var r := float(col.get("radius", 1.0)) * scale
s.radius = r
s.height = r * 2.0
return s
"cylinder":
var c := CylinderMesh.new()
c.top_radius = float(col.get("radius", 1.0)) * scale
c.bottom_radius = c.top_radius
c.height = float(col.get("height", 1.0)) * scale
return c
_:
var b := BoxMesh.new()
var size: Array = col.get("size", [1.0, 1.0, 1.0])
b.size = Vector3(float(size[0]), float(size[1]), float(size[2])) * scale
return b
func _mesh_half_height(mesh: Mesh) -> float:
if mesh is BoxMesh:
return (mesh as BoxMesh).size.y * 0.5
if mesh is CylinderMesh:
return (mesh as CylinderMesh).height * 0.5
if mesh is SphereMesh:
return (mesh as SphereMesh).radius
return 0.0
## Auswahl nachträglich umfärben (ohne ganzen Rebuild) — z. B. nach Drag.
func set_selected(idx: int) -> void:
selected_index = idx
for marker in object_markers:
if marker == null or not is_instance_valid(marker):
continue
var mi: MeshInstance3D = marker.get_child(0)
mi.material_override = _select_mat if int(marker.get_meta("obj_index", -1)) == idx else _base_mat
## Spawn-Marker: halbtransparente Säule + Label3D mit der Punkt-ID, teamfarbig.

View file

@ -0,0 +1,42 @@
extends RefCounted
class_name MeshShapes
## Platzhalter-Meshes für Bibliotheks-Objekte (Form aus dem Kollider-Eintrag,
## wie der Server-Sichtbarkeit) — geteilt von Viewport-Bauer und Ghost-Preview,
## damit Objekte im Editor überall identisch aussehen.
const SHAPE_BOX := "box"
const SHAPE_SPHERE := "sphere"
const SHAPE_CYLINDER := "cylinder"
static func make_placeholder(shape: String, col: Dictionary, scale: float) -> Mesh:
match shape:
SHAPE_SPHERE:
var s := SphereMesh.new()
var r := maxf(0.01, float(col.get("radius", 1.0)) * scale)
s.radius = r
s.height = r * 2.0
return s
SHAPE_CYLINDER:
var c := CylinderMesh.new()
c.top_radius = maxf(0.01, float(col.get("radius", 1.0)) * scale)
c.bottom_radius = c.top_radius
c.height = maxf(0.01, float(col.get("height", 1.0)) * scale)
return c
_:
var b := BoxMesh.new()
var size: Array = col.get("size", [1.0, 1.0, 1.0])
b.size = Vector3(maxf(0.01, float(size[0])), maxf(0.01, float(size[1])),
maxf(0.01, float(size[2]))) * scale
return b
## Halbe Höhe des Meshes über dem Fußpunkt (Mesh-Ursprung = Objekt-Fuß).
static func half_height(mesh: Mesh) -> float:
if mesh is BoxMesh:
return (mesh as BoxMesh).size.y * 0.5
if mesh is CylinderMesh:
return (mesh as CylinderMesh).height * 0.5
if mesh is SphereMesh:
return (mesh as SphereMesh).radius
return 0.0

View file

@ -1,9 +1,11 @@
extends Camera3D
## Orbit-Kamera für den Editor-Viewport (MVP-Grundgerüst).
## Orbit-Kamera für den Editor-Viewport.
## Steuerung (Maus, keine Tastenkürzel nötig — Chef-Grundsatz):
## Mittlere Maustaste ziehen = Drehen (Orbit)
## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden)
## Mausrad = Zoom
## Mausrad = Zoom — AUSSER ein Objekt/eine Platzier-Vorschau
## ist aktiv: dann dreht das Mausrad das Objekt
## (interaction.gd meldet das per wheel_filter).
## Ziel-Punkt, Winkel und Abstand bleiben intern; Position wird jede Frame
## aus ihnen berechnet.
@ -12,6 +14,10 @@ var yaw_deg := 45.0
var pitch_deg := 35.0
var distance := 300.0
## Von main.gd gesetzt: Callable → true, wenn das Mausrad gerade ein Objekt
## drehen soll (Platzier-Modus oder Auswahl) statt zu zoomen.
var wheel_filter := Callable()
var _dragging := false
var _panning := false
@ -27,9 +33,13 @@ func _unhandled_input(event: InputEvent) -> void:
_dragging = mb.pressed and not mb.shift_pressed
_panning = mb.pressed and mb.shift_pressed
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
if _wheel_blocked():
return
distance = maxf(5.0, distance * 0.9)
_apply()
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
if _wheel_blocked():
return
distance = minf(distance * 1.1, distance_max())
_apply()
elif event is InputEventMouseMotion:
@ -49,6 +59,10 @@ func _unhandled_input(event: InputEvent) -> void:
_apply()
func _wheel_blocked() -> bool:
return wheel_filter.is_valid() and bool(wheel_filter.call())
func distance_max() -> float:
return 6000.0

235
tests/test_ux.gd Normal file
View file

@ -0,0 +1,235 @@
extends SceneTree
## Editor-Tests (headless): Karte t_40d855d0 — UX-Fundament (Undo/Redo-Historie,
## Terrain-Höhen-Abfrage, Platzhalter-Meshes).
## Ausführen: godot --headless --path . --script res://tests/test_ux.gd
## Exit 0 = alles grün. Läuft OHNE UI (ActionHistory/SvmapDocument/MeshShapes).
## GUI-Funktionen (Ghost-Preview sichtbar, Auswahl-Färbung) werden im
## Desktop-Smoke auf Slys PC verifiziert (Screenshot), nicht hier.
var failures := 0
var checks := 0
func _init() -> void:
_run_tests()
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 _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
func _place(hist: ActionHistory, doc: Dictionary, id: String, pos: Array) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj(id, pos))
hist.commit("Test: %s platzieren" % id, before, hist.snapshot(["objects"]))
func _delete_at(hist: ActionHistory, doc: Dictionary, idx: int) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).remove_at(idx)
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
func _move_obj(hist: ActionHistory, doc: Dictionary, idx: int, pos: Array) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array)[idx]["pos"] = pos
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:%d" % idx)
func _rotate_obj(hist: ActionHistory, doc: Dictionary, idx: int, deg: float) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array)[idx]["rot_y_deg"] = float(deg)
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:%d" % idx)
func _run_tests() -> void:
_test_undo_roundtrip()
_test_merge_and_truncation()
_test_alias_safety()
_test_history_cap_and_clear()
_test_terrain_height()
_test_mesh_shapes()
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
## für löschen/verschieben/drehen.
func _test_undo_roundtrip() -> void:
print("Testgruppe: Undo/Redo-Roundtrip")
var doc := SvmapDocument.create_new("UX-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
_check(objects.is_empty(), "Start: Objekt-Liste leer")
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
_check(objects.size() == 1, "setzen: 1 Objekt")
_check(hist.can_undo() and not hist.can_redo(), "History: undo möglich, redo nicht")
var desc := hist.undo()
_check(objects.is_empty(), "undo: Objekt weg (setzen→undo)")
_check(hist.can_redo() and not hist.can_undo(), "nach undo: redo möglich, undo verbraucht")
_check(desc == "Test: crate_wood platzieren", "undo-Desc stimmt")
desc = hist.redo()
_check(objects.size() == 1 and str(objects[0]["id"]) == "crate_wood", "redo: Objekt identisch zurück (setzen→undo→redo)")
_check(desc == "Test: crate_wood platzieren", "redo-Desc stimmt")
_check(hist.can_undo() and not hist.can_redo(), "nach redo: Zustand konsistent")
_delete_at(hist, doc, 0)
_check(objects.is_empty(), "löschen: Liste leer")
hist.undo()
_check(objects.size() == 1, "löschen→undo: Objekt zurück")
hist.redo()
_check(objects.is_empty(), "löschen→undo→redo: wieder weg")
_place(hist, doc, "crate_metal", [0.0, 0.0, 0.0])
_check(objects.size() == 1, "weiterbearbeiten: Platzieren nach fertigem Roundtrip klappt")
_move_obj(hist, doc, 0, [5.0, 0.0, 7.5])
_check(float(objects[0]["pos"][0]) == 5.0, "verschieben: neue Position im Doc")
hist.undo()
_check(objects.size() == 1 and float(objects[0]["pos"][0]) == 0.0, "verschieben→undo: zurück auf Platzierungsposition")
hist.redo()
_check(float(objects[0]["pos"][0]) == 5.0 and float(objects[0]["pos"][2]) == 7.5, "verschieben→undo→redo: Position wiederhergestellt")
_rotate_obj(hist, doc, 0, 90.0)
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "drehen→undo: 0°")
hist.redo()
_check(absf(float(objects[0]["rot_y_deg"]) - 90.0) < 0.0001, "drehen→undo→redo: 90°")
## Mausrad/leichtes Drehen und Verschieben in Ticks dürfen die History NICHT
## fluten — gleicher merge_tag pro Objekt = EIN History-Eintrag.
func _test_merge_and_truncation() -> void:
print("Testgruppe: Merge-Tags (Mausrad-Ticks → 1 Schritt) + Redo-Ast")
var doc := SvmapDocument.create_new("Merge-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
_place(hist, doc, "watchtower_wood", [0.0, 0.0, 0.0])
var entries_after_place := hist.entries.size()
for i in 8:
var before := hist.snapshot(["objects"])
objects[0]["rot_y_deg"] = float((i + 1) * 15)
hist.commit("Objekt gedreht", before, hist.snapshot(["objects"]), "rot:0")
_check(hist.entries.size() == entries_after_place + 1, "8 Dreh-Ticks → EIN zusätzlicher History-Eintrag (geflutet wären 8)")
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Endposition 120° im Doc")
# innerster Zustand nach Merge-Undo = Zustand VOR der ersten Drehung (0°)
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "Merge-Undo springt auf 0° (nicht auf Zwischenstufe 105°)")
hist.redo()
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Merge-Redo wieder 120°")
# Neuer Schritt verwirft den Redo-Zweig
_place(hist, doc, "bush_small", [1.0, 0.0, 1.0])
_check(not hist.can_redo(), "neuer Schritt: Redo-Zweig verworfen")
hist.undo()
_check(objects.size() == 1, "undo nach Zweig: nur watchtower bleibt")
## Snapshot VOR Mutation darf nach Mutation unverändert bleiben (sonst
## „rettet“ undo versehentlich eine Mutation — Alias-Falle bei Godot-Dicts).
func _test_alias_safety() -> void:
print("Testgruppe: Alias-Sicherheit der Snapshots")
var doc := SvmapDocument.create_new("Alias", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj("crate_metal", [1.0, 0.0, 2.0]))
hist.commit("alias-test", before, hist.snapshot(["objects"]))
var stored_before: Array = hist.entries[0]["before"]["objects"]
_check(stored_before.is_empty(), "before-Snapshot: leer")
var stored_after: Array = hist.entries[0]["after"]["objects"]
stored_after.clear()
_check((doc["objects"] as Array).size() == 1, "Clear auf gespeichertem Snapshot ändert Doc nicht (tiefe Kopie)")
var doc_objects: Array = doc["objects"]
doc_objects.append({"id": "fence_metal", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
hist.undo()
_check((doc["objects"] as Array).is_empty(), "undo trotz Doc-Mutation: sauber auf before (leer)")
hist.redo()
_check((doc["objects"] as Array).size() == 1, "redo: Snapshot-Zustand (1 Objekt), nicht der mutierte")
func _test_history_cap_and_clear() -> void:
print("Testgruppe: Kapazität + begin() setzt zurück")
var doc := SvmapDocument.create_new("Cap", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
for i in ActionHistory.MAX_STEPS + 20:
_place(hist, doc, "crate_wood", [float(i), 0.0, 0.0])
_check(hist.entries.size() == ActionHistory.MAX_STEPS, "Cap: History hält sich an MAX_STEPS")
_check(objects.size() == ActionHistory.MAX_STEPS + 20, "Doc fuellt sich voll — Cap betrifft nur History-Tiefe")
hist.undo()
_check(objects.size() == ActionHistory.MAX_STEPS + 19, "auch nach Cap-Kippen funktioniert undo")
var doc2 := SvmapDocument.create_new("Frisch", 1024.0, "nacht")
hist.begin(doc2)
_check(not hist.can_undo() and not hist.can_redo(), "begin() mit neuer Map: History leer")
_check(hist.doc == doc2, "begin() hängt an der neuen Map-Referenz")
## Flache neue Map → Terrain-Höhe überall = min_height. Synthetischer Gradient →
## bilinear korrekt (roher uint16-Wert → Meter).
func _test_terrain_height() -> void:
print("Testgruppe: Terrain-Höhen-Abfrage")
var doc := SvmapDocument.create_new("Flach", 1024.0, "mittag")
_check(absf(SvmapDocument.terrain_height_at(doc, 0.0, 0.0) - 0.0) < 0.001, "flache Map: Höhe 0 m")
_check(absf(SvmapDocument.terrain_height_at(doc, 500.0, -500.0) - 0.0) < 0.001, "flache Map: auch am Rand 0 m")
# Synthetischer Gradient: res=17, size=160 → 10 m Raster; Raw-Wert = Zeile*655 (ROH skaliert)
var res := 17
var bytes := PackedByteArray()
bytes.resize(res * res * 2)
var i := 0
while i < res * res:
var row := i / res
var raw := int(round(float(row) / float(res - 1) * 65535.0))
bytes[i * 2] = raw & 0xFF
bytes[i * 2 + 1] = (raw >> 8) & 0xFF
i += 1
var slope := {
"type": "heightmap", "size_x_m": 160.0, "size_z_m": 160.0,
"min_height_m": 0.0, "max_height_m": 100.0, "resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(bytes),
}
var doc2 := {"terrain": slope}
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 0.0) - 0.0) < 0.5, "Gradient: Nordkante ≈ 0 m")
var mid := SvmapDocument.terrain_height_at(doc2, 0.0, 80.0)
_check(absf(mid - 50.0) < 1.0, "Gradient: Mitte ≈ 50 m (ist %.1f)" % mid)
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 160.0) - 100.0) < 0.5, "Gradient: Südkante ≈ 100 m")
_check(absf(SvmapDocument.terrain_height_at(doc2, 5000.0, -900.0) - 0.0) < 0.6, "Außerhalb-Query wird geclampt (keine Abstürze)")
func _test_mesh_shapes() -> void:
print("Testgruppe: Platzhalter-Meshes (Bibliothek/Kollider)")
var b := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 1.0)
_check(b is BoxMesh, "box → BoxMesh")
_check((b as BoxMesh).size == Vector3(2, 4, 6), "box: Größe 1:1 aus dem Kollider")
var scaled := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 2.0)
_check((scaled as BoxMesh).size.is_equal_approx(Vector3(4, 8, 12)), "box: scale=2 verdoppelt alle Achsen")
var sp := MeshShapes.make_placeholder("sphere", {"radius": 1.5}, 1.0)
_check(sp is SphereMesh and absf((sp as SphereMesh).radius - 1.5) < 0.001, "sphere → SphereMesh mit Radius")
var cyl := MeshShapes.make_placeholder("cylinder", {"radius": 0.4, "height": 12.0}, 1.0)
_check(cyl is CylinderMesh and absf((cyl as CylinderMesh).height - 12.0) < 0.001, "cylinder → CylinderMesh mit Höhe")
_check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention")
_check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius")
_check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m")