197 lines
7.3 KiB
GDScript
197 lines
7.3 KiB
GDScript
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
|