From 602cbc3402f8b22b31e1c3d6767fe0767990fd1a Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Thu, 15 Oct 2026 09:45:48 +0000 Subject: [PATCH] =?UTF-8?q?M2:=20Map-Editor=20UX-Fundament=20=E2=80=94=20U?= =?UTF-8?q?ndo/Redo-History,=20Ghost-Preview,=20Auswahl-Anfassen,=20helfen?= =?UTF-8?q?de=20Meldungen=20(t=5F40d855d0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 61 +++-- src/editor/action_history.gd | 100 ++++++++ src/editor/interaction.gd | 374 ++++++++++++++++++++++++++++ src/svmap/map_document.gd | 39 +++ src/ui/main.gd | 412 ++++++++++++++++++++++++++++--- src/viewport/map_view_builder.gd | 93 +++---- src/viewport/mesh_shapes.gd | 42 ++++ src/viewport/orbit_camera.gd | 20 +- tests/test_ux.gd | 235 ++++++++++++++++++ 9 files changed, 1267 insertions(+), 109 deletions(-) create mode 100644 src/editor/action_history.gd create mode 100644 src/editor/interaction.gd create mode 100644 src/viewport/mesh_shapes.gd create mode 100644 tests/test_ux.gd diff --git a/README.md b/README.md index 1105aef..4c5ba6e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/editor/action_history.gd b/src/editor/action_history.gd new file mode 100644 index 0000000..9e5467b --- /dev/null +++ b/src/editor/action_history.gd @@ -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", "")) \ No newline at end of file diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd new file mode 100644 index 0000000..874f247 --- /dev/null +++ b/src/editor/interaction.gd @@ -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)) \ No newline at end of file diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 44d26de..5263646 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -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) diff --git a/src/ui/main.gd b/src/ui/main.gd index eecebfe..a63e358 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -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) \ No newline at end of file diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index becd19e..7a67303 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -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. @@ -221,4 +228,4 @@ func _build_spawns(zones: Dictionary) -> void: label.font_size = 220 label.pixel_size = 0.01 marker.add_child(label) - add_child(marker) + add_child(marker) \ No newline at end of file diff --git a/src/viewport/mesh_shapes.gd b/src/viewport/mesh_shapes.gd new file mode 100644 index 0000000..f5cdeb5 --- /dev/null +++ b/src/viewport/mesh_shapes.gd @@ -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 \ No newline at end of file diff --git a/src/viewport/orbit_camera.gd b/src/viewport/orbit_camera.gd index 22b92b4..8a37a7a 100644 --- a/src/viewport/orbit_camera.gd +++ b/src/viewport/orbit_camera.gd @@ -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 @@ -71,4 +85,4 @@ func _apply() -> void: cos(yaw) * cos(pitch) ) * distance position = target + offset - look_at(target, Vector3.UP) + look_at(target, Vector3.UP) \ No newline at end of file diff --git a/tests/test_ux.gd b/tests/test_ux.gd new file mode 100644 index 0000000..045af33 --- /dev/null +++ b/tests/test_ux.gd @@ -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") \ No newline at end of file