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