M2: Map-Editor UX-Fundament — Undo/Redo-History, Ghost-Preview, Auswahl-Anfassen, helfende Meldungen (t_40d855d0)
This commit is contained in:
parent
84bc08145b
commit
602cbc3402
9 changed files with 1267 additions and 109 deletions
61
README.md
61
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
|
M2 (siehe PLAN.md §6): Map-Paket-Format → Editor-MVP → erste offizielle Map
|
||||||
(offenes Gelände) → Map-Repo/Release-Workflow über Forgejo (M3).
|
(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
|
UX-Fundament auf dem App-Skelett (t_5badf371), nach dem Chef-Grundsatz gebaut:
|
||||||
Menüleiste „Datei“, 3D-Viewport mit Orbit-Kamera, Objekt-Baum links,
|
|
||||||
Map-Eigenschaften rechts, Statusleiste unten). Kein Spiel-Code-Import.
|
*(Unverändert darunter: svmap laden/speichern V1–V14 mit Prüfsummen-Interop,
|
||||||
- **Map-Datenmodell:** 1:1 das svmap-Format v1 (`docs/map_format.md` im
|
Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/
|
||||||
scopedvr-match-server-Repo) — Terrain-Parameter (Heightmap R16/LE/Base64),
|
Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)*
|
||||||
Objekt-Liste, Spawn-Zonen (Team 0/1), Metadaten + Wetter-Preset.
|
|
||||||
Implementierung: `src/svmap/svmap_io.gd` (Validierung V1–V14 wie der
|
- **Undo/Redo (`src/editor/action_history.gd`):** zentrale ActionHistory über
|
||||||
Server-Loader, identische Kanonisierung/Prüfsumme — interop-getestet in
|
die svmap-Ober-Blöcke (`objects`/`terrain`/`spawn_zones` — tiefe Kopien vor
|
||||||
beide Richtungen), `src/svmap/map_document.gd` (Neue-Map-Defaults).
|
und nach jeder Änderung, merge_tags gegen Mausrad-Tick-Fluten, Max-Stufe 100).
|
||||||
- **Laden/Speichern:** `.svmap` über Datei-Dialog; speichern validiert VOR dem
|
Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben.
|
||||||
Schreiben und signiert (sha256 über kanonisierte Payload). Speichern →
|
Spätere Werkzeuge melden sich mit `snapshot(keys)` vor und
|
||||||
Laden → Speichern ist byte-identisch. Ungültige Maps werden mit klaren,
|
`commit(desc, before, after)` nach der Änderung an — Terrain-Strokes und
|
||||||
deutschsprachigen Meldungen abgelehnt.
|
Spawn-Zonen landen damit automatisch in derselben History.
|
||||||
- **Viewport-Grundgerüst:** Orbit/Zoom/Pan per Maus (kein Tastenkürzel nötig),
|
- **Objekt-Platzierung mit Ghost-Preview (`src/editor/interaction.gd`):**
|
||||||
Maßstabsgitter + Boden-Referenz in Terrain-Größe, Platzhalter-Meshes für
|
Bibliothek links (deutsche Namen + Poly-Zahl), Klick startet den
|
||||||
Bibliotheks-Objekte, Spawn-Marker teamfarbig mit Beschriftung,
|
Platzier-Modus — eine transparente Vorschau schwebt am Cursor auf dem Boden
|
||||||
Wetter-Preset färbt Licht/Himmel.
|
(Höhe kommt aus dem Terrain), Klick setzt, Rechtsklick/ESC bricht ab,
|
||||||
- **Neue-Map-Dialog:** Name, Größe (512/1024/2048 m), Wetter-Preset. Neue Maps
|
Mausrad dreht die Vorschau VOR dem Setzen (15°-Raster). Modus bleibt an
|
||||||
sind sofort speicherbar (4 Default-Spawn-Punkte pro Team, V11 erfüllt).
|
für schnelles Mehrfachsetzen. Poly-Budget wird VOR dem Platzieren geprüft.
|
||||||
- **Tests:** `tests/test_editor_svmap.gd` (31 Checks: Roundtrip byte-identisch,
|
- **Auswahl-Anfassen:** Klick wählt ein Objekt (orange + Emission), Ziehen
|
||||||
V2–V13-Negativfälle, Poly-Budget, Server-Kompatibilität der Prüfsumme).
|
verschiebt es (auf Terrain-Höhe geclampt in der Map), R-Taste ODER Mausrad
|
||||||
Lauf: `godot --headless --path . --script res://tests/test_editor_svmap.gd`
|
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
|
**Bibliothek:** `library/objects.json` ist die Editor-Kopie der offiziellen
|
||||||
Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier
|
Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier
|
||||||
synchron ziehen (IDs sind der Vertrag, Entscheidung 20).
|
synchron ziehen (IDs sind der Vertrag, Entscheidung 20).
|
||||||
|
|
||||||
**Noch offen (folgende Karten der MVP-Kette):** Terrain-Werkzeuge
|
**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.
|
Spawn-Zonen-Editor, Performance-Check-UI, Export-Workflow.
|
||||||
|
|
|
||||||
100
src/editor/action_history.gd
Normal file
100
src/editor/action_history.gd
Normal 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
374
src/editor/interaction.gd
Normal 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))
|
||||||
|
|
@ -91,3 +91,42 @@ static func poly_usage(data: Dictionary, manifest: Dictionary) -> float:
|
||||||
var scale := float(obj.get("scale", 1.0))
|
var scale := float(obj.get("scale", 1.0))
|
||||||
total += poly * scale * scale
|
total += poly * scale * scale
|
||||||
return total
|
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)
|
||||||
|
|
|
||||||
412
src/ui/main.gd
412
src/ui/main.gd
|
|
@ -1,29 +1,61 @@
|
||||||
extends Control
|
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).
|
## 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
|
## Struktur: Top-Bar (Menü + Rückgängig/Wiederholen) | links Objekt-Bibliothek
|
||||||
## | rechts Map-Eigenschaften | unten Statusleiste.
|
## | Mitte 3D-Viewport | rechts Map-Eigenschaften + Auswahl | unten Statusleiste.
|
||||||
##
|
##
|
||||||
## MVP-Umfang: .svmap laden/speichern (V1–V14, wie im Server-Loader), neue Map,
|
## UX-Fundament dieser Karte (Chef-Grundsatz Sly — map-editor-README):
|
||||||
## Map-Metadaten editieren, Orbit-Viewport mit Gitter/Boden. Terrain-Werkzeuge
|
## 1. Undo/Redo (Strg+Z / Strg+Y) über ActionHistory — spätere Werkzeuge
|
||||||
## und Objekt-Platzierung kommen mit eigenen Karten (MVP-Kette im README).
|
## (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"
|
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 manifest: Dictionary = {}
|
||||||
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
|
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
|
||||||
var current_path := "" # leer = noch nie gespeichert
|
var current_path := "" # leer = noch nie gespeichert
|
||||||
var dirty := false
|
var dirty := false
|
||||||
|
|
||||||
|
var history := ActionHistory.new()
|
||||||
|
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
|
||||||
|
|
||||||
# UI-Referenzen
|
# UI-Referenzen
|
||||||
var content_tree: Tree
|
var content_tree: Tree
|
||||||
var stats_objects: Label
|
var stats_objects: Label
|
||||||
var stats_poly: Label
|
|
||||||
var status_label: Label
|
var status_label: Label
|
||||||
var file_label: Label
|
var file_label: Label
|
||||||
var view_builder: Node3D
|
var view_builder: Node3D
|
||||||
var camera: Camera3D
|
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
|
# Eigenschaften-Panel
|
||||||
var prop_name: LineEdit
|
var prop_name: LineEdit
|
||||||
|
|
@ -41,12 +73,14 @@ var error_dialog: AcceptDialog
|
||||||
var error_text: Label
|
var error_text: Label
|
||||||
|
|
||||||
var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder
|
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:
|
func _ready() -> void:
|
||||||
manifest = _load_manifest()
|
manifest = _load_manifest()
|
||||||
_build_ui()
|
_build_ui()
|
||||||
_start_with_new_map()
|
_start_with_new_map()
|
||||||
|
_maybe_start_demo_mode()
|
||||||
|
|
||||||
|
|
||||||
func _load_manifest() -> Dictionary:
|
func _load_manifest() -> Dictionary:
|
||||||
|
|
@ -69,7 +103,7 @@ func _build_ui() -> void:
|
||||||
vbox.add_theme_constant_override("separation", 4)
|
vbox.add_theme_constant_override("separation", 4)
|
||||||
add_child(vbox)
|
add_child(vbox)
|
||||||
|
|
||||||
# --- Top-Bar ---
|
# --- Top-Bar: Menü + Rückgängig/Wiederholen ---
|
||||||
var top := PanelContainer.new()
|
var top := PanelContainer.new()
|
||||||
var top_h := HBoxContainer.new()
|
var top_h := HBoxContainer.new()
|
||||||
top_h.add_theme_constant_override("separation", 10)
|
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("Neue Map…", 0)
|
||||||
popup.add_item("Öffnen…", 1)
|
popup.add_item("Öffnen…", 1)
|
||||||
popup.add_separator()
|
popup.add_separator()
|
||||||
popup.add_item("Speichern", 2)
|
popup.add_item("Speichern (Strg+S)", 2)
|
||||||
popup.add_item("Speichern unter…", 3)
|
popup.add_item("Speichern unter…", 3)
|
||||||
popup.add_separator()
|
popup.add_separator()
|
||||||
popup.add_item("Beenden", 4)
|
popup.add_item("Beenden", 4)
|
||||||
popup.id_pressed.connect(_on_menu_action)
|
popup.id_pressed.connect(_on_menu_action)
|
||||||
top_h.add_child(file_menu)
|
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()
|
var title_label := Label.new()
|
||||||
title_label.text = " %s" % APP_TITLE
|
title_label.text = " %s" % APP_TITLE
|
||||||
title_label.add_theme_font_size_override("font_size", 20)
|
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_vertical = Control.SIZE_EXPAND_FILL
|
||||||
split.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
split.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
split.split_offset = -320
|
split.split_offset = -320
|
||||||
split.collapsed = true # rechte Eigenschaften-Klappen via "expand"-Flag unten
|
|
||||||
vbox.add_child(split)
|
vbox.add_child(split)
|
||||||
|
|
||||||
split.add_child(_build_left_panel())
|
split.add_child(_build_left_panel())
|
||||||
split.add_child(_build_center_viewport())
|
split.add_child(_build_center_viewport())
|
||||||
split.add_child(_build_right_panel())
|
split.add_child(_build_right_panel())
|
||||||
|
|
||||||
# --- Statusleiste ---
|
# --- Statusleiste: Hilfs-Meldungen + Budget ---
|
||||||
var status_panel := PanelContainer.new()
|
var status_panel := PanelContainer.new()
|
||||||
var status_h := HBoxContainer.new()
|
var status_h := HBoxContainer.new()
|
||||||
status_h.add_theme_constant_override("separation", 16)
|
status_h.add_theme_constant_override("separation", 16)
|
||||||
|
|
@ -126,17 +173,13 @@ func _build_ui() -> void:
|
||||||
|
|
||||||
status_label = Label.new()
|
status_label = Label.new()
|
||||||
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
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)
|
status_h.add_child(status_label)
|
||||||
|
|
||||||
stats_objects = Label.new()
|
stats_objects = Label.new()
|
||||||
stats_objects.modulate = Color(1, 1, 1, 0.75)
|
stats_objects.modulate = Color(1, 1, 1, 0.75)
|
||||||
status_h.add_child(stats_objects)
|
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 ---
|
# --- Dialoge ---
|
||||||
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
|
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
|
||||||
new_dialog.confirmed.connect(_on_new_map_confirmed)
|
new_dialog.confirmed.connect(_on_new_map_confirmed)
|
||||||
|
|
@ -177,23 +220,50 @@ func _build_left_panel() -> PanelContainer:
|
||||||
vbox.add_theme_constant_override("separation", 8)
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
panel.add_child(vbox)
|
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"))
|
vbox.add_child(_header_label("Map-Inhalt"))
|
||||||
|
|
||||||
content_tree = Tree.new()
|
content_tree = Tree.new()
|
||||||
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
content_tree.custom_minimum_size = Vector2(0, 200)
|
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.hide_root = true
|
||||||
|
content_tree.item_selected.connect(_on_tree_selected)
|
||||||
vbox.add_child(content_tree)
|
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
|
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:
|
func _build_center_viewport() -> SubViewportContainer:
|
||||||
var container := SubViewportContainer.new()
|
var container := SubViewportContainer.new()
|
||||||
container.stretch = true
|
container.stretch = true
|
||||||
|
|
@ -217,13 +287,19 @@ func _build_center_viewport() -> SubViewportContainer:
|
||||||
camera.current = true
|
camera.current = true
|
||||||
world.add_child(camera)
|
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)
|
# Bedien-Hinweis im Viewport (Overlay)
|
||||||
var help := Label.new()
|
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.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
help.offset_left = 12
|
help.offset_left = 12
|
||||||
help.offset_bottom = -8
|
help.offset_bottom = -8
|
||||||
help.offset_top = -34
|
help.offset_top = -46
|
||||||
help.modulate = Color(1, 1, 1, 0.65)
|
help.modulate = Color(1, 1, 1, 0.65)
|
||||||
container.add_child(help)
|
container.add_child(help)
|
||||||
return container
|
return container
|
||||||
|
|
@ -240,6 +316,31 @@ func _build_right_panel() -> PanelContainer:
|
||||||
vbox.add_theme_constant_override("separation", 8)
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
scroll.add_child(vbox)
|
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(_header_label("Map-Eigenschaften"))
|
||||||
|
|
||||||
vbox.add_child(_field_label("Name"))
|
vbox.add_child(_field_label("Name"))
|
||||||
|
|
@ -270,16 +371,13 @@ func _build_right_panel() -> PanelContainer:
|
||||||
prop_weather.add_item("Nacht (dunkel)")
|
prop_weather.add_item("Nacht (dunkel)")
|
||||||
vbox.add_child(prop_weather)
|
vbox.add_child(prop_weather)
|
||||||
|
|
||||||
vbox.add_child(HSeparator.new())
|
vbox.add_child(_separator())
|
||||||
vbox.add_child(_header_label("Terrain"))
|
vbox.add_child(_header_label("Terrain"))
|
||||||
terrain_info = Label.new()
|
terrain_info = Label.new()
|
||||||
terrain_info.text = ""
|
terrain_info.text = ""
|
||||||
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
vbox.add_child(terrain_info)
|
vbox.add_child(terrain_info)
|
||||||
var note := Label.new()
|
vbox.add_child(_muted_label("Terrain-Höhen bearbeiten kommt mit der nächsten Version."))
|
||||||
note.text = "Terrain-Höhen bearbeiten kommt mit\nder nächsten Version."
|
|
||||||
note.modulate = Color(1, 1, 1, 0.55)
|
|
||||||
vbox.add_child(note)
|
|
||||||
|
|
||||||
# Signale (Änderungen → Dokument + Ansicht)
|
# Signale (Änderungen → Dokument + Ansicht)
|
||||||
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
|
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
|
||||||
|
|
@ -305,14 +403,27 @@ func _field_label(text: String) -> Label:
|
||||||
return 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 --
|
# ------------------------------------------------------------ Map-Aktionen --
|
||||||
|
|
||||||
func _start_with_new_map() -> void:
|
func _start_with_new_map() -> void:
|
||||||
doc = SvmapDocument.create_new("Neue Map", 1024.0, "mittag")
|
doc = SvmapDocument.create_new("Neue Map", 1024.0, "mittag")
|
||||||
current_path = ""
|
current_path = ""
|
||||||
dirty = false
|
dirty = false
|
||||||
|
history.begin(doc)
|
||||||
_refresh_all()
|
_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:
|
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())
|
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather())
|
||||||
current_path = ""
|
current_path = ""
|
||||||
dirty = false
|
dirty = false
|
||||||
|
history.begin(doc)
|
||||||
|
_clear_edit_state()
|
||||||
_refresh_all()
|
_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()])
|
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:
|
func _on_open_selected(path: String) -> void:
|
||||||
var result := SvmapIO.load_from_file(path)
|
var result := SvmapIO.load_from_file(path)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
_show_errors("Map konnte NICHT geladen werden:", result["errors"])
|
_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" % path.get_file(), true)
|
_set_status("Laden fehlgeschlagen: %s — versuche eine .svmap-Datei aus dem Map-Editor oder vom Server." % path.get_file(), true)
|
||||||
return
|
return
|
||||||
doc = result["data"]
|
doc = result["data"]
|
||||||
current_path = path
|
current_path = path
|
||||||
dirty = false
|
dirty = false
|
||||||
|
history.begin(doc)
|
||||||
|
_clear_edit_state()
|
||||||
_refresh_all()
|
_refresh_all()
|
||||||
var map_name := str(doc.get("info", {}).get("name", "?"))
|
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:
|
func _save() -> void:
|
||||||
|
|
@ -393,7 +514,180 @@ func _show_errors(title: String, errors: Array) -> void:
|
||||||
error_dialog.popup_centered()
|
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:
|
func _on_prop_changed(text: String, field: String) -> void:
|
||||||
if _loading_ui_values or doc.is_empty():
|
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])
|
_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 ---
|
# --------------------------------------------------------------- Anzeige ---
|
||||||
|
|
||||||
func _refresh_all() -> void:
|
func _refresh_all() -> void:
|
||||||
|
|
@ -454,6 +762,7 @@ func _refresh_all() -> void:
|
||||||
_refresh_tree()
|
_refresh_tree()
|
||||||
_refresh_stats()
|
_refresh_stats()
|
||||||
_refresh_file_label()
|
_refresh_file_label()
|
||||||
|
_refresh_history_buttons()
|
||||||
_refresh_viewport()
|
_refresh_viewport()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -473,7 +782,8 @@ func _refresh_tree() -> void:
|
||||||
var obj = objects[i]
|
var obj = objects[i]
|
||||||
var item := content_tree.create_item(obj_item)
|
var item := content_tree.create_item(obj_item)
|
||||||
if obj is Dictionary:
|
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:
|
else:
|
||||||
item.set_text(0, "ungültig #%d" % (i + 1))
|
item.set_text(0, "ungültig #%d" % (i + 1))
|
||||||
|
|
||||||
|
|
@ -497,8 +807,9 @@ func _refresh_tree() -> void:
|
||||||
func _refresh_stats() -> void:
|
func _refresh_stats() -> void:
|
||||||
var count := SvmapDocument.object_count(doc)
|
var count := SvmapDocument.object_count(doc)
|
||||||
var poly := SvmapDocument.poly_usage(doc, manifest)
|
var poly := SvmapDocument.poly_usage(doc, manifest)
|
||||||
stats_objects.text = "Objekte: %d / 5000" % count
|
var budget := int(SvmapIO.MAX_TOTAL_POLY)
|
||||||
stats_poly.text = "Poly-Budget: %s / 1.500.000" % _fmt_int(int(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:
|
func _refresh_file_label() -> void:
|
||||||
|
|
@ -523,3 +834,26 @@ func _fmt_int(value: int) -> String:
|
||||||
out = "." + s.substr(s.length() - 3) + out
|
out = "." + s.substr(s.length() - 3) + out
|
||||||
s = s.substr(0, s.length() - 3)
|
s = s.substr(0, s.length() - 3)
|
||||||
return s + out
|
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)
|
||||||
|
|
@ -2,12 +2,16 @@ extends Node3D
|
||||||
## Baut die visuelle Repräsentation der Map im Editor-Viewport:
|
## Baut die visuelle Repräsentation der Map im Editor-Viewport:
|
||||||
## Boden-Referenz (Plane in Terrain-Größe) + Maßstabsgitter + Himmel/Licht
|
## Boden-Referenz (Plane in Terrain-Größe) + Maßstabsgitter + Himmel/Licht
|
||||||
## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung.
|
## + 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 GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6)
|
||||||
const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9)
|
const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9)
|
||||||
const GROUND_COLOR := Color(0.23, 0.32, 0.24)
|
const GROUND_COLOR := Color(0.23, 0.32, 0.24)
|
||||||
const OBJECT_COLOR := Color(0.55, 0.75, 0.95)
|
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
|
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)
|
# 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)},
|
"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:
|
func _ready() -> void:
|
||||||
rebuild({}, {})
|
rebuild({}, {})
|
||||||
|
|
@ -27,6 +37,15 @@ func _ready() -> void:
|
||||||
func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
child.queue_free()
|
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():
|
if data.is_empty():
|
||||||
return
|
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-
|
## 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:
|
func _build_objects(objects: Array, manifest: Dictionary) -> void:
|
||||||
var lib: Dictionary = manifest.get("objects", {})
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
var mat := StandardMaterial3D.new()
|
for i in objects.size():
|
||||||
mat.albedo_color = OBJECT_COLOR
|
var obj = objects[i]
|
||||||
mat.roughness = 0.8
|
|
||||||
for obj in objects:
|
|
||||||
if not (obj is Dictionary):
|
if not (obj is Dictionary):
|
||||||
|
object_markers.append(null)
|
||||||
continue
|
continue
|
||||||
var oid := str(obj.get("id", ""))
|
var oid := str(obj.get("id", ""))
|
||||||
if not lib.has(oid):
|
if not lib.has(oid):
|
||||||
|
object_markers.append(null)
|
||||||
continue # Validierung fängt das — defensiv überspringen.
|
continue # Validierung fängt das — defensiv überspringen.
|
||||||
var entry: Dictionary = lib[oid]
|
var entry: Dictionary = lib[oid]
|
||||||
var col: Dictionary = entry.get("collision", {})
|
var col: Dictionary = entry.get("collision", {})
|
||||||
var scale := float(obj.get("scale", 1.0))
|
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])
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
mi.mesh = _placeholder_mesh(str(col.get("shape", "box")), col, scale)
|
var marker := Node3D.new()
|
||||||
mi.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
|
marker.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)))
|
marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
|
||||||
mi.position.y += _mesh_half_height(mi.mesh)
|
marker.set_meta("obj_index", i)
|
||||||
mi.material_override = mat
|
var mi := MeshInstance3D.new()
|
||||||
add_child(mi)
|
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:
|
## Auswahl nachträglich umfärben (ohne ganzen Rebuild) — z. B. nach Drag.
|
||||||
match shape:
|
func set_selected(idx: int) -> void:
|
||||||
"sphere":
|
selected_index = idx
|
||||||
var s := SphereMesh.new()
|
for marker in object_markers:
|
||||||
var r := float(col.get("radius", 1.0)) * scale
|
if marker == null or not is_instance_valid(marker):
|
||||||
s.radius = r
|
continue
|
||||||
s.height = r * 2.0
|
var mi: MeshInstance3D = marker.get_child(0)
|
||||||
return s
|
mi.material_override = _select_mat if int(marker.get_meta("obj_index", -1)) == idx else _base_mat
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
## Spawn-Marker: halbtransparente Säule + Label3D mit der Punkt-ID, teamfarbig.
|
## Spawn-Marker: halbtransparente Säule + Label3D mit der Punkt-ID, teamfarbig.
|
||||||
|
|
|
||||||
42
src/viewport/mesh_shapes.gd
Normal file
42
src/viewport/mesh_shapes.gd
Normal 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
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
extends Camera3D
|
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):
|
## Steuerung (Maus, keine Tastenkürzel nötig — Chef-Grundsatz):
|
||||||
## Mittlere Maustaste ziehen = Drehen (Orbit)
|
## Mittlere Maustaste ziehen = Drehen (Orbit)
|
||||||
## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden)
|
## 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
|
## Ziel-Punkt, Winkel und Abstand bleiben intern; Position wird jede Frame
|
||||||
## aus ihnen berechnet.
|
## aus ihnen berechnet.
|
||||||
|
|
||||||
|
|
@ -12,6 +14,10 @@ var yaw_deg := 45.0
|
||||||
var pitch_deg := 35.0
|
var pitch_deg := 35.0
|
||||||
var distance := 300.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 _dragging := false
|
||||||
var _panning := false
|
var _panning := false
|
||||||
|
|
||||||
|
|
@ -27,9 +33,13 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
_dragging = mb.pressed and not mb.shift_pressed
|
_dragging = mb.pressed and not mb.shift_pressed
|
||||||
_panning = mb.pressed and mb.shift_pressed
|
_panning = mb.pressed and mb.shift_pressed
|
||||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
||||||
|
if _wheel_blocked():
|
||||||
|
return
|
||||||
distance = maxf(5.0, distance * 0.9)
|
distance = maxf(5.0, distance * 0.9)
|
||||||
_apply()
|
_apply()
|
||||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
||||||
|
if _wheel_blocked():
|
||||||
|
return
|
||||||
distance = minf(distance * 1.1, distance_max())
|
distance = minf(distance * 1.1, distance_max())
|
||||||
_apply()
|
_apply()
|
||||||
elif event is InputEventMouseMotion:
|
elif event is InputEventMouseMotion:
|
||||||
|
|
@ -49,6 +59,10 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
_apply()
|
_apply()
|
||||||
|
|
||||||
|
|
||||||
|
func _wheel_blocked() -> bool:
|
||||||
|
return wheel_filter.is_valid() and bool(wheel_filter.call())
|
||||||
|
|
||||||
|
|
||||||
func distance_max() -> float:
|
func distance_max() -> float:
|
||||||
return 6000.0
|
return 6000.0
|
||||||
|
|
||||||
|
|
|
||||||
235
tests/test_ux.gd
Normal file
235
tests/test_ux.gd
Normal 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")
|
||||||
Loading…
Reference in a new issue