Objekt-Ops: Duplizieren (Strg+D) + Anheben/Absenken (Bild hoch/runter) + Spiegeln (X/Z)
Karte t_7567a06f. Hoehe wohnt direkt in object.pos.y — KEIN svmap-Format- Feld noetig (V9 erlaubt freie y-Werte in bounds; Server-Loader geprueft). Absenken stoppt am Terrain (nichts begraben), Anheben deckelt auf bounds.max. Spiegeln am Objekt selbst (Position bleibt): X → −rot, Z → 180°−rot selbstinvers. Alles über ActionHistory (Merge-Tags für Lift-Serien), deutsche UI + Klartext-Status, dragt behält Terrain-Abstand. test_ux.gd: 90 Checks (42 neu), Suite 5/5 grün, xvfb-Smoke UI-Bau ok.
This commit is contained in:
parent
ce1dc4130d
commit
eca11bbf83
7 changed files with 480 additions and 13 deletions
51
README.md
51
README.md
|
|
@ -162,3 +162,54 @@ Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz:
|
|||
- **Desktop-Smoke:** `godot … -- --spawns-demo [ordner] [--spawns-demo-quit]`
|
||||
fährt Zonen-Übersicht → Live-Check-Alarm → Undo → „Karte prüfen“
|
||||
programmatisch durch und speichert Screenshots.
|
||||
|
||||
## Status (M2, Karte t_7567a06f — Objekt-Ops: Duplizieren + Hoch/Runter + Spiegeln)
|
||||
|
||||
Auf dem UX-Fundament (t_40d855d0) und der Objekt-Bibliothek (t_3a998ae4)
|
||||
aufgebaut, nach dem Chef-Grundsatz (Klick statt Konsole, deutsche UI,
|
||||
Undo für alles):
|
||||
|
||||
- **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des
|
||||
ausgewählten Objekts hängt diagonal 1 m versetzt daneben (mit Raster-Snap
|
||||
eingerastet, wenn an) und ist DIREKT angewählt — sie lässt sich gleich
|
||||
weiterziehen/drehen/löschen. Drehung, Größe und Höhe werden übernommen.
|
||||
Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft
|
||||
(Klartext-Meldung, was zu tun ist). EIN Undo-Schritt.
|
||||
- **Anheben/Absenken (`▲ Anheben / ▼ Absenken` + Bild↑/Bild↓):** Objekte
|
||||
ändern die Höhe in 0,25-m-Schritten. MEHRERE Klicks in Serie = EIN
|
||||
Undo-Schritt. Design-Entscheidungen, dokumentiert:
|
||||
- Die Höhe wohnt direkt in `object.pos.y` — das svmap-Format erlaubt
|
||||
freie y-Werte innerhalb der Match-Grenzen (Loader-Regel V9), deshalb
|
||||
ist **KEIN Extra-Feld im Dateiformat nötig** und die Loader-Kompatibilität
|
||||
bleibt unangetastet (bewiesen in `tests/test_ux.gd`: Erhöhtes Objekt
|
||||
überlebt gespeichern → laden intakt (Höhe bleibt exakt)).
|
||||
- **Absenken stoppt an der Terrain-Höhe** am Objektstandort — nichts wird
|
||||
in Hügel begraben. Steckt ein Objekt im Boden (z. B. nach dem
|
||||
Terrain-Formen), bewegt Absenken es NICHT, und die Statusleiste erklärt,
|
||||
dass `▲ Anheben` oder `⚡ Alles auf den Boden setzen` hilft.
|
||||
- **Anheben** hat als Obergrenze die Match-Grenze `bounds.max.y` — höher
|
||||
passt der Server nicht (V9).
|
||||
- **Ziehen behält die Höhe:** Ein angehobenes Objekt schwebt beim
|
||||
Verschieben in derselben Höhe über dem (neuen) Boden weiter; bodennah
|
||||
bleibt bodennah.
|
||||
- Die Auswahl-Anzeige zeigt die Höhe mit („+0,25 m über dem Boden“).
|
||||
- **Spiegeln (`⇄ Links↔Rechts` / `⇅ Vor↔Zurück`):** Das Objekt wird AN
|
||||
SEINER STelle gespiegelt — Position bleibt, die Blickrichtung dreht:
|
||||
- links↔rechts (Welt-X-Spiegelung): `rot_y_deg → −rot`
|
||||
- vor↔zurück (Welt-Z-Spiegelung): `rot_y_deg → 180° − rot`
|
||||
(Herleitung im Quellcode: gespiegelter Richtungs-Vektor = gespiegelter
|
||||
Winkel; jede Spiegelung ist dadurch selbstinvers — zweimal spiegeln =
|
||||
Ausgangszustand.) Die Rotation wird mitgespiegelt, wie bestellt.
|
||||
Bewusst NICHT um die Kartemitte gespiegelt: das würde das Objekt quer
|
||||
über die Map springen lassen — überraschend für Einsteiger. Klartext
|
||||
in Buttons + Tooltip.
|
||||
- **Alles über ActionHistory:** Snapshot vor/nach JEDE Aktion
|
||||
(Duplizieren = EIN Schritt, Anheben-Serie = EIN Schritt via Merge-Tag,
|
||||
Spiegeln = EIN Schritt). Statusleiste erklärt nach jeder Aktion den
|
||||
nächsten Schritt und die Grenzen auf Deutsch.
|
||||
- **Tests:** `tests/test_ux.gd` auf 90 Checks erweitert (42 neu):
|
||||
Duplizieren (Raster/ohne Raster/Undo-Roundtrip/Index-Schutz), Anheben/
|
||||
Absenken (Schrittweite, Boden-Stopp, Deckel bounds.max, versteckt-Regel,
|
||||
Merge-Tags), Spiegeln (X/Z-Mapping, Selbstinvers, Position unverändert),
|
||||
svmap-Roundtrip mit angehobener Höhe. `godot … --quit-after` smoke:
|
||||
UI baut alle neuen Buttons fehlerfrei.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ const ROT_STEP_DEG := 15.0
|
|||
const DRAG_THRESHOLD_PX := 4.0
|
||||
const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4)
|
||||
|
||||
# Höhe beim Ziehen merken (Karte t_7567a06f): das Objekt behält seinen
|
||||
# Abstand zum Terrain — ein angehobenes Objekt bleibt beim Verschieben oben.
|
||||
|
||||
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
|
||||
var camera: Camera3D
|
||||
var view_builder # script-erweitert, Variant
|
||||
|
|
@ -31,6 +34,7 @@ var _dragging := false
|
|||
var _drag_moved := false
|
||||
var _press_pos := Vector2.ZERO
|
||||
var _grab_offset := Vector3.ZERO
|
||||
var _drag_lift := 0.0 # Terrain-Abstand des Objekts beim Anfassen (Höhe hebt sich mit)
|
||||
|
||||
|
||||
func setup(p_main, p_camera: Camera3D, p_builder) -> void:
|
||||
|
|
@ -272,6 +276,121 @@ func delete_selected() -> void:
|
|||
main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label)
|
||||
|
||||
|
||||
# ------------------------------------------ Duplizieren/Höhe/Spiegeln ------
|
||||
## (Karte t_7567a06f — alle drei auch bei Sly bedienbar: Button rechts +
|
||||
## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.)
|
||||
|
||||
## Strg+D / Button "Duplizieren": Kopie diagonal leicht versetzt (mit
|
||||
## Raster-Snap eingerastet, wenn an), direkt angewählt, EIN Undo-Schritt.
|
||||
## Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft.
|
||||
func duplicate_selected() -> void:
|
||||
if selected_index < 0:
|
||||
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true)
|
||||
return
|
||||
var objects: Array = main.doc.get("objects", [])
|
||||
if selected_index >= objects.size():
|
||||
clear_selection(true)
|
||||
return
|
||||
if objects.size() >= SvmapIO.MAX_OBJECTS:
|
||||
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true)
|
||||
return
|
||||
var obj: Dictionary = objects[selected_index]
|
||||
var oid := str(obj.get("id", ""))
|
||||
var lib: Dictionary = main.manifest.get("objects", {})
|
||||
var entry: Dictionary = lib.get(oid, {})
|
||||
var scl := float(obj.get("scale", 1.0))
|
||||
var poly := float(entry.get("poly", 0)) * scl * scl
|
||||
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
|
||||
var budget := float(SvmapIO.MAX_TOTAL_POLY)
|
||||
if used + poly > budget:
|
||||
main.call("_set_status", "Für eine Kopie fehlt das Poly-Budget (%s von %s belegt) — Strg+Z nimmt etwas zurück, oder lösche ein anderes Objekt." % [
|
||||
main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
|
||||
return
|
||||
var before: Dictionary = main.history.snapshot(["objects"])
|
||||
var new_index: int = ObjectOps.duplicate_object(main.doc, selected_index, bool(main.get("grid_snap")))
|
||||
if new_index < 0:
|
||||
clear_selection(true)
|
||||
return
|
||||
main.history.commit("%s duplizieren" % main.object_label(oid),
|
||||
before, main.history.snapshot(["objects"]))
|
||||
main.call("_after_structure_change")
|
||||
# Kopie direkt anwählen — Sly kann sie gleich weiterziehen.
|
||||
select_object(new_index)
|
||||
var objects2: Array = main.doc.get("objects", [])
|
||||
var label: String = main.object_label(str(objects2[new_index].get("id", oid)))
|
||||
main.call("_set_status", "%s dupliziert: das neue Objekt ist direkt angewählt — Ziehen verschiebt es, Entf löscht es wieder." % label)
|
||||
|
||||
|
||||
## ▲/▼ / Strg+Bild↑/↓: Objekt in 0,25-m-Schritten anheben/absenken.
|
||||
## Mehrere Klicks in Serie = EIN Undo-Schritt (Merge-Tag, wie beim Drehen).
|
||||
## Absenken stoppt am Terrain (nichts wird begraben), Anheben an der
|
||||
## Match-Grenze (bounds.max) — Regeln in ObjectOps.lift_object.
|
||||
func lift_selected(delta_m: float) -> void:
|
||||
if selected_index < 0:
|
||||
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du anheben oder absenken willst.", true)
|
||||
return
|
||||
if placing:
|
||||
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 applied := ObjectOps.lift_object(main.doc, selected_index, delta_m)
|
||||
if absf(applied) < 0.0001:
|
||||
# Nichts passiert (Snapshot verwerfen): hilfsbereit erklären, WO die Grenze ist.
|
||||
var ground: String
|
||||
if delta_m > 0.0:
|
||||
ground = "Die Match-Höhe ist erreicht — höher passt der Server nicht mehr."
|
||||
else:
|
||||
ground = "Weiter absenken geht nicht: Das Objekt liegt am Boden. Steckt es IN einem Hügel? ▲ Anheben oder \"⚡ Alles auf den Boden setzen\" holt es raus."
|
||||
main.call("_set_status", ground)
|
||||
return
|
||||
var after: Dictionary = main.history.snapshot(["objects"])
|
||||
main.history.commit("Objekt %s (%s)" % [
|
||||
"angehoben" if delta_m > 0.0 else "abgesenkt",
|
||||
("%.2f m" % applied)],
|
||||
before, after, "lift:%d" % selected_index)
|
||||
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
|
||||
if marker != null and is_instance_valid(marker):
|
||||
marker.position.y = float((objects[selected_index]["pos"] as Array)[1])
|
||||
main.dirty = true
|
||||
main.call("_refresh_file_label")
|
||||
main.call("_refresh_stats")
|
||||
main.call("_refresh_history_buttons")
|
||||
main.call("_sync_selection_panel")
|
||||
main.call("_set_status", "Objekt %s auf %.2f m — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [
|
||||
"angehoben" if delta_m > 0.0 else "abgesenkt", float((objects[selected_index]["pos"] as Array)[1])])
|
||||
|
||||
|
||||
## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts —
|
||||
## die Blickrichtung wird gespiegelt (sicher selbstinvers: doppelt = zurück).
|
||||
func mirror_selected(axis: String) -> void:
|
||||
if selected_index < 0:
|
||||
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du spiegeln willst.", 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 obj: Dictionary = objects[selected_index]
|
||||
var old_rot := float(obj.get("rot_y_deg", 0.0))
|
||||
ObjectOps.mirror_object(obj, axis)
|
||||
var new_rot := float(obj.get("rot_y_deg", 0.0))
|
||||
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
|
||||
if marker != null and is_instance_valid(marker):
|
||||
marker.rotation.y = deg_to_rad(new_rot)
|
||||
var desc := "Objekt gespiegelt (links↔rechts)" if axis == "x" else "Objekt gespiegelt (vor↔zurück)"
|
||||
main.history.commit(desc, before, main.history.snapshot(["objects"]))
|
||||
main.dirty = true
|
||||
main.call("_refresh_file_label")
|
||||
main.call("_refresh_history_buttons")
|
||||
main.call("_sync_selection_panel")
|
||||
main.call("_set_status", "%s — Blickrichtung %.0f° → %.0f°. Strg+Z stellt die alte Richtung wieder her." % [
|
||||
desc, old_rot, new_rot])
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Eingaben ------
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
|
|
@ -290,7 +409,9 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
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)
|
||||
# Höhe: der Terrain-Abstand bleibt beim Ziehen erhalten (angehoben
|
||||
# schwebt weiter oben) — kein harter Terrain-Clamp mehr, t_7567a06f.
|
||||
new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z) + _drag_lift
|
||||
var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length()
|
||||
if dist > DRAG_THRESHOLD_PX:
|
||||
_drag_moved = true
|
||||
|
|
@ -345,8 +466,13 @@ func _on_left_press(mb: InputEventMouseButton) -> void:
|
|||
if p != null and marker != null and is_instance_valid(marker):
|
||||
_grab_offset = marker.position - (p as Vector3)
|
||||
_grab_offset.y = 0.0
|
||||
# Terrain-Abstand einfrieren (t_7567a06f): ein Objekt mit Höhe behält
|
||||
# beim Ziehen diesen Abstand zum Boden — bodennah bleibt bodennah.
|
||||
_drag_lift = marker.position.y \
|
||||
- SvmapDocument.terrain_height_at(main.doc, marker.position.x, marker.position.z)
|
||||
else:
|
||||
_grab_offset = Vector3.ZERO
|
||||
_drag_lift = 0.0
|
||||
else:
|
||||
# Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen).
|
||||
if selected_index != -1:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class_name ObjectOps
|
|||
|
||||
const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte)
|
||||
const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend"
|
||||
const LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f)
|
||||
const DUPLICATE_OFFSET_M := Vector2(1.0, 1.0) # Kopie diagonal leicht versetzt
|
||||
|
||||
|
||||
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
|
||||
|
|
@ -32,3 +34,98 @@ static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float:
|
|||
if not enabled or grid_m <= 0.0:
|
||||
return value
|
||||
return roundf(value / grid_m) * grid_m
|
||||
|
||||
|
||||
# ---------------------------------------------------- Objekt-Ops (t_7567a06f) --
|
||||
# Duplizieren · Anheben/Absenken · Spiegeln. Bewusst pure Funktionen wie der
|
||||
# Rest der Datei: die UI (interaction.gd/main.gd) snapshotet, ruft auf, commitet.
|
||||
|
||||
## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie
|
||||
## ist diagonal um DUPLICATE_OFFSET_M versetzt (mit Raster-Snap eingerastet,
|
||||
## wenn an) und behält Drehung/Größe/Höhe. Rückgabe: neuer Index (-1 = ungültig).
|
||||
## VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung statt Fehler).
|
||||
static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> int:
|
||||
var objects: Array = doc.get("objects", [])
|
||||
if index < 0 or index >= objects.size():
|
||||
return -1
|
||||
var source = objects[index]
|
||||
if not (source is Dictionary):
|
||||
return -1
|
||||
var copy: Dictionary = (source as Dictionary).duplicate(true)
|
||||
var pos: Array = copy.get("pos", [0.0, 0.0, 0.0])
|
||||
if (pos as Array).size() == 3:
|
||||
var nx := float(pos[0]) + DUPLICATE_OFFSET_M.x
|
||||
var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y
|
||||
copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)]
|
||||
objects.append(copy)
|
||||
return objects.size() - 1
|
||||
|
||||
|
||||
## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben).
|
||||
## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y
|
||||
## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 —
|
||||
## bewusst KEIN Extra-Format-Feld → Loader-Kompatibilität unangetastet).
|
||||
## Regeln (dokumentiert, README):
|
||||
## - Anheben: frei nach oben, Höchstgrenze = bounds.max.y (Match-Grenze, V9).
|
||||
## - Absenken: stoppt an der Terrain-Höhe am Objektstandort — nichts wird
|
||||
## in Hügel begraben. Ein Objekt, das unter der Oberfläche steckt (z. B.
|
||||
## nach dem Terrain-Formen), bewegt sich beim Absenken NICHT (0.0 zurück
|
||||
## — die UI erklärt, dass Anheben oder Snap-to-Ground hilft).
|
||||
## Rückgabe: tatsächlich angewendete Differenz in Metern (0.0 = nichts
|
||||
## passiert — kein Commit; die UI gibt eine Erklärung statt).
|
||||
static func lift_object(doc: Dictionary, index: int, delta_m: float) -> float:
|
||||
var objects: Array = doc.get("objects", [])
|
||||
if index < 0 or index >= objects.size():
|
||||
return 0.0
|
||||
var obj = objects[index]
|
||||
if not (obj is Dictionary):
|
||||
return 0.0
|
||||
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||
if (pos as Array).size() != 3:
|
||||
return 0.0
|
||||
var y := float(pos[1])
|
||||
var max_y := 80.0
|
||||
var bounds: Dictionary = doc.get("bounds", {})
|
||||
if (bounds.get("max", null) is Array) and ((bounds["max"] as Array).size() == 3):
|
||||
max_y = float(bounds["max"][1])
|
||||
var target: float
|
||||
if delta_m >= 0.0:
|
||||
target = minf(y + delta_m, max_y)
|
||||
else:
|
||||
var floor_y := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
|
||||
if y < floor_y - FLOAT_EPSILON:
|
||||
return 0.0 # steckt im Boden — Absenken macht das nicht schlimmer
|
||||
target = maxf(y + delta_m, floor_y)
|
||||
var applied := target - y
|
||||
if absf(applied) < 0.0001:
|
||||
return 0.0
|
||||
obj["pos"] = [float(pos[0]), target, float(pos[2])]
|
||||
return applied
|
||||
|
||||
|
||||
## Spiegeln des Objekts AN SEINER STELLE (nicht quer über die Map): die
|
||||
## Position bleibt, die Blickrichtung wird gespiegelt:
|
||||
## axis "x": Welt-X-Spiegelung („links↔rechts“) → rot_y_deg → −rot
|
||||
## axis "z": Welt-Z-Spiegelung („vor↔zurück“) → rot_y_deg → 180° − rot
|
||||
## Herleitung: Blickrichtungs-Vektor d(θ) = (−sin θ, −cos θ); Spiegelung
|
||||
## negiert die jeweilige Komponente → −θ bzw. 180° − θ. Jede Spiegelung ist
|
||||
## dadurch selbstinvers (zweimal spiegeln = Ausgangszustand).
|
||||
static func mirror_object(obj: Dictionary, axis: String) -> void:
|
||||
var rot := float(obj.get("rot_y_deg", 0.0))
|
||||
var mirrored := -rot if axis == "x" else fmod(180.0 - rot, 360.0)
|
||||
obj["rot_y_deg"] = fmod(mirrored + 3600.0, 360.0)
|
||||
|
||||
|
||||
## Höhe ÜBER dem Terrain am Objektstandort (> 0 = schwebt, ~0 = steht auf
|
||||
## dem Boden). Für die Auswahl-Anzeige.
|
||||
static func lift_offset(doc: Dictionary, index: int) -> float:
|
||||
var objects: Array = doc.get("objects", [])
|
||||
if index < 0 or index >= objects.size():
|
||||
return 0.0
|
||||
var obj = objects[index]
|
||||
if not (obj is Dictionary):
|
||||
return 0.0
|
||||
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||
if (pos as Array).size() != 3:
|
||||
return 0.0
|
||||
return float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ var redo_btn: Button
|
|||
var sel_label: Label
|
||||
var sel_rotate_btn: Button
|
||||
var sel_delete_btn: Button
|
||||
var sel_dup_btn: Button # Objekt-Ops (Karte t_7567a06f)
|
||||
var sel_mirror_x_btn: Button
|
||||
var sel_mirror_z_btn: Button
|
||||
var sel_raise_btn: Button
|
||||
var sel_lower_btn: Button
|
||||
|
||||
# Eigenschaften-Panel
|
||||
var prop_name: LineEdit
|
||||
|
|
@ -396,7 +401,7 @@ func _build_center_viewport() -> SubViewportContainer:
|
|||
|
||||
# Bedien-Hinweis im Viewport (Overlay)
|
||||
help_label = Label.new()
|
||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||
help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||
help_label.offset_left = 12
|
||||
help_label.offset_bottom = -8
|
||||
|
|
@ -439,6 +444,39 @@ func _build_right_panel() -> PanelContainer:
|
|||
sel_delete_btn.pressed.connect(interaction.delete_selected)
|
||||
sel_btns.add_child(sel_delete_btn)
|
||||
|
||||
# Objekt-Ops (Karte t_7567a06f): Duplizieren · Spiegeln · Anheben/Absenken.
|
||||
var dup_row := HBoxContainer.new()
|
||||
dup_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(dup_row)
|
||||
sel_dup_btn = _sel_op_button("⧉ Duplizieren (Strg+D)",
|
||||
"Legt eine Kopie daneben — leicht versetzt, direkt angewählt. Strg+D geht auch.",
|
||||
func() -> void: interaction.duplicate_selected())
|
||||
dup_row.add_child(sel_dup_btn)
|
||||
|
||||
var mirror_row := HBoxContainer.new()
|
||||
mirror_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(mirror_row)
|
||||
sel_mirror_x_btn = _sel_op_button("⇄ Links↔Rechts",
|
||||
"Spiegelt das Objekt an seiner Stelle (links↔rechts, X-Achse) — die Blickrichtung wird mitgespiegelt.",
|
||||
func() -> void: interaction.mirror_selected("x"))
|
||||
mirror_row.add_child(sel_mirror_x_btn)
|
||||
sel_mirror_z_btn = _sel_op_button("⇅ Vor↔Zurück",
|
||||
"Spiegelt das Objekt an seiner Stelle (vor↔zurück, Z-Achse) — die Blickrichtung wird mitgespiegelt.",
|
||||
func() -> void: interaction.mirror_selected("z"))
|
||||
mirror_row.add_child(sel_mirror_z_btn)
|
||||
|
||||
var lift_row := HBoxContainer.new()
|
||||
lift_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(lift_row)
|
||||
sel_raise_btn = _sel_op_button("▲ Anheben (Bild↑)",
|
||||
"Objekt 0,25 m anheben — es schwebt dann, auch wenn der Boden darunter anders ist. Bild↑ geht auch.",
|
||||
func() -> void: interaction.lift_selected(ObjectOps.LIFT_STEP_M))
|
||||
lift_row.add_child(sel_raise_btn)
|
||||
sel_lower_btn = _sel_op_button("▼ Absenken (Bild↓)",
|
||||
"Objekt 0,25 m absenken — stoppt am Boden (nichts wird in Hügel begraben). Bild↓ geht auch.",
|
||||
func() -> void: interaction.lift_selected(-ObjectOps.LIFT_STEP_M))
|
||||
lift_row.add_child(sel_lower_btn)
|
||||
|
||||
vbox.add_child(_separator())
|
||||
|
||||
# --- Map-Eigenschaften ---
|
||||
|
|
@ -605,6 +643,15 @@ func _build_right_panel() -> PanelContainer:
|
|||
return panel
|
||||
|
||||
|
||||
func _sel_op_button(text: String, tip: String, action: Callable) -> Button:
|
||||
var btn := Button.new()
|
||||
btn.text = text
|
||||
btn.custom_minimum_size = Vector2(140, 40)
|
||||
btn.tooltip_text = tip
|
||||
btn.pressed.connect(action)
|
||||
return btn
|
||||
|
||||
|
||||
func _header_label(text: String) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
|
|
@ -812,6 +859,20 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||
else:
|
||||
interaction.delete_selected()
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_D:
|
||||
if ctrl and tool_mode == "objects":
|
||||
interaction.duplicate_selected()
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_PAGEUP:
|
||||
if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \
|
||||
and not interaction.placing:
|
||||
interaction.lift_selected(ObjectOps.LIFT_STEP_M)
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_PAGEDOWN:
|
||||
if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \
|
||||
and not interaction.placing:
|
||||
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# --------------------------------------------------------- Bibliothek ------
|
||||
|
|
@ -959,7 +1020,7 @@ func _leave_spawn_mode() -> void:
|
|||
|
||||
func _enter_objects_mode() -> void:
|
||||
tool_mode = "objects"
|
||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||
_base_tool_status()
|
||||
|
||||
|
||||
|
|
@ -1014,7 +1075,7 @@ func _recheck_terrain_mode() -> void:
|
|||
tool_mode = "objects"
|
||||
terrain_panel.visible = false
|
||||
_sync_terrain_button()
|
||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
|
||||
|
||||
|
||||
|
|
@ -1148,18 +1209,20 @@ func _mark_tree_selection_walk(item: TreeItem, idx: int) -> void:
|
|||
func _sync_selection_panel() -> void:
|
||||
var objects: Array = doc.get("objects", [])
|
||||
var idx: int = interaction.selected_index
|
||||
if idx < 0 or idx >= objects.size():
|
||||
var has_sel := idx >= 0 and idx < objects.size()
|
||||
for btn in [sel_rotate_btn, sel_delete_btn, sel_dup_btn, sel_mirror_x_btn,
|
||||
sel_mirror_z_btn, sel_raise_btn, sel_lower_btn]:
|
||||
btn.disabled = not has_sel
|
||||
if not has_sel:
|
||||
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" % [
|
||||
var lift := ObjectOps.lift_offset(doc, idx)
|
||||
var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift)
|
||||
sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Höhe: %s · Richtung: %.0f°\nZiehen: verschieben · R oder Mausrad: drehen (15°)\nBild↑/↓: anheben/absenken · Strg+D: duplizieren\nSpiegeln/Löschen: Buttons oben · Strg+Z: 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
|
||||
float(pos[0]), float(pos[2]), lift_text, float(obj.get("rot_y_deg", 0.0))]
|
||||
|
||||
|
||||
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
|
||||
|
|
@ -1174,7 +1237,7 @@ func _selection_status(idx: int) -> void:
|
|||
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" % [
|
||||
_set_status("%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Bild↑/↓ anheben/absenken, Strg+D duplizieren, Entf zum Löschen.%s" % [
|
||||
object_label(str(obj.get("id", ""))), budget_warning])
|
||||
|
||||
|
||||
|
|
|
|||
130
tests/test_ux.gd
130
tests/test_ux.gd
|
|
@ -64,6 +64,7 @@ func _run_tests() -> void:
|
|||
_test_history_cap_and_clear()
|
||||
_test_terrain_height()
|
||||
_test_mesh_shapes()
|
||||
_test_object_ops()
|
||||
|
||||
|
||||
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
|
||||
|
|
@ -238,3 +239,132 @@ func _test_mesh_shapes() -> void:
|
|||
_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")
|
||||
|
||||
|
||||
# ----------------------------- Objekt-Ops: Duplizieren/Höhe/Spiegeln -------
|
||||
## (Karte t_7567a06f — pure Funktionen in ObjectOps, UI-Wiring wird im
|
||||
## Desktop-Smoke verifiziert; hier Headless-Beweis der Logik + Undo-Roundtrips.)
|
||||
|
||||
func _test_object_ops() -> void:
|
||||
print("Testgruppe: Objekt-Ops (Duplizieren · Anheben/Absenken · Spiegeln)")
|
||||
var doc := SvmapDocument.create_new("Objekt-Ops-Test", 1024.0, "mittag")
|
||||
var hist := ActionHistory.new()
|
||||
hist.begin(doc)
|
||||
var objects: Array = doc["objects"]
|
||||
|
||||
# --- Duplizieren:stelle diagonal versetzt, Raster-Snap, Undo fähig ---
|
||||
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
|
||||
objects[0]["rot_y_deg"] = 30.0
|
||||
var new_idx := ObjectOps.duplicate_object(doc, 0, true)
|
||||
_check(new_idx == 1, "duplizieren: neuer Index 1")
|
||||
_check(objects.size() == 2, "duplizieren: 1 → 2 Objekte")
|
||||
_check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID")
|
||||
_check(float(objects[1]["pos"][0]) == 11.0 and float(objects[1]["pos"][2]) == 11.0,
|
||||
"duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)")
|
||||
_check(absf(float(objects[1]["rot_y_deg"]) - 30.0) < 0.0001, "duplizieren: Drehung übernommen")
|
||||
_check(float(objects[1]["pos"][1]) == float(objects[0]["pos"][1]), "duplizieren: Höhe übernommen")
|
||||
|
||||
# Kopie ohne Raster: Snap aus → exakt +1/+1 auch auf 0,5-m-Fraktionen.
|
||||
objects[0]["pos"] = [10.4, 0.0, 10.4]
|
||||
var idx2 := ObjectOps.duplicate_object(doc, 0, false)
|
||||
_check(absf(float(objects[idx2]["pos"][0]) - 11.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+1.0)")
|
||||
# Fraktionsobjekt wieder aufräumen für die History-Tests:
|
||||
objects.remove_at(idx2)
|
||||
|
||||
# Duplizieren + Undo → Kopie weg; Redo → wieder da (EIN Undo-Schritt).
|
||||
var before := hist.snapshot(["objects"])
|
||||
var idx3 := ObjectOps.duplicate_object(doc, 0, true)
|
||||
hist.commit("Test: duplizieren", before, hist.snapshot(["objects"]))
|
||||
_check(objects.size() == 3, "duplizieren über History: Kopie hängt an (3 = Original + Raster-Kopie + neue Kopie)")
|
||||
hist.undo()
|
||||
_check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)")
|
||||
hist.redo()
|
||||
_check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da")
|
||||
_check(absf(float(objects[2]["pos"][0]) - 11.0) < 0.0001, "duplizieren→redo: Position identisch")
|
||||
|
||||
# Ungültige Indizes schaden nichts:
|
||||
_check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1")
|
||||
_check(objects.size() == 3, "duplizieren: falscher Index ändert nichts")
|
||||
|
||||
# --- Anheben/Absenken: 0,25-m-Schritte, Deckel = bounds.max, Boden-Stopp ---
|
||||
var h0: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
|
||||
var up := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
|
||||
_check(absf(up - ObjectOps.LIFT_STEP_M) < 0.0001, "anheben: 0,25 m angewendet")
|
||||
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.25)) < 0.0001, "anheben: y = Boden + 0,25 m")
|
||||
ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
|
||||
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.5)) < 0.0001, "anheben x2: 0,5 m")
|
||||
|
||||
var down := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M)
|
||||
_check(absf(down + 0.25) < 0.0001, "absenken: −0,25 m")
|
||||
var stop := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M * 10)
|
||||
_check(absf(stop + 0.25) < 0.0001, "absenken: stoppt genau am Terrain (kein Begraben)")
|
||||
_check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "absenken am Boden: 0.0 (UI erklärt statt commit)")
|
||||
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "nach Absenken-Stopp: exakt Boden")
|
||||
|
||||
# Deckel: bounds.max.y = 80 (Neue-Map) → Großsprung wird auf 80 gedeckelt.
|
||||
var cap := ObjectOps.lift_object(doc, 0, 1000.0)
|
||||
_check(absf(float(objects[0]["pos"][1]) - 80.0) < 0.0001, "anheben: Höchstgrenze bounds.max.y (80 m)")
|
||||
_check(absf(cap - (80.0 - h0)) < 0.0001, "anheben: angewendet = Rest bis zum Deckel")
|
||||
_check(ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) == 0.0, "anheben am Deckel: 0.0")
|
||||
|
||||
# Im Boden versteckt (nach Terrain-Formen): Absenken bewegt NICHT,
|
||||
# Anheben hebt schrittweise aus dem Boden.
|
||||
objects[0]["pos"] = [10.0, -3.0, 10.0]
|
||||
_check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "verborgen: absenken = 0.0")
|
||||
var out := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
|
||||
_check(absf(out - ObjectOps.LIFT_STEP_M) < 0.0001, "versteckt: anheben hebt schrittweise (0,25 m ab −3 m)")
|
||||
_check(absf(float(objects[0]["pos"][1]) + 2.75) < 0.0001, "versteckt: y steigt vom Untergrund aus (kein Sprung-Ruckel)")
|
||||
|
||||
# lift_offset (Offset der UI-Anzeige): angehoben → +0,25, am Boden → 0.
|
||||
objects[0]["pos"] = [10.0, h0 + 0.25, 10.0]
|
||||
_check(absf(ObjectOps.lift_offset(doc, 0) - 0.25) < 0.001, "lift_offset: +0,25 m sichtbar")
|
||||
objects[0]["pos"] = [10.0, h0, 10.0]
|
||||
_check(absf(ObjectOps.lift_offset(doc, 0)) < 0.001, "lift_offset: am Boden = 0")
|
||||
|
||||
# Merge-Tags wie beim Drehen: mehrere Lift-Ticks = EIN History-Eintrag.
|
||||
var entries_before := hist.entries.size()
|
||||
for i in 5:
|
||||
var b := hist.snapshot(["objects"])
|
||||
var y2 := float(objects[0]["pos"][1])
|
||||
objects[0]["pos"] = [10.0, y2 + ObjectOps.LIFT_STEP_M, 10.0]
|
||||
hist.commit("Objekt angehoben", b, hist.snapshot(["objects"]), "lift:0")
|
||||
_check(hist.entries.size() == entries_before + 1, "5 Lift-Ticks → EIN History-Eintrag (Merge)")
|
||||
hist.undo()
|
||||
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "Merge-Undo: alle Stufen auf einmal weg")
|
||||
|
||||
# --- Spiegeln: Rotation wird mitgespiegelt, Position bleibt ---
|
||||
objects[0]["rot_y_deg"] = 30.0
|
||||
ObjectOps.mirror_object(objects[0], "x")
|
||||
_check(absf(float(objects[0]["rot_y_deg"]) - 330.0) < 0.0001, "spiegeln X: 30° → 330° (−30°)")
|
||||
ObjectOps.mirror_object(objects[0], "x")
|
||||
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln X: selbstinvers (330° → 30°)")
|
||||
_check(float(objects[0]["pos"][0]) == 10.0, "spiegeln: Position unverändert (am Objekt, nicht quer über die Map)")
|
||||
|
||||
objects[0]["rot_y_deg"] = 30.0
|
||||
ObjectOps.mirror_object(objects[0], "z")
|
||||
_check(absf(float(objects[0]["rot_y_deg"]) - 150.0) < 0.0001, "spiegeln Z: 30° → 150° (180° − rot)")
|
||||
ObjectOps.mirror_object(objects[0], "z")
|
||||
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln Z: selbstinvers (150° → 30°)")
|
||||
|
||||
# Spiegeln über History: ein Eintrag, Undo stellt die alte Richtung her.
|
||||
objects[0]["rot_y_deg"] = 30.0
|
||||
var before_m := hist.snapshot(["objects"])
|
||||
ObjectOps.mirror_object(doc["objects"][0], "x")
|
||||
hist.commit("Test: spiegeln", before_m, hist.snapshot(["objects"]))
|
||||
hist.undo()
|
||||
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln→undo: alte Richtung zurück")
|
||||
|
||||
# --- svmap-Roundtrip: Höhe + gespiegelte Rotation bleiben 1:1 erhalten ---
|
||||
objects[0]["rot_y_deg"] = 210.0
|
||||
objects[0]["pos"] = [5.0, h0 + 1.75, -6.0]
|
||||
var path := "/tmp/t7567_roundtrip.svmap"
|
||||
var res := SvmapIO.save_to_file(path, doc)
|
||||
_check(res["ok"], "svmap-Roundtrip: angesenkte/gesp. Map speichert (V1–V14 ok)")
|
||||
var loaded := SvmapIO.load_from_file(path)
|
||||
_check(loaded["ok"], "svmap-Roundtrip: laden ok (Prüfsumme intakt)")
|
||||
if loaded["ok"]:
|
||||
var lo: Array = loaded["data"]["objects"]
|
||||
_check(lo.size() == objects.size(), "svmap-Roundtrip: Objekt-Anzahl gleich")
|
||||
_check(absf(float(lo[0]["pos"][1]) - (h0 + 1.75)) < 0.0001, "svmap-Roundtrip: angehobene Höhe bleibt (KEIN Extra-Format-Feld — pos.y ist frei)")
|
||||
_check(absf(float(lo[0]["rot_y_deg"]) - 210.0) < 0.0001, "svmap-Roundtrip: gespiegelte Rotation bleibt")
|
||||
_check(str(lo[1]["id"]) == "crate_wood", "svmap-Roundtrip: Kopie bleibt")
|
||||
|
|
|
|||
0
tools/parse_check.sh
Normal file → Executable file
0
tools/parse_check.sh
Normal file → Executable file
0
tools/run_tests.sh
Normal file → Executable file
0
tools/run_tests.sh
Normal file → Executable file
Loading…
Reference in a new issue