- InputMap-Actions mit physischen KEY_*-Codes (shortcut_keys.gd, idempotent) — DE-Layout/Windows-unabhängig - Freelook: RMT halten = Umsehen+Flug (WASD/EQ, Shift x3, Alt x0.3, Rad = Grundtempo dauerhaft), Shift+F toggle; Orbit bleibt Default, Flug-Exit ohne Kamera-Sprung (FlyMath pure) - F zentriert Auswahl (SelectionMath.focus_center); Esc = pure Prioritätskette (ShortcutPolicy) - Strg+LMB + Baum SELECT_MULTI: Mehrfachauswahl synchron Szene↔Baum; Entf/Strg+D wirken auf die ganze Auswahl (EIN Undo-Schritt) - docs/tasten.md = deutsche Source of truth (Karten 2/3 ergänzen) - Tests: test_shortcuts.gd NEU 70 Checks, Suite 571 grün, Builder-Export 21/0, Parse-Check grün
173 lines
7.8 KiB
GDScript
173 lines
7.8 KiB
GDScript
extends RefCounted
|
||
class_name ObjectOps
|
||
## Objekt-Operationen des Editors (Karte t_3a998ae4) — bewusst pure Funktionen
|
||
## (kein UI, kein Knoten): Snap-to-Ground und Raster-Snap. main.gd/interaction.gd
|
||
## rufen auf, tests/test_library.gd prüft headless.
|
||
|
||
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(2.0, 0.0) # Kopie +2 m in X (Rasterversatz,
|
||
# Karte t_c46d31c2)
|
||
|
||
|
||
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
|
||
## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter.
|
||
## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden).
|
||
static func snap_to_ground(doc: Dictionary) -> int:
|
||
var changed := 0
|
||
var objects: Array = doc.get("objects", [])
|
||
for obj in objects:
|
||
if not (obj is Dictionary):
|
||
continue
|
||
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||
if (pos as Array).size() != 3:
|
||
continue
|
||
var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
|
||
if absf(float(pos[1]) - h) > FLOAT_EPSILON:
|
||
obj["pos"] = [float(pos[0]), h, float(pos[2])]
|
||
changed += 1
|
||
return changed
|
||
|
||
|
||
## Eine Koordinate aufs Raster legen (Raster-Snap). grid_m <= 0 oder aus = no-op.
|
||
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 um DUPLICATE_OFFSET_M versetzt (+2 m in X, Rasterversatz Karte
|
||
## t_c46d31c2; mit Raster-Snap eingerastet, wenn an) und behält Drehung/Größe/
|
||
## Höhe — der Füßpunkt (pos.y) bleibt exakt. 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
|
||
|
||
|
||
## MEHRERE Objekte auf einmal duplizieren (Karte t_c46d31c2, Strg+D bei
|
||
## Mehrfachauswahl): jede Kopie über duplicate_object (+2 m in X), Reihenfolge
|
||
## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt).
|
||
## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet
|
||
## EINEN Undo-Schritt für alle Kopien zusammen.
|
||
static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool) -> Array:
|
||
var new_indices: Array = []
|
||
for i in indices:
|
||
var ni := duplicate_object(doc, int(i), grid_enabled)
|
||
if ni >= 0:
|
||
new_indices.append(ni)
|
||
return new_indices
|
||
|
||
|
||
## 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]))
|
||
|
||
|
||
# ------------------------------------------------- Platzier-Fußpunkt (C) ----
|
||
# Karte t_4092e3a0 (Gerätebefund: „Ghost saß an der Unterlage, platziert saß
|
||
# das Objekt HÖHER"): Ghost-Preview und Platzierung MÜSSEN denselben Pfad
|
||
# nutzen. Diese eine Funktion ist der Pfad:
|
||
# 1. Raster-Snap auf X/Z (wenn an),
|
||
# 2. Y = Terrain-Höhe am GERASTETEN Punkt (SvmapDocument.terrain_height_at)
|
||
# — das ist der Objekt-Fußpunkt (Konvention, README §Konventionen):
|
||
# „pos.y = Terrainhöhe an (x, z)“. Der sichtbare Lift (halbe Mesh-Höhe,
|
||
# aus der Registry-Kollision via MeshShapes.half_height) ist Aufgabe der
|
||
# Darstellung (Viewport-Bauer UND Ghost-Kind-Mesh machen es identisch).
|
||
# clamp_to_map: true = Ghost clampft still auf die Map (Vorschau bleibt
|
||
# sichtbar am Rand); false = Platzierung bekommt den ungeclampten Punkt und
|
||
# die UI meldet „außerhalb der Map“.
|
||
static func placement_foot(doc: Dictionary, hit: Vector3, grid_enabled: bool,
|
||
half: Vector2, clamp_to_map: bool) -> Vector3:
|
||
var pos := hit
|
||
pos.x = snap_coord(pos.x, grid_enabled)
|
||
pos.z = snap_coord(pos.z, grid_enabled)
|
||
if clamp_to_map:
|
||
pos.x = clampf(pos.x, -half.x, half.x)
|
||
pos.z = clampf(pos.z, -half.y, half.y)
|
||
pos.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z)
|
||
return pos
|