scopedvr-map-core/editor/group_ops.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

197 lines
7.3 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends RefCounted
class_name GroupOps
## Gruppen-Logik des Map-Editors (Karte t_f6f29d86) — pure, statische Funktionen
## (kein UI, kein Knoten), headless testbar via tests/test_ux.gd.
##
## ENTSCHIEDUNG Gruppen-Persistenz (Karte t_f6f29d86, 15.10.):
## Gruppen-Zugehörigkeit liegt als OPTIONALES Objekt-Feld "group" (int ≥ 0,
## abwesend oder < 0 = ungruppiert) direkt im svmap-Format — NICHT als
## Editor-Sitzungsstatus. Begründung (Loader-Kompatibilität nachgewiesen,
## src/maps/svmap_loader.gd im Match-Server):
## - V4 prüft nur PFLICHT-Felder; unbekannte Zusatz-Keys je Objekt werden
## nicht abgelehnt (Loader liest id/pos/scale/rot_y_deg + optionales rot).
## - Die sha256-Prüfsumme läuft über das GESAMTE kanonisierte Dict — das
## Feld reist automatisch mit, ohne dass Prüfsummen-Code sich ändert.
## - Alte Maps ohne "group"-Feld bleiben 1:1 ladbar (Feld abwesend =
## ungruppiert), neue Maps laufen auf unverändertem Server-Loader.
## - Kein format_version-Bump nötig (additiv + optional, Vorwärts- UND
## Rückwärtskompatibel). Format-Doku-Anpassung (map_format.md) als
## Folge-Karte im Match-Server-Repo.
## Vorteil gegenüber Sitzungsstatus: Gruppen überleben Speichern/Laden
## UND Undo/Redo (ActionHistory kopiert den objects-Block tief — das Feld
## reist mit jedem Snapshot mit).
##
## Konvention: Gruppen-IDs werden bei jeder "Gruppieren"-Aktion neu vergeben
## (max + 1). Lücken (nach Löschen/Lösen) sind erlaubt und harmlos.
const UNGROUPED := -1
## Gruppen-ID eines Objekts (ungültig/abwesend → UNGROUPED).
static func group_id(obj) -> int:
if not (obj is Dictionary):
return UNGROUPED
var g = obj.get("group", UNGROUPED)
if not (g is int or g is float) or not is_finite(float(g)):
return UNGROUPED
var gi := int(float(g))
return gi if gi >= 0 else UNGROUPED
## Objekt-Feld hart auf "ungruppiert" setzen (Feld entfernen — sauberes Format).
static func _clear_group(obj: Dictionary) -> void:
obj.erase("group")
## Aktive Gruppen-IDs (aufsteigend sortiert) — nur IDs mit ≥ 1 Mitglied.
static func active_group_ids(doc: Dictionary) -> Array:
var ids := {}
for obj in (doc.get("objects", []) as Array):
var g := group_id(obj)
if g != UNGROUPED:
ids[g] = true
var out: Array = []
for g in ids.keys():
out.append(int(g))
out.sort()
return out
## Nächste freie Gruppen-ID (max aktiv + 1; leere Map → 0).
static func next_group_id(doc: Dictionary) -> int:
var max_id := UNGROUPED
for obj in (doc.get("objects", []) as Array):
var g := group_id(obj)
if g > max_id:
max_id = g
return max_id + 1
## Indizes aller Mitglieder von Gruppe gid (aufsteigend).
static func members_of(doc: Dictionary, gid: int) -> Array:
var out: Array = []
var objects: Array = doc.get("objects", [])
for i in objects.size():
if group_id(objects[i]) == gid:
out.append(i)
return out
## Die aktiven Gruppen der gegebenen Indizes (für "Gruppe lösen").
static func groups_of(doc: Dictionary, indices: Array) -> Array:
var ids := {}
var objects: Array = doc.get("objects", [])
for i in indices:
if i is int and i >= 0 and i < objects.size():
var g := group_id(objects[i])
if g != UNGROUPED:
ids[g] = true
return ids.keys()
## Ausgewählte Objekte zu EINER Gruppe machen. Gibt die neue Gruppen-ID
## zurück (−1, wenn weniger als 2 gültige Indizes — eine Gruppe braucht ≥ 2).
## Objekte, die schon in anderen Gruppen waren, verlassen diese.
static func group(doc: Dictionary, indices: Array) -> int:
var valid := _valid_indices(doc, indices)
if valid.size() < 2:
return UNGROUPED
var gid := next_group_id(doc)
var objects: Array = doc["objects"]
for i in valid:
objects[i]["group"] = gid
return gid
## Gruppe(n) der gegebenen Indizes KOMPLETT lösen (alle Mitglieder, nicht nur
## die Auswahl — sonst bliebe eine Rumpfgruppe zurück). Rückgabe: Anzahl
## befreiter Objekte.
static func ungroup(doc: Dictionary, indices: Array) -> int:
var objects: Array = doc.get("objects", [])
var freed := 0
for gid in groups_of(doc, indices):
for i in members_of(doc, gid):
_clear_group(objects[i])
freed += 1
return freed
## Mittel-Punkt (XZ) der gegebenen Objekt-Indizes (Dreh-Mitte).
static func centroid(doc: Dictionary, indices: Array) -> Vector3:
var objects: Array = doc.get("objects", [])
var sum := Vector3.ZERO
var n := 0
for i in _valid_indices(doc, indices):
var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
sum += Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
n += 1
return sum / float(maxi(n, 1))
## Mitglieder gemeinsam verschieben (XZ + delta), Terrain-Clamp PRO Objekt:
## X/Z bleiben in der Map, Y wird auf die Terrain-Höhe an der neuen Stelle
## gesetzt. Rückgabe: Anzahl tatsächlich geänderter Objekte.
static func move_members(doc: Dictionary, indices: Array, delta: Vector3) -> int:
var objects: Array = doc.get("objects", [])
var half := _terrain_half(doc)
var moved := 0
for i in _valid_indices(doc, indices):
var obj: Dictionary = objects[i]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var x := clampf(float(pos[0]) + delta.x, -half.x, half.x)
var z := clampf(float(pos[2]) + delta.z, -half.y, half.y)
var y := SvmapDocument.terrain_height_at(doc, x, z)
if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001 \
or absf(y - float(pos[1])) > 0.0001:
moved += 1
obj["pos"] = [x, y, z]
return moved
## Mitglieder gemeinsam DREHEN: Positionen rotieren um den Gruppen-Mittelpunkt
## (XZ, Godot-Y-Konvention wie marker.rotation.y) und rot_y_deg je Objekt
## += delta_deg. Terrain-Clamp PRO Objekt danach (die Gruppe kann über eine
## Kante wandern). Rückgabe: Anzahl geänderter Objekte.
static func rotate_members(doc: Dictionary, indices: Array, delta_deg: float) -> int:
var objects: Array = doc.get("objects", [])
var valid := _valid_indices(doc, indices)
if valid.is_empty():
return 0
var c := centroid(doc, valid)
var rad := deg_to_rad(delta_deg)
var cos_a := cos(rad)
var sin_a := sin(rad)
var half := _terrain_half(doc)
var changed := 0
for i in valid:
var obj: Dictionary = objects[i]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var dx := float(pos[0]) - c.x
var dz := float(pos[2]) - c.z
var x := clampf(c.x + dx * cos_a + dz * sin_a, -half.x, half.x)
var z := clampf(c.z - dx * sin_a + dz * cos_a, -half.y, half.y)
var y := SvmapDocument.terrain_height_at(doc, x, z)
if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001 \
or absf(y - float(pos[1])) > 0.0001:
changed += 1
obj["pos"] = [x, y, z]
# 3 Achsen (Karte t_e166e656): Gruppendrehung dreht die Y-Komponente
# und lässt Kippen (X/Z) unverändert — rot/rot_y_deg bleiben synchron.
var euler := SvmapIO.objekt_rotation(obj)
SvmapIO.rot_setzen(obj, Vector3(
euler.x, fmod(euler.y + delta_deg + 3600.0, 360.0), euler.z))
return changed
static func _valid_indices(doc: Dictionary, indices: Array) -> Array:
var objects: Array = doc.get("objects", [])
var out: Array = []
for i in indices:
if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary):
out.append(i)
return out
static func _terrain_half(doc: Dictionary) -> Vector2:
var terrain: Dictionary = doc.get("terrain", {})
return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5