From 17a8927feca5b5bae63777b08ce66936831d352c Mon Sep 17 00:00:00 2001 From: slyphax Date: Sat, 31 Oct 2026 15:30:18 +0100 Subject: [PATCH] =?UTF-8?q?Map-Kern=20aus=20dem=20Editor=20=C3=BCbernommen?= =?UTF-8?q?=20(Stand=20e8ab954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 12 + editor/action_history.gd | 132 +++++ editor/action_history.gd.uid | 1 + editor/group_ops.gd | 197 ++++++++ editor/group_ops.gd.uid | 1 + editor/move_math.gd | 61 +++ editor/move_math.gd.uid | 1 + editor/object_ops.gd | 384 ++++++++++++++ editor/object_ops.gd.uid | 1 + editor/selection_math.gd | 143 ++++++ editor/selection_math.gd.uid | 1 + editor/spawn_ops.gd | 325 ++++++++++++ editor/spawn_ops.gd.uid | 1 + editor/symmetry_core.gd | 233 +++++++++ editor/symmetry_core.gd.uid | 1 + library/svmap_wasser.gd | 240 +++++++++ library/svmap_wasser.gd.uid | 1 + library/terrain_arrays.gd | 221 ++++++++ library/terrain_arrays.gd.uid | 1 + svmap/group_state.gd | 111 ++++ svmap/group_state.gd.uid | 1 + svmap/kollider_bau.gd | 294 +++++++++++ svmap/kollider_bau.gd.uid | 1 + svmap/loadout_katalog.gd | 201 ++++++++ svmap/loadout_katalog.gd.uid | 1 + svmap/map_document.gd | 470 +++++++++++++++++ svmap/map_document.gd.uid | 1 + svmap/modus_katalog.gd | 264 ++++++++++ svmap/modus_katalog.gd.uid | 1 + svmap/skybox_katalog.gd | 125 +++++ svmap/skybox_katalog.gd.uid | 1 + svmap/svmap_io.gd | 916 ++++++++++++++++++++++++++++++++++ svmap/svmap_io.gd.uid | 1 + svmap/svmap_migration.gd | 67 +++ svmap/svmap_migration.gd.uid | 1 + svmap/svmap_tint.gd | 149 ++++++ svmap/svmap_tint.gd.uid | 1 + svmap/terrain_field.gd | 456 +++++++++++++++++ svmap/terrain_field.gd.uid | 1 + 39 files changed, 5020 insertions(+) create mode 100644 README.md create mode 100644 editor/action_history.gd create mode 100644 editor/action_history.gd.uid create mode 100644 editor/group_ops.gd create mode 100644 editor/group_ops.gd.uid create mode 100644 editor/move_math.gd create mode 100644 editor/move_math.gd.uid create mode 100644 editor/object_ops.gd create mode 100644 editor/object_ops.gd.uid create mode 100644 editor/selection_math.gd create mode 100644 editor/selection_math.gd.uid create mode 100644 editor/spawn_ops.gd create mode 100644 editor/spawn_ops.gd.uid create mode 100644 editor/symmetry_core.gd create mode 100644 editor/symmetry_core.gd.uid create mode 100644 library/svmap_wasser.gd create mode 100644 library/svmap_wasser.gd.uid create mode 100644 library/terrain_arrays.gd create mode 100644 library/terrain_arrays.gd.uid create mode 100644 svmap/group_state.gd create mode 100644 svmap/group_state.gd.uid create mode 100644 svmap/kollider_bau.gd create mode 100644 svmap/kollider_bau.gd.uid create mode 100644 svmap/loadout_katalog.gd create mode 100644 svmap/loadout_katalog.gd.uid create mode 100644 svmap/map_document.gd create mode 100644 svmap/map_document.gd.uid create mode 100644 svmap/modus_katalog.gd create mode 100644 svmap/modus_katalog.gd.uid create mode 100644 svmap/skybox_katalog.gd create mode 100644 svmap/skybox_katalog.gd.uid create mode 100644 svmap/svmap_io.gd create mode 100644 svmap/svmap_io.gd.uid create mode 100644 svmap/svmap_migration.gd create mode 100644 svmap/svmap_migration.gd.uid create mode 100644 svmap/svmap_tint.gd create mode 100644 svmap/svmap_tint.gd.uid create mode 100644 svmap/terrain_field.gd create mode 100644 svmap/terrain_field.gd.uid diff --git a/README.md b/README.md new file mode 100644 index 0000000..e594b2a --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# ScopedVR Map-Kern + +Gemeinsamer Code für den PC-Map-Editor und die Quest-App: .svmap lesen und +schreiben, Terrain-Daten, Objekte, Spawns, Zonen, Blickrichtung, Loadout, Undo. +Eingebunden als Git-Submodul unter `res://core/`. + +Regeln: +- Kein Code für Maus, Tastatur, Fenster oder Panels. Das gehört in die App. +- Keine App-spezifischen Pfade. Daten wie `res://library/objects.json` liefert die App. +- Maps gehören nicht in dieses Repo. + +Umzug aus scopedvr-map-editor @ e8ab954, ältere Historie liegt dort. diff --git a/editor/action_history.gd b/editor/action_history.gd new file mode 100644 index 0000000..592c9d5 --- /dev/null +++ b/editor/action_history.gd @@ -0,0 +1,132 @@ +extends RefCounted +class_name ActionHistory +## Zentrale Undo/Redo-Historie des Map-Editors (M2, Karte t_40d855d0). +## +## Die Map IST das svmap-Dictionary (1:1-Format, keine Kopie). Jede Aktion +## sichert die betroffenen OBER-Blöcke (z. B. "objects", "terrain", +## "spawn_zones") als tiefe Kopie VOR und NACH der Änderung. Undo setze die +## Blöcke zurück, Redo wendet "after" erneut an. +## +## So melden spätere Werkzeuge ihre Aktionen an (Terrain-Strokes, Spawn-Zonen): +## var before := history.snapshot(["terrain"]) # VOR der Änderung +## doc["terrain"][...] = ... # Änderung machen +## history.commit("Hügel geformt", before, history.snapshot(["terrain"])) +## +## Ober-Blöcke sind klein (Objekt-Liste/Spawn-Punkte) oder Base64-Strings +## (heightmap) — tiefe Kopien sind hier billig und bombenfest gegen +## Alias-Verwechslung, weil Godot-Arrays/Dicts sonst gegenseitig referenzieren. + +const MAX_STEPS := 100 + + +var doc: Dictionary = {} +var entries: Array = [] # Eintrag = {desc:String, merge:String, before:{Blk:Variante}, after:{Blk:Variante}} +var index := -1 # zuletzt angewandter Eintrag; -1 = nichts + + +func begin(document: Dictionary) -> void: + doc = document # Referenz — wir ändern dieselbe Map, keine Kopie + clear() + + +func clear() -> void: + entries.clear() + index = -1 + + +## Tiefe Kopie der angegebenen Blöcke (VOR oder NACH einer Änderung). +## Skalare Ober-Keys (format_version, weather, …) werden als Wert kopiert — +## so kann ein Snapshot auch den GANZEN doc abdecken (Resize, R3). +func snapshot(keys: Array) -> Dictionary: + var out := {} + for key in keys: + if doc.has(key): + var value: Variant = doc[key] + if value is Array or value is Dictionary: + out[key] = value.duplicate(true) + else: + out[key] = value + return out + + +func can_undo() -> bool: + return index >= 0 + + +func can_redo() -> bool: + return index + 1 < entries.size() + + +func undo_desc() -> String: + if not can_undo(): + return "" + return str(entries[index].get("desc", "")) + + +func redo_desc() -> String: + if not can_redo(): + return "" + return str(entries[index + 1].get("desc", "")) + + +## Eine fertige Änderung in die History einhängen. +## merge_tag: ungleich "" → identisch getagte Folge-Aufrufe (z. B. Drehen +## per Mausrad in schnellen Ticks) überschreiben den letzten Eintrag, +## statt die History zu fluten. commit immer direkt NACH der Doc-Änderung aufrufen. +func commit(desc: String, before: Dictionary, after: Dictionary, merge_tag: String = "") -> void: + if merge_tag != "" and index >= 0 and str(entries[index].get("merge", "")) == merge_tag: + entries[index]["after"] = after + return + entries.resize(index + 1) # redo-Zweig verwerfen (Standard-Undo-Semantik) + entries.append({"desc": desc, "merge": merge_tag, "before": before, "after": after}) + if entries.size() > MAX_STEPS: + entries.remove_at(0) # älteste Aktion verdrängen + index = entries.size() - 1 + + +## Letzte Aktion zurücknehmen. Rückgabe: Beschreibung ("" = nichts da). +## Restore läuft IN-PLACE: doc["objects"] etc. behalten ihre Identität (Array/ +## Dict-Objekt wird geleert und neu befüllt) — bezogene Referenzen (Tests, +## Ansichten) werden nicht zu Stale-Schnappschüssen. +func undo() -> String: + if not can_undo(): + return "" + var entry: Dictionary = entries[index] + for key in entry["before"]: + _restore_block(key, entry["before"][key]) + index -= 1 + return str(entry.get("desc", "")) + + +## Zurückgenommenes wiederherstellen. Rückgabe: Beschreibung ("" = nichts da). +func redo() -> String: + if not can_redo(): + return "" + index += 1 + var entry: Dictionary = entries[index] + for key in entry["after"]: + _restore_block(key, entry["after"][key]) + return str(entry.get("desc", "")) + + +func _restore_block(key: String, value: Variant) -> void: + if value == null: + doc.erase(key) + return + # Ganzen Block (Array/Dict) in-place neu befüllen — Identität bleibt erhalten. + # Skalare (format_version, weather, …) werden als Wert gesetzt. + var source: Variant = value + if source is Array or source is Dictionary: + source = value.duplicate(true) + if source is Array: + var arr: Array = doc.get(key, []) + arr.clear() + arr.append_array(source) + doc[key] = arr + elif source is Dictionary: + var dict: Dictionary = doc.get(key, {}) + dict.clear() + dict.merge(source) + doc[key] = dict + else: + doc[key] = source \ No newline at end of file diff --git a/editor/action_history.gd.uid b/editor/action_history.gd.uid new file mode 100644 index 0000000..e1c64cc --- /dev/null +++ b/editor/action_history.gd.uid @@ -0,0 +1 @@ +uid://bj1egufnqcho5 diff --git a/editor/group_ops.gd b/editor/group_ops.gd new file mode 100644 index 0000000..bb3defc --- /dev/null +++ b/editor/group_ops.gd @@ -0,0 +1,197 @@ +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 diff --git a/editor/group_ops.gd.uid b/editor/group_ops.gd.uid new file mode 100644 index 0000000..1f8ad6e --- /dev/null +++ b/editor/group_ops.gd.uid @@ -0,0 +1 @@ +uid://d0luucpxtrabo diff --git a/editor/move_math.gd b/editor/move_math.gd new file mode 100644 index 0000000..e43fb18 --- /dev/null +++ b/editor/move_math.gd @@ -0,0 +1,61 @@ +extends RefCounted +class_name MoveMath +## Shortcuts-Mathe für Karte 2/3 (t_55257792) — bewusst pure Funktionen +## (kein Knoten, keine Kamera): Pfeiltasten-Raster-Schritt + Terrain- +## Pinselgrößen-Zyklus. tests/test_shortcuts.gd prüft headless; main.gd und +## interaction.gd sind nur die dünnen Anwender. +## +## Pfeiltasten-Konvention (Editor-Üblichkeit): „↑" bewegt die Auswahl VON der +## Kamera WEG (in Blickrichtung, horizontal), „↓" auf sie zu, links/rechts +## entsprechend quer. Die Blickrichtung wird auf die NÄCHSTE Weltachse +## gerastet (Godot-Editor-Konvention) — eine Pfeiltaste verschiebt also immer +## exakt entlang X oder Z, nie diagonal. + +const NUDGE_STEP_M := 2.0 # eine Rasterzelle pro Tastendruck (Karte 2/3) + + +## Pfeil-Richtung → Welt-Delta (2 m). yaw_deg = Kamera-Gierwinkel (Orbit ODER +## Flug), fwd = +1 (↑) / −1 (↓), side = +1 (rechts) / −1 (links). +## Die horizontale Blickrichtung (FlyMath.forward_from_euler(yaw, 0)) wird auf +## die nächste Weltachse gerastet: |z| >= |x| → Z-Achse, sonst X-Achse +## (Gleichstand → Z, deterministisch). Rechts = Blickrichtung × oben gekreuzt. +static func nudge_delta(yaw_deg: float, fwd: int, side: int) -> Vector3: + var f := forward_from_euler(yaw_deg, 0.0) + var axis := Vector3.ZERO + if absf(f.z) >= absf(f.x): + axis = Vector3(0.0, 0.0, -1.0) if f.z <= 0.0 else Vector3(0.0, 0.0, 1.0) + else: + axis = Vector3(-1.0, 0.0, 0.0) if f.x <= 0.0 else Vector3(1.0, 0.0, 0.0) + var right := axis.cross(Vector3.UP) + return (axis * float(fwd) + right * float(side)) * NUDGE_STEP_M + + +## Horizontale Blickrichtung (nur Yaw — identische Formel wie FlyMath, hier +## kopiert statt Abhängigkeit, damit die Datei ohne FlyMath-Load testbar bleibt). +static func forward_from_euler(yaw_deg: float, _pitch_deg: float) -> Vector3: + var yaw := deg_to_rad(yaw_deg) + return Vector3(-sin(yaw), 0.0, -cos(yaw)) + + +# ------------------------------------------------- Terrain-Pinsel-Presets ---- + +## Pinselgröße-Zyklus durch die 3 Presets (Klein 4 m / Mittel 12 m / Groß 32 m, +## wie die Buttons im Terrain-Panel). up = ] (vergrößern), down = [ (verkleinern). +## Bei einem FREIEN Slider-Wert (nutzergezogen) geht ] zum nächsten Preset +## ÜBER dem Wert, [ zum nächsten DARUNTER — am Rand wird zyklisch gewrappt. +## Rückgabe: der neue Radius in Metern. +static func brush_next_preset(current_m: float, up: bool) -> float: + var presets := [4.0, 12.0, 32.0] + if up: + for r in presets: + if r > current_m + 0.001: + return r + return presets[0] # zyklisch: über dem größten → wieder Klein + # Abwärts: das NÄCHSTE Preset UNTER dem Wert (größtes darunter) — + # rückwärts iterieren, sonst fände die Schleife immer Klein zuerst. + var i := presets.size() - 1 + while i >= 0: + if presets[i] < current_m - 0.001: + return presets[i] + i -= 1 + return presets[presets.size() - 1] # zyklisch: unter dem kleinsten → Groß diff --git a/editor/move_math.gd.uid b/editor/move_math.gd.uid new file mode 100644 index 0000000..9001904 --- /dev/null +++ b/editor/move_math.gd.uid @@ -0,0 +1 @@ +uid://behfbtvkyai65 diff --git a/editor/object_ops.gd b/editor/object_ops.gd new file mode 100644 index 0000000..f9e0e69 --- /dev/null +++ b/editor/object_ops.gd @@ -0,0 +1,384 @@ +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) + + +## Auswahl per Pfeiltaste im 2-m-Raster verschieben (Karte t_55257792) — pure. +## Genau das gegebene Delta (±2 m pro Tastendruck, Richtung von MoveMath), +## X/Z an der Map geklemmt. Die HÖHE bleibt relativ zum Terrain erhalten +## (t_7567a06f-Semantik wie beim Ziehen): ein angehobenes Objekt schwebt +## weiter, ein bodennahes bleibt bodennah — ein Objekt auf dem 2-m-Raster +## landet wieder exakt auf dem Raster. Rückgabe: Anzahl geänderter Objekte. +static func nudge_objects(doc: Dictionary, indices: Array, delta: Vector3) -> int: + var objects: Array = doc.get("objects", []) + var half := _terrain_half_size(doc) + var changed := 0 + for i in indices: + if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): + continue + var obj: Dictionary = objects[i] + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + continue + 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 lift := float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) + var y := SvmapDocument.terrain_height_at(doc, x, z) + lift + if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001: + changed += 1 + obj["pos"] = [x, y, z] + return changed + + +## Die AUSWAHL auf den Boden absetzen (Strg+Bild↑, Karte t_55257792 — das +## Godot-"Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell): pos.y wird +## exakt die Terrain-Höhe an (x, z). X/Z bleiben unangetastet. Rückgabe: +## Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden). +static func snap_selection_to_floor(doc: Dictionary, indices: Array) -> int: + var objects: Array = doc.get("objects", []) + var changed := 0 + for i in indices: + if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): + continue + var pos: Array = (objects[i] as Dictionary).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: + (objects[i] as Dictionary)["pos"] = [float(pos[0]), h, float(pos[2])] + changed += 1 + return changed + + +static func _terrain_half_size(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 + + +## 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, + offset_x := DUPLICATE_OFFSET_M.x) -> 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]) + offset_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, + offset_x := DUPLICATE_OFFSET_M.x) -> Array: + var new_indices: Array = [] + for i in indices: + var ni := duplicate_object(doc, int(i), grid_enabled, offset_x) + if ni >= 0: + new_indices.append(ni) + regroup_copies(doc, new_indices) + return new_indices + + +## Kopien gruppierter Objekte bilden eine NEUE Gruppe je Original-Gruppe — sonst +## würde die Kopie eines Gebäudes mit dem Original zu EINER Gruppe verschmelzen +## (Gruppen-ID wird von duplicate(true) mitkopiert). Name: " (Kopie)". +static func regroup_copies(doc: Dictionary, new_indices: Array) -> void: + var objects: Array = doc.get("objects", []) + var mapping := {} + for ni in new_indices: + var obj: Variant = objects[int(ni)] + var old_gid := GroupOps.group_id(obj) + if old_gid == GroupOps.UNGROUPED: + continue + if not mapping.has(old_gid): + var gid := GroupOps.next_group_id(doc) + mapping[old_gid] = gid + var old_name := GroupState.display_name(doc, old_gid) + objects[int(ni)]["group"] = gid # sofort belegen, damit next_group_id weiterzählt + GroupState.set_group_name(doc, gid, "%s (Kopie)" % old_name) + (obj as Dictionary)["group"] = int(mapping[old_gid]) + + +## 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 (Karte t_e166e656, Platzierungsfreiheit — Sly: „Steine versenken!"): +## - Anheben: frei nach oben, Höchstgrenze = bounds.max.y (Match-Grenze, V9). +## - Absenken: FREI unter die Terrain-Höhe (Versenken — der Stein darf +## halb im Boden stecken). Untergrenze = bounds.min.y; fehlt bounds → +## −80 m als sinnvolle Default-Grenze. +## 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 min_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]) + if (bounds.get("min", null) is Array) and ((bounds["min"] as Array).size() == 3): + min_y = float(bounds["min"][1]) + var target := clampf(y + delta_m, min_y, max_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). +## 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt — Blickachse X +## bleibt bei X-Spiegelung unverändert, Rollen (Z) dreht das Vorzeichen; +## bei Z-Spiegelung umgekehrt. rot/rot_y_deg bleiben via SvmapIO.rot_setzen +## synchron (reines Y → kein rot-Feld, Datei legacy-sauber). +static func mirror_object(obj: Dictionary, axis: String) -> void: + var euler := SvmapIO.objekt_rotation(obj) + if axis == "x": + SvmapIO.rot_setzen(obj, Vector3( + fposmod(euler.x, 360.0), + fposmod(-euler.y, 360.0), + fposmod(-euler.z, 360.0))) + else: + SvmapIO.rot_setzen(obj, Vector3( + fposmod(-euler.x, 360.0), + fposmod(180.0 - euler.y, 360.0), + fposmod(euler.z, 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 + + +# --------------------------------------------- Symmetrie — Objekte (M3) ----- +# Karte t_34908425 (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md): im +# Symmetrie-Modus wirkt jede Objekt-Änderung auf beide Seiten. Die Transform- +# Mathe und das sym-Feld kommen aus SymmetryCore (M1, t_f5935023) — hier nur +# die Objekt-Anwendung, KEINE zweite Spiegel-Mathe. Alles static + pure +# (Dokument rein, Mutation im Dokument), headless testbar via +# tests/test_object_mirror.gd. Modus OFF = no-op (Verhalten wie bisher). +# +# Partner-Regel (invariant): der Zwilling ist IMMER der exakte Spiegel seiner +# Quelle — Position via SymmetryCore.mirror_point (y bleibt 1:1, Spec-Tabelle), +# Drehung via SymmetryCore.mirror_rot_y (Punkt: +180°, Achse: Vorzeichen). +# +# ACHTUNG Indizes: symmetry_place_twin hängt den Zwilling ans ENDE der Liste — +# bestehende Indizes bleiben gültig (nur Anhängen, nie Verschieben). + +## Zwilling beim PLATZIEREN/DUPLIZIEREN anlegen: objects[index] bekommt eine +## neue sym-Kennung (SymmetryCore.next_sym_key), der Zwilling mit exakt +## gespiegeltem Transform wandert ans ENDE der objects-Liste. Ein Objekt, das +## auf der Achse/im Zentrum liegt (SymmetryCore.maps_to_self), wird NICHT +## gedoppelt. Modus OFF oder ungültiger Index: es passiert nichts. +## Rückgabe: {"twin": int, "on_axis": bool} — twin = Index des Zwillings +## (-1 = keiner angelegt), on_axis = Objekt liegt auf Mitte/Achse. +static func symmetry_place_twin(doc: Dictionary, index: int, mode: int) -> Dictionary: + var out := {"twin": -1, "on_axis": false} + if mode == SymmetryCore.Mode.OFF: + return out + var objects: Array = doc.get("objects", []) + if index < 0 or index >= objects.size() or not (objects[index] is Dictionary): + return out + var src: Dictionary = objects[index] + var pos: Array = src.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + return out + var center := SymmetryCore.map_center(doc) + var p := Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + if SymmetryCore.maps_to_self(mode, p, center): + out["on_axis"] = true + return out + var twin: Dictionary = src.duplicate(true) + SymmetryCore.clear_sym(twin) + var mp := SymmetryCore.mirror_point(mode, p, center) + # y 1:1 übernommen (Spec vc27: Höhe ist ein Skalar) → der Zwilling ist der + # EXAKTE Spiegel, inklusive Höhe (auf symmetrischem Terrain = auf dem Boden). + twin["pos"] = [mp.x, mp.y, mp.z] + # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines + # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). + SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( + mode, SvmapIO.objekt_rotation(src))) + var key := SymmetryCore.next_sym_key(doc) + SymmetryCore.set_sym(src, key) + SymmetryCore.set_sym(twin, key) + objects.append(twin) + out["twin"] = objects.size() - 1 + return out + + +## Nach einer Positions-/Dreh-Änderung ALLE betroffenen Paare synchronisieren: +## der Zwilling wird auf den exakten Spiegel seiner Quelle gesetzt. members = +## Indizes der geänderten Objekte; gehört BEIDE Seiten eines Paares zur +## Änderung, ist die Quelle das primary_hint (das gezogene/gedrehte Primär- +## Objekt), sonst der niedrigere Index (deterministisch). Modus OFF: no-op. +## Rückgabe: Indizes der neu geschriebenen Zwillinge (für die Marker-Synchronisierung). +static func symmetry_sync_pairs(doc: Dictionary, members: Array, mode: int, + primary_hint: int = -1) -> Array: + if mode == SymmetryCore.Mode.OFF: + return [] + var objects: Array = doc.get("objects", []) + var center := SymmetryCore.map_center(doc) + var changed: Array = [] + var handled: Dictionary = {} + for i in members: + if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): + continue + var key := SymmetryCore.sym_of(objects[i] as Dictionary) + if key.is_empty(): + continue + var t := SymmetryCore.find_partner_index(objects, key, i) + if t < 0: + continue + var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)] + if handled.has(pair_key): + continue + handled[pair_key] = true + var src_i: int = i + if members.has(t): + # Beide Seiten in der Änderung (z. B. beide markiert und gezogen): + # die Quelle entscheidet, der Zwilling wird neu gespiegelt — so + # bleibt das Paar exakt spiegelbildlich statt auseinanderzulaufen. + if primary_hint == t: + src_i = t + elif primary_hint != i and t < i: + src_i = t + var twin_i: int = t if src_i == i else i + var src: Dictionary = objects[src_i] + var spos: Array = src.get("pos", [0.0, 0.0, 0.0]) + if (spos as Array).size() != 3: + continue + var mp := SymmetryCore.mirror_point(mode, + Vector3(float(spos[0]), float(spos[1]), float(spos[2])), center) + var twin: Dictionary = objects[twin_i] + twin["pos"] = [mp.x, mp.y, mp.z] + # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines + # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). + SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( + mode, SvmapIO.objekt_rotation(src))) + changed.append(twin_i) + return changed + + +## Löschen erweitern: zu den gewählten Indizes kommen die sym-Partner dazu +## (jeder Partner genau einmal, auch wenn mehrere Quellen dasselbe Paar +## treffen). Modus OFF: Liste unverändert (Verhalten wie bisher). +static func symmetry_expand_delete(doc: Dictionary, doomed: Array, mode: int) -> Array: + if mode == SymmetryCore.Mode.OFF: + return doomed + var objects: Array = doc.get("objects", []) + var out: Array = doomed.duplicate() + for i in doomed: + if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): + continue + var key := SymmetryCore.sym_of(objects[i] as Dictionary) + if key.is_empty(): + continue + var t := SymmetryCore.find_partner_index(objects, key, i) + if t >= 0 and not out.has(t): + out.append(t) + return out diff --git a/editor/object_ops.gd.uid b/editor/object_ops.gd.uid new file mode 100644 index 0000000..a8fb101 --- /dev/null +++ b/editor/object_ops.gd.uid @@ -0,0 +1 @@ +uid://dnocqoteya1h3 diff --git a/editor/selection_math.gd b/editor/selection_math.gd new file mode 100644 index 0000000..56a3a5c --- /dev/null +++ b/editor/selection_math.gd @@ -0,0 +1,143 @@ +extends RefCounted +class_name SelectionMath +## Rubberband-Auswahl-Mathematik (Karte t_f6f29d86) — pure, statisch, +## headless testbar (kein Viewport nötig — Transform + Projection genügen). +## +## Ein Objekt gilt als "im Rahmen", wenn sein Anker-Punkt (Fußpunkt der +## Objekt-Marker) im Bildschirm-Rechteck liegt. Anker statt Mesh-Exakt: +## schnell und fürs Auge richtig — Marker sind die Klick-Flächen. + + +## Kern (getestet): Weltpunkte → Bildschirmprojektion mit MITGEBENGER +## Kamera-Transform + Projektionsmatrix. Punkte hinter der Kamera +## (Blickrichtung -Z, view.z >= 0) werden ignoriert. +static func pick_in_rect_projection(cam_xform: Transform3D, proj: Projection, + viewport_size: Vector2, world_positions: Array, rect: Rect2) -> Array: + var out: Array = [] + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return out + var inv := cam_xform.affine_inverse() + for i in world_positions.size(): + var p: Variant = world_positions[i] + if not (p is Vector3): + continue + var view: Vector3 = inv * (p as Vector3) + if view.z >= 0.0: + continue # hinter der Kamera (kein Spiegel-Treffer) + var clip: Vector4 = proj * Vector4(view.x, view.y, view.z, 1.0) + if absf(clip.w) < 0.0000001: + continue + var ndc := Vector2(clip.x / clip.w, clip.y / clip.w) + var screen := Vector2((ndc.x * 0.5 + 0.5) * viewport_size.x, + (1.0 - (ndc.y * 0.5 + 0.5)) * viewport_size.y) + if rect.has_point(screen): + out.append(i) + return out + + +## Runtime-Variante (Editor): nimmt die Viewport-Kamera direkt. +static func pick_in_rect(cam: Camera3D, world_positions: Array, rect: Rect2) -> Array: + if cam == null or not is_instance_valid(cam): + return [] + var viewport_size := Vector2(cam.get_viewport().size) + return pick_in_rect_projection(cam.global_transform, cam.get_camera_projection(), + viewport_size, world_positions, rect) + + +## Normalisiertes Rechteck aus zwei Ecken (auch "rückwärts" aufgezogen). +static func rect_from(a: Vector2, b: Vector2) -> Rect2: + return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)), + Vector2(absf(b.x - a.x), absf(b.y - a.y))) + + +## Mittelpunkt der Auswahl für "F = Auswahl ins Bild zentrieren" +## (Karte t_c46d31c2): Mittelwert der Marker-Anker (+ halbe Mesh-Höhe — der +## optische Objekt-Mittelpunkt, nicht der Fußpunkt). Ungültige Indizes/Marker +## werden übersprungen; bei GAR NICHTS Gültigem: Ursprung (main meldet dann +## „nichts ausgewählt“ — der Guard sitzt beim Aufrufer). +static func focus_center(markers: Array, indices: Array) -> Vector3: + var sum := Vector3.ZERO + var count := 0 + for i in indices: + if not (i is int) or i < 0 or i >= markers.size(): + continue + var m: Variant = markers[i] + if m == null or not (m is Node3D) or not is_instance_valid(m): + continue + var node := m as Node3D + sum += node.global_position \ + + Vector3.UP * float(node.get_meta("half_height", 1.0)) * 0.5 + count += 1 + if count == 0: + return Vector3.ZERO + return sum / float(count) + + +# ------------------------------------------------- Exakter Klick-Treffer --- + +## Faces-Cache pro Mesh (Klick-Pick, Karte Mauer-Klick 24.10.): get_faces() +## kopiert jedes Mal das ganze Dreiecks-Array — einmal pro Mesh reicht. +static var _faces_cache: Dictionary = {} + + +## Strahl gegen die ECHTEN Dreiecke eines Meshes (Möller–Trumbore über +## ray_tri). xform = globale Transform der MeshInstance (inkl. +## Objekt-Drehung/-Kippung, Platzhalter-Lift und Node-Scale). +## Rückgabe: Welt-Abstand vom Strahl-Ursprung zum nächsten Treffer, -1 = keiner. +## WARUM: Die Kugel-Näherung (pick_radius = halbe AABB-Diagonale) eines großen +## Gebäudes ragt weit über seine Wände hinaus — ein Klick auf die schmale +## Mauer daneben traf zuerst die Gebäude-Kugel (Sly-Befund 22.10.). +static func ray_mesh_distance(xform: Transform3D, faces: PackedVector3Array, + origin: Vector3, dir: Vector3) -> float: + if faces.size() < 3: + return -1.0 + var inv := xform.affine_inverse() + var lo: Vector3 = inv * origin + var ld: Vector3 = inv.basis * dir + var best := INF + for i in int(faces.size() / 3): + var t := ray_tri(lo, ld, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2]) + if t >= 0.0 and t < best: + best = t + if best == INF: + return -1.0 + return origin.distance_to(xform * (lo + ld * best)) + + +## Faces eines Meshes (gecacht). Leeres Array, wenn kein Mesh. +static func mesh_faces(mesh: Mesh) -> PackedVector3Array: + if mesh == null: + return PackedVector3Array() + var key := mesh.get_rid() + if not _faces_cache.has(key): + _faces_cache[key] = mesh.get_faces() + return _faces_cache[key] + + +## Objekt-Mesh eines Markers: das Modell ist laut +## map_view_builder._build_objects IMMER das erste MeshInstance3D-Kind. +static func marker_mesh(marker: Node3D) -> MeshInstance3D: + for child in marker.get_children(): + if child is MeshInstance3D: + return child as MeshInstance3D + return null + + +## Möller–Trumbore: Abstand t entlang des Strahls zum Dreieck (a, b, c), -1 = kein Treffer. +static func ray_tri(origin: Vector3, dir: Vector3, a: Vector3, b: Vector3, c: Vector3) -> float: + var e1 := b - a + var e2 := c - a + var p := dir.cross(e2) + var det := e1.dot(p) + if absf(det) < 0.000001: + return -1.0 + var inv := 1.0 / det + var tv := origin - a + var u := tv.dot(p) * inv + if u < 0.0 or u > 1.0: + return -1.0 + var q := tv.cross(e1) + var v := dir.dot(q) * inv + if v < 0.0 or u + v > 1.0: + return -1.0 + return e2.dot(q) * inv diff --git a/editor/selection_math.gd.uid b/editor/selection_math.gd.uid new file mode 100644 index 0000000..77dcae2 --- /dev/null +++ b/editor/selection_math.gd.uid @@ -0,0 +1 @@ +uid://00t31s6wj67c diff --git a/editor/spawn_ops.gd b/editor/spawn_ops.gd new file mode 100644 index 0000000..4d48617 --- /dev/null +++ b/editor/spawn_ops.gd @@ -0,0 +1,325 @@ +extends RefCounted +class_name SpawnOps +## Spawn-Zonen-Operationen des Editors (M2, Karte t_c4162c5d) — pure Funktionen +## (kein UI, kein Knoten), wie ObjectOps für Objekte. main.gd/spawn_tool.gd +## rufen auf, tests/test_spawns.gd prüft headless. +## +## Format-Realität (map_format.md): spawn_zones speichert pro Team PUNKTE +## (id + pos) — der Kreis in der Karte ist die Editor-Visualisierung einer +## "Zone" um so einen Punkt. Sein Radius ist KEIN Formatfeld; er überlebt +## Speichern/Laden in design_notes.spawn_radius_m — der Loader prüft +## design_notes nicht, also rundtrip-sicher und trotzdem lesbar in der Datei. +## +## Die Validierung hier spiegelt 1:1 die Loader-Regeln (svmap_io.gd, Tabelle +## docs/map_format.md §4): V11 — beide Teams je ≥ 4 Spawn-Punkte; V7 — jede +## Objekt-ID in der Bibliothek. Live-Anzeige und "Karte prüfen" reden genau +## das, was der Loader beim Export entscheiden wird (keine Zweit-Meinung). + +const DEFAULT_ZONE_RADIUS := 6.0 # Meter — geräumig für einen Spieler-Start +const MIN_ZONE_RADIUS := 2.0 +const MAX_ZONE_RADIUS := 30.0 +const RADIUS_KEY := "spawn_radius_m" # Unter design_notes (Loader-frei, siehe oben) +const YAW_KEY := "yaw_deg" # Am Punkt/an der Zone selbst, Server und Client lesen es + +# TDM-Konvention (map_format.md): Team 0 = Blau = Prefix "A" (A1, A2 …), +# Team 1 = Rot = Prefix "B" (B1, B2 …). +const TEAM_LABELS := ["Team Blau", "Team Rot"] +const TEAM_PREFIXES := ["A", "B"] + +## Welcher Spawn-Satz gerade bearbeitet wird (reiner Editor-Zustand, nicht im Dokument): +## "base" = doc.spawn_zones (Standard für alle Modi), sonst ein Modus aus +## doc.mode_spawns (eigene Spawns dieses Modus, ModusKatalog). Deathmatch kennt nur +## den Schlüssel "all" — beide Team-Werkzeuge schreiben dorthin. +static var edit_set: String = ModusKatalog.SPAWN_SET_BASE +const SET_ID_PREFIX := {"tdm": "T", "conquest": "Q", "koth": "K", "dm": "D"} + + +static func _team_key(team: int) -> String: + return "all" if edit_set == ModusKatalog.MODE_DM else str(clampi(team, 0, 1)) + + +## Zonen-Block des bearbeiteten Satzes: {"0":{points}, "1":{points}} bzw. {"all":{points}}. +## create=true legt fehlende Blöcke an (nur für Schreibzugriffe). +static func zones_of(doc: Dictionary, create := false) -> Dictionary: + if edit_set == ModusKatalog.SPAWN_SET_BASE: + var z = doc.get("spawn_zones", {}) + if not (z is Dictionary): + z = {} + if create: + doc["spawn_zones"] = z + return z + var ms = doc.get("mode_spawns", {}) + if not (ms is Dictionary): + ms = {} + var set_v = (ms as Dictionary).get(edit_set, {}) + if not (set_v is Dictionary): + set_v = {} + if create: + doc["mode_spawns"] = ms + (ms as Dictionary)[edit_set] = set_v + return set_v + + +static func team_label(team: int) -> String: + return TEAM_LABELS[clampi(team, 0, 1)] + + +static func team_prefix(team: int) -> String: + return TEAM_PREFIXES[clampi(team, 0, 1)] + + +## Der Zone-Block eines Teams ("0"/"1") — immer ein Dictionary (leer, wenn fehlt). +static func zone_dict(doc: Dictionary, team: int) -> Dictionary: + var zones = zones_of(doc) + var zone = zones.get(_team_key(team), {}) + if zone is Dictionary: + return zone + return {} + + +## Spawn-Punkte eines Teams als Array (leer, wenn Block fehlt/defekt ist). +static func points(doc: Dictionary, team: int) -> Array: + var p = zone_dict(doc, team).get("points", []) + return p if p is Array else [] + + +static func count_points(doc: Dictionary, team: int) -> int: + return points(doc, team).size() + + +## Nächste freie Spawn-ID fürs Team ("A5") — Lücken werden übersprungen +## (Team-Prefixe trennen die Teams, doppelt vergebene Nummern nie). +static func next_id(doc: Dictionary, team: int) -> String: + var used := {} + var all_sets: Array = [doc.get("spawn_zones", {})] + var ms = doc.get("mode_spawns", {}) + if ms is Dictionary: + all_sets.append_array((ms as Dictionary).values()) + for zones in all_sets: + if not (zones is Dictionary): + continue + for key in zones.keys(): + var zone = zones[key] + if not (zone is Dictionary): + continue + var pts = zone.get("points", []) + if not (pts is Array): + continue + for p in pts: + if p is Dictionary: + used[str(p.get("id", ""))] = true + var prefix := team_prefix(team) + if edit_set != ModusKatalog.SPAWN_SET_BASE: + prefix = str(SET_ID_PREFIX.get(edit_set, "M")) + ("" if edit_set == ModusKatalog.MODE_DM else prefix) + var i := 1 + while used.has("%s%d" % [prefix, i]): + i += 1 + return "%s%d" % [prefix, i] + + +## Radius einer Zone aus design_notes (Default, wenn nicht gesetzt) — geclampt. +static func zone_radius(doc: Dictionary, pid: String) -> float: + var notes = doc.get("design_notes", {}) + if not (notes is Dictionary): + return DEFAULT_ZONE_RADIUS + var radii = (notes as Dictionary).get(RADIUS_KEY, {}) + if not (radii is Dictionary): + return DEFAULT_ZONE_RADIUS + return clampf(float(radii.get(pid, DEFAULT_ZONE_RADIUS)), MIN_ZONE_RADIUS, MAX_ZONE_RADIUS) + + +## Radius setzen (geklammert auf Editor-Range). Legt design_notes an, wenn fehlt. +static func set_radius(doc: Dictionary, pid: String, m: float) -> void: + var notes = doc.get("design_notes", {}) + if not (notes is Dictionary): + notes = {} + doc["design_notes"] = notes + var radii = (notes as Dictionary).get(RADIUS_KEY, {}) + if not (radii is Dictionary): + radii = {} + (notes as Dictionary)[RADIUS_KEY] = radii + radii[pid] = clampf(m, MIN_ZONE_RADIUS, MAX_ZONE_RADIUS) + + +## History-Schnappschuss für JEDE Spawn-Zonen-Änderung: spawn_zones REGULÄR +## über die ActionHistory, design_notes IMMER mit — auch wenn es vorher nicht +## gab (leerer Block) — damit Radius-Änderungen sauber mit-Undo-en und keine +## Alt-Radien nach Undo herumgeistern. +static func snapshot_blocks(hist: ActionHistory, doc: Dictionary) -> Dictionary: + var out := hist.snapshot(["spawn_zones"]) + var ms: Variant = doc.get("mode_spawns", null) # fehlend = null → Undo löscht den Block wieder + out["mode_spawns"] = ms.duplicate(true) if ms is Dictionary else null + out["design_notes"] = (doc.get("design_notes", {}) as Dictionary).duplicate(true) + return out + + +## Blickrichtung beim Spawn (optionales Feld yaw_deg an Spawn-Punkten und Conquest-Zonen). +## Fehlt es, schaut der Spieler zur Kartenmitte — der Pfeil zeigt das grau an. +static func has_yaw(entry: Dictionary) -> bool: + return entry.has(YAW_KEY) + + +static func yaw_towards(from: Vector3, to: Vector2) -> float: + var dx := to.x - from.x + var dz := to.y - from.z + if Vector2(dx, dz).length() < 0.5: + return 0.0 + return fposmod(rad_to_deg(atan2(-dx, -dz)), 360.0) + + +## Die Richtung, die im Spiel gilt: gesetzte oder Standard (zur Kartenmitte). +static func effective_yaw(doc: Dictionary, entry: Dictionary) -> float: + if entry.has(YAW_KEY): + return fposmod(float(entry[YAW_KEY]), 360.0) + var pos: Array = entry.get("pos", [0.0, 0.0, 0.0]) + return yaw_towards(Vector3(float(pos[0]), float(pos[1]), float(pos[2])), SymmetryCore.map_center(doc)) + + +## yaw = Grad setzen, null = zurück auf Standard. +static func write_yaw(entry: Dictionary, yaw: Variant) -> void: + if yaw == null: + entry.erase(YAW_KEY) + else: + entry[YAW_KEY] = fposmod(roundf(float(yaw)), 360.0) + + +static func set_point_yaw(doc: Dictionary, team: int, pid: String, yaw: Variant) -> bool: + for p in points(doc, team): + if p is Dictionary and str(p.get("id", "")) == pid: + write_yaw(p, yaw) + return true + return false + + +## Neue Spawn-Zone (Punkt) ans Team hängen. y = Terrain-Höhe (Punkt sitzt auf +## dem Boden; der Loader prüft nur Grenzen, aber schwebende Spawns verwirren). +## Rückgabe: der neue Punkt (id + pos). +static func add_zone(doc: Dictionary, team: int, x: float, z: float) -> Dictionary: + var zones: Dictionary = zones_of(doc, true) + var team_key := _team_key(team) + var zone = zones.get(team_key, {}) + if not (zone is Dictionary): + zone = {} + if not zone.has("label"): + zone["label"] = team_label(team) + zones[team_key] = zone + if not (zone.get("points", []) is Array): + zone["points"] = [] + var point := { + "id": next_id(doc, team), + "pos": [x, SvmapDocument.terrain_height_at(doc, x, z), z], + } + (zone["points"] as Array).append(point) + return point + + +## Zone verschieben (x/z wie übergeben, y = Terrain-Höhe). false, wenn fehlt. +## Außerhalb-Kandidaten clampt der Aufrufer (spawn_tool auf die Terrain-Fläche). +static func move_point(doc: Dictionary, team: int, pid: String, x: float, z: float) -> bool: + for p in points(doc, team): + if p is Dictionary and str(p.get("id", "")) == pid: + p["pos"] = [x, SvmapDocument.terrain_height_at(doc, x, z), z] + return true + return false + + +## Zone löschen (inkl. Radius-Eintrag in design_notes — sonst würde eine +## spätere Zone mit derselben ID einen alten Radius erben). false, wenn fehlt. +static func remove_point(doc: Dictionary, team: int, pid: String) -> bool: + var pts := points(doc, team) + for i in pts.size(): + var p = pts[i] + if p is Dictionary and str(p.get("id", "")) == pid: + pts.remove_at(i) + var notes = doc.get("design_notes", {}) + if notes is Dictionary: + var radii = (notes as Dictionary).get(RADIUS_KEY, {}) + if radii is Dictionary: + (radii as Dictionary).erase(pid) + return true + return false + + +## Live-Validierungs-Zeilen (Loader-Regeln V11 + V7) — je {ok, text, hint}. +## Genau die Antwort auf "darf diese Map exportiert werden?" — BEVOR man exportiert. +static func validate_lines(doc: Dictionary, manifest: Dictionary = {}) -> Array: + var lines: Array = [] + var min_pts := SvmapIO.MIN_SPAWN_POINTS_PER_TEAM + for team in [0, 1]: + var count := count_points(doc, team) + var ok := count >= min_pts + var text := "%s Spawns %s: %d (mindestens %d nötig)" % [ + "✅" if ok else "❌", team_label(team), count, min_pts] + var hint := "" + if not ok: + hint = "Im Werkzeug »Spawns« %d Zone(n) für %s auf den Boden malen" % [ + min_pts - count, team_label(team)] + lines.append({"ok": ok, "text": text, "hint": hint}) + # V7: Objekt-IDs gegen die Bibliothek (keine Objekte = ok — Objects sind optional). + var lib: Dictionary = manifest.get("objects", {}) if manifest is Dictionary else {} + var total := 0 + var unknown := 0 + for obj in (doc.get("objects", []) as Array): + total += 1 + if not (obj is Dictionary) or not lib.has(str(obj.get("id", ""))): + unknown += 1 + var ids_ok := unknown == 0 + var ids_text := "%s Objekt-IDs: %s" % ["✅" if ids_ok else "❌", + "alle %d in der Bibliothek" % total if unknown == 0 else + "%d von %d unbekannt — auswendige Namen kann der Server nicht laden" % [unknown, total]] + lines.append({"ok": ids_ok, "text": ids_text, "hint": "Bibliothek bietet nur bekannte Objekte" if not ids_ok else ""}) + return lines + + +## Einzeiliger Live-Status für die rechte Spalte (ständig sichtbar, aktualisiert +## sich bei jeder Änderung): "✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 …" +static func live_status(doc: Dictionary, manifest: Dictionary = {}) -> String: + var parts: Array = [] + for line in validate_lines(doc, manifest): + parts.append(str(line["text"])) + return " · ".join(PackedStringArray(parts)) + + +## "Karte prüfen" (Map-Fertig-Check): Klartext-Liste ✅/❌ + ok-Flag. +## Der harte Gate bleibt das SPEICHERN (SvmapIO.save_to_file validiert und +## signiert mit demselben Kern) — der Check hier verspricht dasselbe Ergebnis, +## nur freundlich und VOR dem Export. ok = der echte Loader-Kern (_validate_core) +## wäre grün; die Prüfsumme bewusst NICHT — die macht erst das Speichern. +static func ready_report(doc: Dictionary, manifest: Dictionary = {}) -> Dictionary: + var lines: Array = [] + var info: Dictionary = doc.get("info", {}) + var name_ok := str(info.get("name", "")).strip_edges() != "" + lines.append({"ok": name_ok, "text": "%s Map-Name ist gesetzt%s" % [ + "✅" if name_ok else "❌", + "" if name_ok else " — unter »Map-Eigenschaften« eintragen"]}) + var author_ok := str(info.get("author", "")).strip_edges() != "" + lines.append({"ok": author_ok, "text": "%s Autor ist gesetzt%s" % [ + "✅" if author_ok else "❌", + "" if author_ok else " — unter »Map-Eigenschaften« eintragen"]}) + for line in validate_lines(doc, manifest): + lines.append(line) + var used := SvmapDocument.poly_usage(doc, manifest) + var budget := float(SvmapIO.MAX_TOTAL_POLY) + var poly_frac := used / budget if budget > 0.0 else 0.0 + var poly_ok := poly_frac <= 1.0 + lines.append({"ok": poly_ok, "text": "%s Poly-Budget: %.0f %% belegt (%s von %s)" % [ + "✅" if poly_ok else "❌", poly_frac * 100.0, + _fmt_thousand(int(used)), _fmt_thousand(int(budget))]}) + # Harte Wahrheit: der Loader-Kern entscheidet (dieselbe Funktion, die das + # Speichern vor dem Schreiben noch einmal aufruft — EINE Implementierung, + # kein Drift zwischen Anzeige und Export). + var core: PackedStringArray = SvmapIO._validate_core(doc, manifest) + var ok := core.is_empty() + if not ok: + for e in core: + lines.append({"ok": false, "text": "❌ %s" % str(e), "hint": ""}) + return {"ok": ok, "lines": lines} + + +static func _fmt_thousand(value: int) -> String: + var s := str(value) + var out := "" + while s.length() > 3: + out = "." + s.substr(s.length() - 3) + out + s = s.substr(0, s.length() - 3) + return s + out \ No newline at end of file diff --git a/editor/spawn_ops.gd.uid b/editor/spawn_ops.gd.uid new file mode 100644 index 0000000..9c86c61 --- /dev/null +++ b/editor/spawn_ops.gd.uid @@ -0,0 +1 @@ +uid://c1yvw71ppjl28 diff --git a/editor/symmetry_core.gd b/editor/symmetry_core.gd new file mode 100644 index 0000000..7f975ef --- /dev/null +++ b/editor/symmetry_core.gd @@ -0,0 +1,233 @@ +extends RefCounted +class_name SymmetryCore +## Symmetrie-Modus im Map Builder (Karte t_f5935023, Spec +## /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Entscheidung Sly 20.10.): +## gespiegeltes Bauen — Änderungen an einer Kartenseite landen automatisch +## auf der anderen. DIESE Datei ist M1 (Fundament): reine Mathe + Format- +## Helfer, KEIN Spiegeln von Aktionen — das kommt mit M2 (Terrain), +## M3 (Objekte) und M4 (Bau-Teile + Spawns). +## +## Spiegel-Mathematik (Spec vc27): +## Zentrum = Kartenmitte. Editor-Maps liegen zentriert (bounds +## −size/2 … +size/2), die Mitte ist also in Weltkoordinaten (0, 0) — +## map_center() rechnet sie trotzdem generell aus den bounds aus +## ((min + max) / 2), damit die Helfer auch bei asymmetrischen bounds +## korrekt bleiben. +## | Modus | Position | rot_y | +## |-------------|-----------------------------|--------------| +## | Punkt (180°)| x' = 2cx − x, z' = 2cz − z | rot + 180° | +## | Achse X | x' = 2cx − x, z' = z | −rot | +## | Achse Z | x' = x, z' = 2cz − z | −rot | +## y bleibt in ALLEN Modi unverändert (Höhe ist ein Skalar). +## Standard = Punktspiegelung (nur sie macht beide Hälften wirklich +## deckungsgleich — Achsenspiegelung dreht den Zwilling um 180°, siehe Spec). +## +## Terrain-Zell-Abbildung (Zelle (i,j), i läuft entlang x, j entlang z — +## siehe SvmapDocument.terrain_height_at): Punkt (res−1−i, res−1−j), +## Achse X (res−1−i, j), Achse Z (i, res−1−j). +## +## Partner-Link: ein Paar trägt denselben `sym`-Schlüssel (optionales Feld +## an Objekten UND Bau-Teilen). Validator-Regel (svmap_io.gd, V16): +## String, nicht leer, ≤ 64 Zeichen, höchstens 2 Objekte pro Kennung. +## Alte Karten ohne `sym` laden unverändert (Rückwärtskompatibilität). +## +## Alles static + reine Funktionen, ohne Node-Abhängigkeit → headless testbar. + +enum Mode { + OFF = 0, # Symmetrie aus + POINT = 1, # Punktspiegelung 180° um die Kartenmitte (Standard) + AXIS_X = 2, # Achsenspiegelung X (links ↔ rechts) + AXIS_Z = 3, # Achsenspiegelung Z (vorn ↔ hinten) +} + +## Toolbar-/Statuszeilen-Texte, Index = Mode-Wert. +const MODE_LABELS: Array[String] = ["Aus", "Punkt", "Achse X", "Achse Z"] + +## Toleranz in Metern für „liegt auf der Achse/im Zentrum“ — solche Elemente +## werden beim gespiegelten Bauen NICHT gedoppelt (Spec vc27). +const ON_AXIS_TOLERANCE_M := 0.05 + + +# ------------------------------------------------------------ Modus/Helfer -- + +static func mode_label(mode: int) -> String: + return MODE_LABELS[clampi(mode, 0, MODE_LABELS.size() - 1)] + + +## Kartenmitte aus den bounds ((min + max) / 2 in X und Z). Für zentrierte +## Editor-Maps = (0, 0), unabhängig von der Kartengröße — die Größe steckt +## in den bounds. Fehlen die bounds (defektes doc) → (0, 0). +static func map_center(doc: Dictionary) -> Vector2: + var bounds: Dictionary = doc.get("bounds", {}) + var mn: Array = bounds.get("min", []) + var mx: Array = bounds.get("max", []) + if (mn as Array).size() == 3 and (mx as Array).size() == 3: + return Vector2( + (float(mn[0]) + float(mx[0])) * 0.5, + (float(mn[2]) + float(mx[2])) * 0.5) + return Vector2.ZERO + + +# ----------------------------------------------------- Transform-Helfer ----- + +## Spiegelt die XZ-Position eines Objekts/Bau-Teils; Y (Höhe) bleibt gleich. +static func mirror_point(mode: int, p: Vector3, center: Vector2) -> Vector3: + match mode: + Mode.POINT: + return Vector3(2.0 * center.x - p.x, p.y, 2.0 * center.y - p.z) + Mode.AXIS_X: + return Vector3(2.0 * center.x - p.x, p.y, p.z) + Mode.AXIS_Z: + return Vector3(p.x, p.y, 2.0 * center.y - p.z) + _: + return p + + +## Spiegelt die Y-Drehung in Grad. Normalisiert auf [0, 360): zwei Anwendungen +## ergeben wieder die Ausgangsdrehung (modulo 360 — gleiche Orientierung). +## Punkt: +180°, Achsen: −180°-Spiegel = Vorzeichenwechsel. +static func mirror_rot_y(mode: int, rot_y_deg: float) -> float: + match mode: + Mode.POINT: + return fposmod(rot_y_deg + 180.0, 360.0) + Mode.AXIS_X, Mode.AXIS_Z: + return fposmod(-rot_y_deg, 360.0) + _: + return rot_y_deg + + +## Spiegelt eine Blickrichtung (Grad, Blick = (-sin, 0, -cos)) exakt mit: anders als +## mirror_rot_y zeigt der Pfeil danach wirklich in die gespiegelte Richtung. +static func mirror_facing(mode: int, yaw_deg: float) -> float: + match mode: + Mode.POINT: + return fposmod(yaw_deg + 180.0, 360.0) + Mode.AXIS_X: + return fposmod(-yaw_deg, 360.0) + Mode.AXIS_Z: + return fposmod(180.0 - yaw_deg, 360.0) + _: + return yaw_deg + + +## true, wenn der Punkt unter diesem Modus auf sich selbst abgebildet wird +## (Zentrum bei Punktspiegelung, Spiegelachse bei Achsenspiegelung, ± Toleranz). +## Solche Elemente werden beim gespiegelten Bauen NICHT gedoppelt. +static func maps_to_self(mode: int, p: Vector3, center: Vector2, + threshold_m: float = ON_AXIS_TOLERANCE_M) -> bool: + match mode: + Mode.POINT: + return Vector2(p.x - center.x, p.z - center.y).length() < threshold_m + Mode.AXIS_X: + return absf(p.x - center.x) < threshold_m + Mode.AXIS_Z: + return absf(p.z - center.y) < threshold_m + _: + return true + + +# ------------------------------------------------ Zell-Abbildung (Terrain) -- + +## Rasterzelle (i, j) → gespiegelte Zelle. res = terrain.resolution. +## i läuft entlang x, j entlang z (wie terrain_height_at). OFF → unverändert. +static func mirror_cell(mode: int, i: int, j: int, res: int) -> Vector2i: + if res < 2: + return Vector2i(i, j) + var last := res - 1 + match mode: + Mode.POINT: + return Vector2i(last - i, last - j) + Mode.AXIS_X: + return Vector2i(last - i, j) + Mode.AXIS_Z: + return Vector2i(i, last - j) + _: + return Vector2i(i, j) + + +# ------------------------------------------------------------ Partner-Link -- + +## sym-Kennung eines Elements lesen (leer = kein Partner). Typsicher: nur +## nicht-leere Strings gelten; defekte Felder werden still als „kein Partner“ +## behandelt (Rückwärtskompatibilität mit alten Karten). +static func sym_of(obj: Dictionary) -> String: + var s = obj.get("sym", "") + return s if s is String else "" + + +## sym-Kennung setzen (mutiert das Element und gibt es zurück zum Verketten). +static func set_sym(obj: Dictionary, key: String) -> Dictionary: + obj["sym"] = key + return obj + + +## sym-Kennung entfernen (Feld komplett löschen — fehlt es, wie in alten Karten). +static func clear_sym(obj: Dictionary) -> Dictionary: + obj.erase("sym") + return obj + + +## Index des Partner-Elements in einer Liste (objects ODER gebaeude): +## das Element mit derselben sym-Kennung (index selbst ausgeschlossen). +## −1 = kein Partner gefunden. +static func find_partner_index(list: Array, key: String, exclude_index: int = -1) -> int: + if key.is_empty(): + return -1 + for i in list.size(): + if i == exclude_index or not (list[i] is Dictionary): + continue + if sym_of(list[i] as Dictionary) == key: + return i + return -1 + + +## Nächste freie sym-Kennung im Dokument ("sym_1", "sym_2", …): scannt +## Objekte (inkl. Bauteile), deterministic (kein Zufall, keine Uhr). +static func next_sym_key(doc: Dictionary) -> String: + var used: Dictionary = {} + var block: Array = doc.get("objects", []) + for item in block: + if not (item is Dictionary): + continue + var s := sym_of(item as Dictionary) + if s.begins_with("sym_"): + var tail := s.substr(4) + if tail.is_valid_int(): + used[int(tail)] = true + var n := 1 + while used.has(n): + n += 1 + return "sym_%d" % n + + +# ---------------------------------- Spiegel-Mathe 3 Achsen (t_e166e656) ------ + +## Spiegelt eine VOLLE Euler-Orientierung (Karte t_e166e656, Platzierungs- +## freiheit). Definition (dokumentiert in docs/map_format.md §objects.rot): +## - Achsenspiegelung (AXIS_X / AXIS_Z) = Konjugation der Basis mit der +## Spiegelmatrix: B' = M·B·M — für reines Y-Drehen exakt die bisherige +## Regel mirror_rot_y (−rot). +## - Punktspiegelung (POINT) = 180°-Drehung um Y: B' = Ry(180°)·B — für +## reines Y-Drehen exakt mirror_rot_y (+180°). +## Godot-Euler-Reihenfolge YXZ (Node3D-Rotation), Rückgabe in Grad. +static func mirror_rot_euler(mode: int, rot_deg: Vector3) -> Vector3: + var b := Basis.from_euler(Vector3( + deg_to_rad(rot_deg.x), deg_to_rad(rot_deg.y), deg_to_rad(rot_deg.z))) + var gespiegelt: Basis + match mode: + Mode.POINT: + gespiegelt = Basis.from_euler(Vector3(0.0, deg_to_rad(180.0), 0.0)) * b + Mode.AXIS_X: + var m := Basis.from_scale(Vector3(-1.0, 1.0, 1.0)) + gespiegelt = m * b * m + Mode.AXIS_Z: + var m := Basis.from_scale(Vector3(1.0, 1.0, -1.0)) + gespiegelt = m * b * m + _: + return rot_deg + # Normalisierung [0, 360) je Achse — gleiche Darstellung wie rot_y_deg + # bisher (tests/test_object_mirror.gd erwartet z. B. 330 statt −30). + return Vector3( + fposmod(gespiegelt.get_euler().x * (180.0 / PI), 360.0), + fposmod(gespiegelt.get_euler().y * (180.0 / PI), 360.0), + fposmod(gespiegelt.get_euler().z * (180.0 / PI), 360.0)) diff --git a/editor/symmetry_core.gd.uid b/editor/symmetry_core.gd.uid new file mode 100644 index 0000000..51ee5f0 --- /dev/null +++ b/editor/symmetry_core.gd.uid @@ -0,0 +1 @@ +uid://e08hmo3p6ra3 diff --git a/library/svmap_wasser.gd b/library/svmap_wasser.gd new file mode 100644 index 0000000..755f89b --- /dev/null +++ b/library/svmap_wasser.gd @@ -0,0 +1,240 @@ +class_name SvmapWasser +extends RefCounted +## Wasser pro Map (Karte t_28fdef03) — SPIEGELGLEICHES Modul in Client UND +## Map-Editor (Muster: library/terrain_arrays.gd, t_ab408127 — identischer +## Pfad in beiden Repos, identischer Code; Repos bleiben entkoppelt). +## +## Sly-Entscheidungen 20.10.: Wasserlevel-Ansatz (EINE Höhe pro Map — Senken +## unter der Höhe füllen sich automatisch), Wat-Trägheit (knietief, kein +## Schwimmen), Ufer-Automatik (schlammiges Overlay knapp unter Wasserhöhe). +## +## Verantwortung dieses Moduls (Dictionary rein, Node out — wie SvmapTerrain): +## - hat()/level(): optionales svmap-Feld `water_level` (float, y-Höhe in m). +## Abwesenheit oder Fehltyp = KEIN Wasser (Legacy-Maps unverändert). +## - baue(): EINE transparente Fläche auf Wasserhöhe über der ganzen Map +## (1 Draw-Call, keine Geometrie-Wogen — Wellen NUR im Fragment-Shader). +## - ufer_bild(): Tiefe-Feld (R8, depth = wasser − Terrainhöhe, geklemmt auf +## tiefe_max) als Textur — der Shader liest daraus Ufer-Schaum + Flach- +## Wasser-Transparenz, OHNE Screen-Space-Tricks. +## - ufer_faktor()/matsch_anwenden(): Ufer-Automatik — Zellen knapp unter +## Wasserhöhe bekommen den Matsch-Ton. BEWUSST nur eine abgeleitete Tönung +## (kein gespeichertes Feld): die Material-Schicht bleibt malbar, der +## Matsch liegt als Höhen-Regel drüber — Client UND Editor-Vorschau +## rechnen IDENTISCH (dieselbe Datei). +## - make_material(): dasselbe Material wie der Client (Identitäts-Vertrag +## der Vorschau) — Shader-Pfad je Repo über SHADER_KANDIDATEN. +## +## KEINE echte Spiegelung/Brechung/SSR (bewusste Absage, Performance): +## Spiegel-Impression = Skybox-Farbreflex (uniform) + Fresnel-Glanz. +## +## Wasserdistanz-Regel (Perf-Beweis-Plan): ferne Fragmente vereinfachen im +## Shader selbst (kein Wellen-/Schaum-Aufwand) — keine Per-Frame-Logik nötig. + +## Shader-Pfade je Repo (erster vorhandener gewinnt — Spiegel-Dateien sind +## byteidentisch, siehe tools/sync_wasser.sh im Map-Editor). +const SHADER_KANDIDATEN: Array[String] = [ + "res://shaders/wasser.gdshader", # scopedvr-client + "res://src/viewport/wasser.gdshader", # scopedvr-map-editor (Spiegel) +] + +## Plausibilitätsbereich des svmap-Feldes `water_level` (Meter, y-Höhe). +## GLEICHE Werte wie V18 im Match-Server (svmap_loader) und im Editor +## (svmap_io) — nur Dokumentations-Konstante hier. +const LEVEL_MIN := -5.0 +const LEVEL_MAX := 500.0 + +## Ufer-Bild: Tiefe in 0..tiefe_max m → 0..255 (R8). Mehr Tiefe braucht kein +## Detail — Schaum lebt in den ersten Dezimetern, Transparenz in den ersten Metern. +const TIEFE_MAX := 4.0 + +## Schaum-Band am Ufer (Meter Wassertiefe, in denen Schaum sichtbar ist). +const SCHAUM_BAND := 0.35 + +## Matsch-Ring (Ufer-Automatik): Zellen, deren Terrainhöhe weniger als +## MATSCH_HOEHE unter dem Wasserlevel liegt (bis MATSCH_UEBER leicht darüber, +## damit die Wasserlinie selbst matschig ist), werden zum Matsch-Ton überblendet. +const MATSCH_HOEHE := 0.55 +const MATSCH_UEBER := 0.06 +const MATSCH_FARBE := Color(0.34, 0.26, 0.18) # schlammiges Braun-Betonung + +## Wetter-Farbkopplung (Entscheidung der Atmosphären-Karte): Wasser-Tint je +## Preset. farbe_flach = Seicht-Wasser, farbe_tief = tiefe Mitte, reflex = +## Skybox-Farbreflex (Himmelsfarbe des Presets), glanz = Glint-Stärke. +## Schlüssel = Client-Wetter-Keys (WeatherPresets). Unbekannt → "mittag". +const WETTER_WASSER := { + "mittag": { + "farbe_flach": Color(0.10, 0.30, 0.34), + "farbe_tief": Color(0.02, 0.10, 0.15), + "reflex": Color(0.72, 0.83, 0.92), + "glanz": 1.0, + }, + "abend": { + "farbe_flach": Color(0.18, 0.26, 0.28), + "farbe_tief": Color(0.04, 0.07, 0.11), + "reflex": Color(0.95, 0.62, 0.38), + "glanz": 1.2, + }, + "regen": { + "farbe_flach": Color(0.20, 0.24, 0.26), + "farbe_tief": Color(0.06, 0.09, 0.11), + "reflex": Color(0.55, 0.58, 0.62), + "glanz": 0.35, + }, + "nacht": { + "farbe_flach": Color(0.02, 0.05, 0.09), + "farbe_tief": Color(0.0, 0.015, 0.03), + "reflex": Color(0.16, 0.22, 0.38), + "glanz": 0.9, + }, +} + + +## Optionales svmap-Feld `water_level` vorhanden und brauchbar? Abwesenheit +## ODER Fehltyp = kein Wasser (Legacy-Maps unverändert, Fehltyp toleriert). +static func hat(data: Dictionary) -> bool: + var v: Variant = data.get("water_level", null) + return v is float or v is int + + +## Wasserhöhe aus der Map (INF = kein Wasser). +static func level(data: Dictionary) -> float: + var v: Variant = data.get("water_level", null) + if v is float or v is int: + return float(v) + return INF + + +## Wasserfläche bauen. terrain = der geparste terrain-Block, wasser = Wasser- +## Höhe (Meter), wetter = Preset-Key ("mittag"/"abend"/"regen"/"nacht"). +## Rückgabe: Node3D mit EINER transparenten Fläche — null, wenn kein Wasser +## sinnvoll ist (kein Feld, kein Terrain, Wasserhöhe unter dem Tiefpunkt, +## ungültige Maße). Die Senken "füllen sich" von selbst: alles unter der +## Höhe liegt hinterher unter der Fläche. +static func baue(terrain: Dictionary, wasser: float, wetter: String) -> Node3D: + if not is_finite(wasser): + return null + var res := int(terrain.get("resolution", 0)) + var size_x := float(terrain.get("size_x_m", 0.0)) + var size_z := float(terrain.get("size_z_m", 0.0)) + if res < 2 or size_x <= 0.0 or size_z <= 0.0: + return null + var heights := _decode_heights(terrain) + if heights.size() < res * res: + return null + var hmin := heights[0] + for h in heights: + if h < hmin: + hmin = h + if wasser <= hmin: + return null # Wasserhöhe unter dem Tiefpunkt — nichts gefüllt. + var root := Node3D.new() + root.name = "Wasser" + var mi := MeshInstance3D.new() + mi.name = "WasserFlaeche" + var plane := PlaneMesh.new() + plane.size = Vector2(size_x, size_z) + mi.mesh = plane + mi.position = Vector3(0.0, wasser, 0.0) + mi.material_override = make_material(wetter, ufer_bild(heights, res, wasser)) + root.add_child(mi) + return root + + +## Ufer-Tiefe-Bild (R8): Pixel = Terrainhöhe der Zelle, R-Wert = Tiefe unter +## dem Wasserlevel (0 = Wasserlinie/Ufer, 255 = tiefe_max oder tiefer). +## Identische Index-Konvention wie das Höhenfeld: (0,0) = −X/−Z-Ecke, x → +X, +## z → +Z (svmap-Vertrag). Der PlaneMesh des Wassers überdeckt denselben +## Bereich (zentriert, UV 0..1), der Shader sampelt direkt mit UV. +static func ufer_bild(heights: PackedFloat32Array, res: int, wasser: float) -> Image: + var img := Image.create(res, res, false, Image.FORMAT_R8) + for z in res: + for x in res: + var d := wasser - heights[z * res + x] # Tiefe (negativ = Land) + var v := int(round(clampf(d / TIEFE_MAX, 0.0, 1.0) * 255.0)) + img.set_pixel(x, z, Color8(v, 0, 0)) + return img + + +## Ufer-Automatik (Matsch-Ring): Faktor 0 (Land/tief) … 1 (Uferstreifen). +## Band = MATSCH_HOEHE unter bis MATSCH_UEBER über dem Wasserlevel, weich +## (smoothstep) an beiden Kanten — kein Harter Pixel-Kantensprung. +static func ufer_faktor(h: float, wasser: float) -> float: + if not is_finite(wasser): + return 0.0 + var d := wasser - h # Tiefe der Zelle (negativ = über Wasser) + var oben := clampf((d + MATSCH_UEBER) / MATSCH_UEBER, 0.0, 1.0) + if oben <= 0.0: + return 0.0 + var unten := clampf(d / MATSCH_HOEHE, 0.0, 1.0) + var f := minf(oben, 1.0 - unten * unten * (3.0 - 2.0 * unten)) + return clampf(f, 0.0, 1.0) + + +## Matsch-Ton auf eine Terrain-Zellfarbe anwenden (Client UND Editor rufen +## dieselbe Formel — Identität der Vorschau). faktor = ufer_faktor(...). +## BEWUSST nur RGB: die Alpha der Terrain-Zellfarbe trägt die gepackte +## Material-ID (m/N, Karte t_ab408127) — die darf der Matsch nie anfassen, +## sonst landen die Zellen im Splat-Shader auf dem falschen Textur-Layer. +static func matsch_anwenden(farbe: Color, faktor: float) -> Color: + var gemischt := farbe.lerp(MATSCH_FARBE, faktor * 0.75) # 0.75: Ton bleibt sichtbar + gemischt.a = farbe.a + return gemischt + + +## Wasser-Material (dasselbe im Client UND in der Editor-Vorschau — +## Identitäts-Vertrag): Custom-Shader + Wetter-Tint + Ufer-Tiefen-Textur. +## Fehlt der Shader (Frisch-Klon ohne Spiegel), fällt das Material auf ein +## simples transparentes StandardMaterial zurück — Wasser bleibt sichtbar. +static func make_material(wetter: String, ufer: Image = null) -> Material: + var preset: Dictionary = WETTER_WASSER.get(wetter, WETTER_WASSER["mittag"]) + var shader := _shader_laden() + if shader != null: + var mat := ShaderMaterial.new() + mat.shader = shader + mat.set_shader_parameter("farbe_flach", preset["farbe_flach"]) + mat.set_shader_parameter("farbe_tief", preset["farbe_tief"]) + mat.set_shader_parameter("reflex", preset["reflex"]) + mat.set_shader_parameter("glanz", float(preset["glanz"])) + if ufer != null: + mat.set_shader_parameter("ufer_tex", ImageTexture.create_from_image(ufer)) + return mat + # Fallback ohne Shader (Robustheit — Wasser bleibt sichtbar, nur ohne Wellen). + var std := StandardMaterial3D.new() + std.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + std.albedo_color = Color(preset["farbe_flach"], 0.6) + std.roughness = 0.15 + std.metallic = 0.1 + return std + + +## Shader aus den Repo-Pfaden laden (erster vorhandener gewinnt). +static func _shader_laden() -> Shader: + for pfad in SHADER_KANDIDATEN: + if ResourceLoader.exists(pfad): + var s := load(pfad) + if s is Shader: + return s + return null + + +## Höhenfeld dekodieren (R16, identische Formel wie SvmapTerrain/SvmapIO/ +## match-server — hier NOCHMAL lokal, damit dieses Spiegel-Modul von keinem +## Repo-Skript abhängt; 15 Zeilen Duplikat gegen Repo-Kopplung getauscht). +static func _decode_heights(terrain: Dictionary) -> PackedFloat32Array: + var res := int(terrain.get("resolution", 0)) + var out := PackedFloat32Array() + if res < 2: + return out + out.resize(res * res) + var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", ""))) + if raw.size() < res * res * 2: + return PackedFloat32Array() # kaputtes/fehlendes Höhenfeld → kein Wasser + var hmin := float(terrain.get("min_height_m", 0.0)) + var hmax := float(terrain.get("max_height_m", 1.0)) + if hmax <= hmin: + hmax = hmin + 1.0 + var n := mini(raw.size() / 2, res * res) + for i in n: + var v: int = raw.decode_u16(i * 2) + out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin) + return out diff --git a/library/svmap_wasser.gd.uid b/library/svmap_wasser.gd.uid new file mode 100644 index 0000000..6fa8227 --- /dev/null +++ b/library/svmap_wasser.gd.uid @@ -0,0 +1 @@ +uid://cppnjm0fhofpa diff --git a/library/terrain_arrays.gd b/library/terrain_arrays.gd new file mode 100644 index 0000000..331134a --- /dev/null +++ b/library/terrain_arrays.gd @@ -0,0 +1,221 @@ +extends Resource +class_name TerrainArrays +## Träger-Resource für die Terrain-Texture2DArrays (Karte t_ab408127). +## GLEICHER Pfad in Client UND Editor (res://library/terrain_arrays.gd) — +## die gespeicherte .res verweist auf den Script-Pfad, der muss in beiden +## Repos auflösen (Repos sind entkoppelt, therefore identischer Ort). +## +## Warum diese Klasse: ResourceSaver speichert die internen Images eines +## Texture2DArray NICHT (gespeicherte .res/.tres enthält 20× null — 20.10. +## bewiesen). Images in einer eigenen exportierten Property dagegen landen +## zuverlässig in der .res-Datei. Der Verbraucher baut beim LOAD aus diesen +## Images sofort die Texture2DArrays (create_from_images) — die Images sind +## bereits ETC2-komprimiert (Quest-3-nativ, ~1 MB/Layer statt 4 MB RGBA8), +## der Bau ist also nur ein GPU-Upload, kein Rekomprimieren. + +@export var albedo_images: Array[Image] = [] +@export var normal_images: Array[Image] = [] +@export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling) + + +# ============================ SPLAT-GEWICHTS-ABLEITUNG ====================== +# Karte t_7a0a6b79 (weiche Übergänge): der alte Shader pickte pro Pixel GENAU +# EINE Textur-Schicht über die Vertex-Alpha (floor+0.5-Snap) — an Dreieck- +# kanten sprang die GPU-interpolierte Alpha hart zwischen Material-IDs +# (blockartige, dreieckstreppige Grenzen). NEU: aus der Zell-Schicht +# (materials_r8, res²) wird eine SPLAT-GEWICHTS-TEXTUR abgeleitet — je Texel +# die Top-2-Zell-IDs mit bilinearen Kernel-Gewichten: +# R = id0 (0..N-1), G = w0, B = id1, A = w1 (w0 + w1 = 1 normalisiert) +# Der Shader sampelt die Splat-Textur (FILTER_NEAREST — IDs dürfen NICHT +# bilinear vermischt werden; die Weichheit steckt IN den Gewichten) und mischt +# die zwei Layer: mix(tex(id1), tex(id0), w0). Datenformat UNVERÄNDERT — die +# Zell-Schicht bleibt die Quelle der Wahrheit. +# +# Texel-Dichte: SPLAT_TEXELS_PER_CELL Texel je Terrain-Zelle (4 × res−1) UND +# mindestens SPLAT_TARGET_M_PER_TEXEL Meter je Texel (Karte t_5c94d098 — +# Messung: 8 m/Zelle × 4 = 2 m/Texel bei der 1024-m-Standardmap ergab eine +# in 2-m-Stufen gerasterte Gewichtsrampe, sichtbar als Bänder/Stufen quer zur +# Materialgrenze), als Zweierpotenz geklemmt auf [SPLAT_SIDE_MIN, SPLAT_SIDE_MAX] +# — beim Malen kann dann ein kleiner Sub-Rect neu abgeleitet werden (Live-Pfad). +const SPLAT_TEXELS_PER_CELL := 4 +const SPLAT_SIDE_MIN := 256 +const SPLAT_SIDE_MAX := 1024 +const SPLAT_TARGET_M_PER_TEXEL := 1.0 + + +## Splat-Textur-Kantengröße für ein res×res-Zellraster (Zweierpotenz). +## map_size_m = Kantenlänge der Map in Weltmetern (optional, 0 = nicht gegeben): +## die Karten-GRÖSSE begrenzt die erreichbare Weichheit, nicht die Zellzahl — +## dieselbe Zellzahl liefert auf einer großen Map viel gröbere Texel. +## Ergebnis (Budget-geklemmt, 4 MB RGBA8 bei 1024²): 1024 m → 1024 (1 m/Texel), +## 256 m → 512 (0,5 m/Texel), 4096 m → 1024 (4 m/Texel, Klemme). +static func splat_side_for(res: int, map_size_m: float = 0.0) -> int: + var target := maxi(1, res - 1) * SPLAT_TEXELS_PER_CELL + if map_size_m > 0.0: + target = maxi(target, int(ceilf(map_size_m / SPLAT_TARGET_M_PER_TEXEL))) + var side := SPLAT_SIDE_MIN + while side < target: + side *= 2 + return mini(side, SPLAT_SIDE_MAX) + + +## Splat-Gewichts-Bytes (RGBA8, row-major) für die Zell-Schicht ableiten. +## rect = Texel-Rechteck (position/size im Bild) — leer = ganzes Bild. +## Bilinearer Kernel über die 4 umliegenden Zellen: an einer Zellgrenze rampen +## die Gewichte weich 0→1 (Weichheit = eine Zellbreite, wie in der Spec). +## Test-Vertrag: identische Formel in Client UND Editor (Spiegel-Datei). +static func build_splat_bytes(mats: PackedByteArray, res: int, side: int, + rect: Rect2i = Rect2i()) -> PackedByteArray: + var out := PackedByteArray() + if res < 2 or side < 1 or mats.size() < res * res: + return out + var rx := 0 + var ry := 0 + var rw := side + var rh := side + if rect.size.x > 0 and rect.size.y > 0: + rx = clampi(rect.position.x, 0, side - 1) + ry = clampi(rect.position.y, 0, side - 1) + rw = clampi(rect.size.x, 1, side - rx) + rh = clampi(rect.size.y, 1, side - ry) + out.resize(rw * rh * 4) + var last := float(res - 1) + # Achsen-Gewichte einmal vorrechnen (hängen nur von der Texel-Spalte/Zeile ab): + var ix0 := PackedInt32Array() + var ix1 := PackedInt32Array() + var fx := PackedFloat32Array() + ix0.resize(rw) + ix1.resize(rw) + fx.resize(rw) + for i in rw: + var t := (float(rx + i) + 0.5) / float(side) * last + var i0 := clampi(int(floorf(t)), 0, res - 1) + ix0[i] = i0 + ix1[i] = mini(i0 + 1, res - 1) + fx[i] = t - float(i0) + var iz0 := PackedInt32Array() + var iz1 := PackedInt32Array() + var fz := PackedFloat32Array() + iz0.resize(rh) + iz1.resize(rh) + fz.resize(rh) + for j in rh: + var t := (float(ry + j) + 0.5) / float(side) * last + var j0 := clampi(int(floorf(t)), 0, res - 1) + iz0[j] = j0 + iz1[j] = mini(j0 + 1, res - 1) + fz[j] = t - float(j0) + var ofs := 0 + # Wiederverwendete Kandidaten-Slots: die Ableitung läuft bei 1024² über + # 1 Mio. Texel — Allokationen je Texel (alte Fassung: 2 Packed-Arrays + + # Array-Literale) kosteten messbar Zeit (Karte t_5c94d098). + var cids := PackedInt32Array() + var cws := PackedFloat32Array() + var mids := PackedInt32Array() + var mws := PackedFloat32Array() + cids.resize(4) + cws.resize(4) + mids.resize(4) + mws.resize(4) + for j in rh: + var wz0 := 1.0 - fz[j] + var wz1 := fz[j] + var row0 := iz0[j] * res + var row1 := iz1[j] * res + for i in rw: + var wx0 := 1.0 - fx[i] + var wx1 := fx[i] + # Die vier Nachbarzellen (bilineare Gewichte) — Reihenfolge identisch + # zur Vorversion (Referenz-Checks in tests/test_terrain_splat.gd). + cids[0] = int(mats[row0 + ix0[i]]) + cids[1] = int(mats[row0 + ix1[i]]) + cids[2] = int(mats[row1 + ix0[i]]) + cids[3] = int(mats[row1 + ix1[i]]) + cws[0] = wx0 * wz0 + cws[1] = wx1 * wz0 + cws[2] = wx0 * wz1 + cws[3] = wx1 * wz1 + var a0 := cids[0] + if a0 == cids[1] and a0 == cids[2] and a0 == cids[3]: + # SCHNELLPFAD (Loch im Material, der Regelfall): alle vier Zellen + # dieselbe ID → w0 = 1, keine Mischung. Überspringt das + # Zusammenziehen der Gewichte (innerhalb einer Materialfläche + # praktisch jeder Texel, macht die 1024²-Ableitung bezahlbar). + out.encode_u8(ofs, a0) + out.encode_u8(ofs + 1, 255) + out.encode_u8(ofs + 2, a0) + out.encode_u8(ofs + 3, 0) + ofs += 4 + continue + # Duplikat-IDs zusammenziehen (Gewichte aufsummieren): + var n := 0 + for c in 4: + var w := cws[c] + if w <= 0.0: + continue + var cid := cids[c] + var found := false + for k in n: + if mids[k] == cid: + mws[k] += w + found = true + break + if not found: + mids[n] = cid + mws[n] = w + n += 1 + if n == 0: + # Kann rechnerisch nicht auftreten (die 4 Gewichte summieren zu 1); + # defensiv: Texel = Layer 0 mit vollem Gewicht. + out.encode_u8(ofs, 0) + out.encode_u8(ofs + 1, 255) + out.encode_u8(ofs + 2, 0) + out.encode_u8(ofs + 3, 0) + ofs += 4 + continue + # Top-2 IDs nach Gewicht (Fenster von max. 4 Kandidaten): + var id0 := mids[0] + var w0 := mws[0] + var id1 := id0 + var w1 := 0.0 + for k in range(1, n): + if mws[k] > w0: + id1 = id0 + w1 = w0 + id0 = mids[k] + w0 = mws[k] + elif mws[k] > w1 or id1 == id0: + id1 = mids[k] + w1 = mws[k] + # Normalisieren (w0 + w1 = 1) und 8-bit-packen: + var wsum := maxf(w0 + w1, 0.0001) + var b0 := clampi(roundi(w0 / wsum * 255.0), 0, 255) + out.encode_u8(ofs, clampi(id0, 0, 255)) + out.encode_u8(ofs + 1, b0) + out.encode_u8(ofs + 2, clampi(id1, 0, 255)) + out.encode_u8(ofs + 3, 255 - b0) + ofs += 4 + return out + + +## Ein Splat-Texel dekodieren (für Tests): → [id0, w0, id1, w1] (w0 ∈ 0..1). +static func decode_splat_texel(bytes: PackedByteArray, texel_ofs: int) -> Array: + return [ + int(bytes.decode_u8(texel_ofs)), + float(bytes.decode_u8(texel_ofs + 1)) / 255.0, + int(bytes.decode_u8(texel_ofs + 2)), + float(bytes.decode_u8(texel_ofs + 3)) / 255.0, + ] + + +## Material-ID-Karte für den Shader (Karte 25.10., weiche Übergänge pro Pixel): +## R8, ein Texel je Materialpunkt (mres²) — der Shader liest per texelFetch die +## 4 umliegenden IDs und mischt bilinear. Ersetzt im Rendering die Splat- +## Ableitung (build_splat_bytes bleibt für Probes/Tests). Unpassende Schicht → +## 2×2 alles Gras (Shader bleibt nie ohne Textur). +static func material_image(mats: PackedByteArray, mres: int) -> Image: + if mres < 2 or mats.size() != mres * mres: + var leer := PackedByteArray() + leer.resize(4) + return Image.create_from_data(2, 2, false, Image.FORMAT_R8, leer) + return Image.create_from_data(mres, mres, false, Image.FORMAT_R8, mats) diff --git a/library/terrain_arrays.gd.uid b/library/terrain_arrays.gd.uid new file mode 100644 index 0000000..2c9b50f --- /dev/null +++ b/library/terrain_arrays.gd.uid @@ -0,0 +1 @@ +uid://c5v7hht4apubd diff --git a/svmap/group_state.gd b/svmap/group_state.gd new file mode 100644 index 0000000..e6c2d01 --- /dev/null +++ b/svmap/group_state.gd @@ -0,0 +1,111 @@ +extends RefCounted +class_name GroupState +## Gruppen-ZUSTAND über das Objekt-Feld "group" hinaus (Karte t_a4bf9b8c, U3 — +## Gruppen im Szenen-Baum). GroupOps (src/editor/group_ops.gd) besitzt die +## Zugehörigkeit (Optionales Objekt-Feld "group", int ≥ 0) — diese Datei ergänzt +## NUR ADDITIV, was der Baum additionally braucht: +## +## 1. GRUPPENNAMEN: doc["group_names"] = { "": "Lager", ... } — optionaler +## Ober-Block. Alte Maps ohne diesen Key laden unverändert (V4 prüft nur +## Pflichtblöcke, unbekannte Ober-Keys wandern 1:1 durch _canonicalize in +## SvmapIO und in die sha256-Prüfsumme — gleiche Begründung wie beim +## "group"-Feld in GroupOps). Eintrag fehlt ⇒ Anzeige-Fallback "Gruppe N". +## 2. DRAG-MITGLIDSCHAFT: Objekt in Gruppe ziehen (assign) / heraus ziehen +## (clear für EINZELNE Indizes — GroupOps.ungroup löst dagegen die GANZE +## Gruppe, was beim Drag-out nur eines Objekts nicht gewollt ist). +## +## Bewusst KEINE eigene Undo-Logik: der Aufrufer macht snapshot/commit über +## ActionHistory (["objects"] bzw. ["group_names"]) — jede Aktion bleibt EIN +## Undo-Schritt. + +const NAMES_KEY := "group_names" + + +## Rohe Namen-Tabelle (leer, wenn der Key fehlt — alte Maps). +static func names_dict(doc: Dictionary) -> Dictionary: + var raw: Variant = doc.get(NAMES_KEY, {}) + return raw if raw is Dictionary else {} + + +## Gespeicherter Name einer Gruppe ("" = keiner gesetzt). +static func group_name(doc: Dictionary, gid: int) -> String: + var names := names_dict(doc) + var value: Variant = names.get(str(gid), "") + return str(value) if value is String else "" + + +## Anzeige-Name im Baum: gespeicherter Name oder Fallback "Gruppe N" +## (N = gid + 1 — gleiche Zählung wie in interaction.gd/selection_panel.gd). +static func display_name(doc: Dictionary, gid: int) -> String: + var name := group_name(doc, gid) + return name if not name.is_empty() else ("Gruppe %d" % (gid + 1)) + + +## Namen setzen ("" oder nur Leerzeichen = Eintrag entfernen → Fallback). +## Rückgabe: true, wenn sich am Dokument etwas geändert hat. +static func set_group_name(doc: Dictionary, gid: int, new_name: String) -> bool: + var clean := new_name.strip_edges() + var names := names_dict(doc) + if clean.is_empty(): + if not names.has(str(gid)): + return false + (doc[NAMES_KEY] as Dictionary).erase(str(gid)) + return true + if names.get(str(gid), "") == clean: + return false + if not doc.has(NAMES_KEY) or not (doc[NAMES_KEY] is Dictionary): + doc[NAMES_KEY] = {} + (doc[NAMES_KEY] as Dictionary)[str(gid)] = clean + return true + + +## Namen-Einträge ohne Mitglieder entfernen (Aufräumen nach Löschen/Lösen — +## vom Aufrufer IM SELBEN Undo-Schritt mit-committet). +static func prune_orphans(doc: Dictionary) -> int: + var active := {} + for obj in (doc.get("objects", []) as Array): + var g := GroupOps.group_id(obj) + if g != GroupOps.UNGROUPED: + active[g] = true + var names := names_dict(doc) + var removed := 0 + for key in names.keys(): + if not active.has(int(key)): + names.erase(key) + removed += 1 + if removed > 0: + if (names as Dictionary).is_empty(): + doc.erase(NAMES_KEY) # sauberes Format: leerer Block fliegt raus + elif doc.has(NAMES_KEY): + doc[NAMES_KEY] = names + return removed + + +## Drag IN eine Gruppe: Objekt-Feld "group" auf die Ziel-ID setzen (Objekte, +## die schon diese ID tragen, zählen nicht als Änderung). Nur die GEBENEN +## Indizes — die restliche Gruppe bleibt unberührt. Rückgabe: geänderte Anzahl. +static func assign_group(doc: Dictionary, indices: Array, gid: int) -> int: + if gid < 0: + return 0 + var objects: Array = doc.get("objects", []) + var changed := 0 + for i in indices: + if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary): + if GroupOps.group_id(objects[i]) != gid: + objects[i]["group"] = gid + changed += 1 + return changed + + +## Drag AUS der Gruppe: "group"-Feld NUR der gegebenen Indizes entfernen +## (die Gruppe selbst bleibt mit ihren übrigen Mitgliedern bestehen). +## Rückgabe: geänderte Anzahl. +static func clear_group_fields(doc: Dictionary, indices: Array) -> int: + var objects: Array = doc.get("objects", []) + var changed := 0 + for i in indices: + if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary): + if GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED: + (objects[i] as Dictionary).erase("group") + changed += 1 + return changed diff --git a/svmap/group_state.gd.uid b/svmap/group_state.gd.uid new file mode 100644 index 0000000..84d92fe --- /dev/null +++ b/svmap/group_state.gd.uid @@ -0,0 +1 @@ +uid://b13m4gw07qdsf diff --git a/svmap/kollider_bau.gd b/svmap/kollider_bau.gd new file mode 100644 index 0000000..ad62da0 --- /dev/null +++ b/svmap/kollider_bau.gd @@ -0,0 +1,294 @@ +extends RefCounted +class_name KolliderBau +## Kollider-Bau 1:1 aus dem Quest-3-Client gespiegelt (Karte t_1da96275 — +## „Editor-Playtest-Physik = Quest 3 identisch"). +## +## Dieser Modul ist der SPIEGEL von client/scripts/world/svmap_world.gd +## (_collider/_primitiv_form/_trimesh_aus_datei/_half_height + Terrain- +## Trimesh). Die Playtest-Physik im Editor benutzt EXAKT dieselben Formen +## wie der Quest-Client: box/kugel/zylinder/verbund, Trimesh (inline oder +## aus library/kollisions/.json), Terrain als Trimesh des Höhenfelds. +## +## Regel: Ändert der Client den Kollider-Bau, wird dieser Spiegel MIT +## geändert (Karte verweist auf svmap_world.gd als Quelle der Wahrheit). +## Bewusst KEINE Eigenlogik hier — jede Abweichung wäre eine Abweichung +## vom Quest-3-Verhalten, die Sly ausdrücklich abgelehnt hat. + +const DEFAULT_MANIFEST_PATH := "res://library/objects.json" + +## Trimesh-Quelldateien (begehbare Gebäude): Cache über alle Exemplare — +## mehrere Platzierungen derselben OID teilen sich die Vertices (wie im Client). +static var _trimesh_datei_cache := {} + + +## Bequemer Einstieg: Manifest selbst laden (leer → Objekte ohne Eintrag +## werden übersprungen — die Editor-Validierung hätte sie abgelehnt). +## Spiegel von SvmapWorld.load_manifest(). +static func lade_manifest() -> Dictionary: + if not FileAccess.file_exists(DEFAULT_MANIFEST_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(DEFAULT_MANIFEST_PATH)) + return parsed if parsed is Dictionary else {} + + +## Baut ALLE Welt-Kollider aus einem svmap-Dokument: Terrain-Trimesh + +## je Objekt ein StaticBody3D (Form aus der Registry, Quelle-Dateien aus +## library/kollisions). Gibt die Wurzel zurück (Caller hängt sie ein und +## gibt sie mit frei). Spiegel von SvmapWorld._build (nur Kollider, ohne +## Visuals — die sieht der Editor bereits durch den view_builder). +static func baue_welt_kollider(data: Dictionary, manifest: Dictionary = {}) -> Node3D: + var root := Node3D.new() + root.name = "PlaytestKollider" + var lib: Dictionary = {} + if manifest.is_empty(): + manifest = lade_manifest() + lib = manifest.get("objects", {}) + var terrain: Dictionary = data.get("terrain", {}) + if not terrain.is_empty(): + var tform := terrain_form(data) + if tform != null: + var tbody := StaticBody3D.new() + tbody.name = "TerrainKollider" + var tcs := CollisionShape3D.new() + tcs.shape = tform + tbody.add_child(tcs) + root.add_child(tbody) + var objects: Array = data.get("objects", []) + var i := 0 + for obj in objects: + i += 1 + if not (obj is Dictionary): + continue + var oid := str(obj.get("id", "")) + var entry: Dictionary = lib.get(oid, {}) + if entry.is_empty(): + continue # Editor-Validierung hätte das abgelehnt — defensiv. + var col_spec: Dictionary = entry.get("collision", {}) + var scale := float(obj.get("scale", 1.0)) + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + var marker := Node3D.new() + marker.name = "Kollider_%d_%s" % [i, oid] + marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + # Karte t_e166e656: 3-Achsen-Rotation — `rot` (Euler xyz) oder Fallback + # rot_y_deg, identische Regel wie im Client/Server (SvmapIO.objekt_rotation). + marker.rotation_degrees = SvmapIO.objekt_rotation(obj) + marker.add_child(kollider(col_spec, scale)) + root.add_child(marker) + return root + + +## --------------------------------------------------------------------- -- +## Ab hier 1:1-SPIEGEL von svmap_world.gd (Client) — Funktionsweise, Reihen- +## folge und Toleranzen identisch, nur statisch gebunden. +## --------------------------------------------------------------------- -- + +## Spiegel von SvmapWorld._collider(): compound = mehrere Teile mit eigenem +## Versatz/Drehung, "none" = kein Kollider, sonst eine Form am Fußpunkt. +static func kollider(col_spec: Dictionary, scale: float) -> StaticBody3D: + var body := StaticBody3D.new() + # Rein dekorativ ("none"): kein Kollider. + if str(col_spec.get("shape", "box")) == "none": + return body + if str(col_spec.get("shape", "box")) == "compound": + for teil_v in col_spec.get("parts", []): + var teil: Dictionary = teil_v if teil_v is Dictionary else {} + var cs := CollisionShape3D.new() + cs.shape = primitiv_form(teil, scale) + var off: Array = teil.get("pos", [0.0, 0.0, 0.0]) + cs.position = Vector3(float(off[0]), float(off[1]), float(off[2])) * scale + var rot: Array = teil.get("rot_deg", [0.0, 0.0, 0.0]) + cs.rotation_degrees = Vector3(float(rot[0]), float(rot[1]), float(rot[2])) + body.add_child(cs) + return body + var cs2 := CollisionShape3D.new() + var form := primitiv_form(col_spec, scale) + if form == null: + return body # Ungültige Kollisions-Spec (z. B. defektes Trimesh): kein Kollider. + cs2.shape = form + cs2.position.y = halbe_hoehe(cs2.shape) # pos = Objekt-Fußpunkt (Server-Konvention) + body.add_child(cs2) + return body + + +## Spiegel von SvmapWorld._primitiv_form(): box/sphere/cylinder/trimesh. +## Trimesh: inline-vertices oder collision.quelle aus library/kollisions/, +## × scale, backface AN (begehbar + beschußbar von beiden Seiten). +## Defekte Specs → null (kein Kollider). +static func primitiv_form(col_spec: Dictionary, scale: float) -> Shape3D: + match str(col_spec.get("shape", "box")): + "sphere": + var s := SphereShape3D.new() + s.radius = float(col_spec.get("radius", 1.0)) * scale + return s + "cylinder": + var s := CylinderShape3D.new() + s.radius = float(col_spec.get("radius", 1.0)) * scale + s.height = float(col_spec.get("height", 1.0)) * scale + return s + "trimesh": + var verts_v: Variant = col_spec.get("vertices", null) + if verts_v == null and col_spec.has("quelle"): + verts_v = trimesh_aus_datei(str(col_spec["quelle"])) + if not (verts_v is Array): + return null + var verts: Array = verts_v + if verts.is_empty() or verts.size() % 3 != 0: + return null + var faces := PackedVector3Array() + faces.resize(verts.size()) + var fi := 0 + for v in verts: + if not (v is Array) or (v as Array).size() != 3 \ + or not (v[0] is float or v[0] is int) \ + or not (v[1] is float or v[1] is int) \ + or not (v[2] is float or v[2] is int): + return null + faces[fi] = Vector3(float(v[0]), float(v[1]), float(v[2])) * scale + fi += 1 + var s := ConcavePolygonShape3D.new() + s.set_faces(faces) + s.backface_collision = true + return s + _: + var s := BoxShape3D.new() + var size: Array = col_spec.get("size", [1.0, 1.0, 1.0]) + s.size = Vector3(float(size[0]), float(size[1]), float(size[2])) * scale + return s + + +## Spiegel von SvmapWorld._trimesh_aus_datei(): Quelle-Datei relativ zu +## res://library/, JSON {"vertices": [[x,y,z], ...]}. +static func trimesh_aus_datei(quelle: String) -> Variant: + if quelle.is_empty(): + return null + if _trimesh_datei_cache.has(quelle): + return _trimesh_datei_cache[quelle] + var fa := FileAccess.open("res://library/" + quelle, FileAccess.READ) + if fa == null: + _trimesh_datei_cache[quelle] = null + return null + var parsed: Variant = JSON.parse_string(fa.get_as_text()) + var verts: Variant = null + if parsed is Dictionary and (parsed as Dictionary).get("vertices", null) is Array: + verts = (parsed as Dictionary)["vertices"] + _trimesh_datei_cache[quelle] = verts + return verts + + +## Spiegel des Terrain-Kolliders aus SvmapWorld._build_terrain(): DASSELBE +## Mesh (gleiche Platzierungs-Konvention (0,0) = (−size/2, −size/2), gleiche +## Dreiecks-Wicklung, generate_normals) → create_trimesh_shape(). Nur der +## Kollider, kein MeshInstance (den rendered der Editor bereits). +static func terrain_form(data: Dictionary) -> ConcavePolygonShape3D: + var terrain: Dictionary = data.get("terrain", {}) + var res := int(terrain.get("resolution", 0)) + var size_x := float(terrain.get("size_x_m", 0.0)) + var size_z := float(terrain.get("size_z_m", 0.0)) + var heights := SvmapIO.decode_heights(terrain) + if res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0: + return null + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + st.set_smooth_group(-1) # wie Client (einflussfrei auf die Geometrie) + var last := float(res - 1) + var zi := 0 + while zi < res: + var xi := 0 + while xi < res: + # Platzierungs-Konvention (TerrainField.gd, map_document, + # HeightMapShape3D): Index (0,0) liegt bei (−size/2, −size/2). + var idx := zi * res + xi + st.add_vertex(Vector3( + float(xi) / last * size_x - size_x * 0.5, + heights[idx], + float(zi) / last * size_z - size_z * 0.5)) + xi += 1 + zi += 1 + # Dreiecke so gewickelt, dass die Normalen nach OBEN zeigen — exakt die + # Client-Wicklung ((a, d, c) + (a, b, d), Godot-Front-Faces = clockwise). + var zi2 := 0 + while zi2 < res - 1: + var xi2 := 0 + while xi2 < res - 1: + var a := zi2 * res + xi2 + var b := zi2 * res + xi2 + 1 + var c := (zi2 + 1) * res + xi2 + var d := (zi2 + 1) * res + xi2 + 1 + st.add_index(a) + st.add_index(d) + st.add_index(c) + st.add_index(a) + st.add_index(b) + st.add_index(d) + xi2 += 1 + zi2 += 1 + st.generate_normals() + var mesh := st.commit() + return mesh.create_trimesh_shape() as ConcavePolygonShape3D + + +## Spiegel von SvmapWorld._half_height(). +static func halbe_hoehe(shape: Shape3D) -> float: + if shape is BoxShape3D: + return (shape as BoxShape3D).size.y * 0.5 + if shape is CylinderShape3D: + return (shape as CylinderShape3D).height * 0.5 + if shape is SphereShape3D: + return (shape as SphereShape3D).radius + return 0.0 + + +## Konservative Hindernis-AABB einer GEDREHTEN Box (Karte t_e166e656): alle +## 8 lokalen Ecken der Kollisions-Box × Basis der Objekt-Rotation, dann die +## umschließende AABB. Identische Formel wie Server (svmap_loader) und Client +## (svmap_world) — Identitäts-Vertrag der Playtest-Hindernisboxen. +## +## ALLES Trimesh (t_ff7fc919): bei Trimesh kommt die LOKALE AABB aus der +## Dreiecks-Suppe (Quelle-Datei oder inline), nicht aus collision.size — +## size bleibt nur Merge-/Platzhalter-Info. Defekte/fehlende Suppe → +## Fallback auf die Mess-AABB aus size (identisch zum Client/Server-Mirror). +static func hindernis_aabb(obj: Dictionary, col: Dictionary, scale: float) -> AABB: + var size: Array = col.get("size", [1.0, 1.0, 1.0]) + var half_ext := Vector3(float(size[0]), float(size[1]), float(size[2])) * scale * 0.5 + # Lokale AABB in Fußpunkt-Koordinaten: Box = zentriert über dem Fußpunkt + # (x/z symmetrisch, y von 0 bis Höhe) — exakt die Placement-Konvention + # der CollisionShape (position.y = halbe Höhe). + var mn := Vector3(-half_ext.x, 0.0, -half_ext.z) + var mx := Vector3(half_ext.x, half_ext.y * 2.0, half_ext.z) + if str(col.get("shape", "box")) == "trimesh": + var suppe: Array = _hindernis_suppe(col) + if suppe.size() >= 3 and suppe.size() % 3 == 0: + mn = Vector3(INF, INF, INF) + mx = -Vector3(INF, INF, INF) + for v in suppe: + mn = mn.min(Vector3(float(v[0]), float(v[1]), float(v[2])) * scale) + mx = mx.max(Vector3(float(v[0]), float(v[1]), float(v[2])) * scale) + if mx.x < mn.x: # numerisch leer (nicht-finite Werte) → Fallback + mn = Vector3(-half_ext.x, 0.0, -half_ext.z) + mx = Vector3(half_ext.x, half_ext.y * 2.0, half_ext.z) + var rot := SvmapIO.objekt_rotation(obj) + var basis := Basis.from_euler(Vector3(deg_to_rad(rot.x), deg_to_rad(rot.y), deg_to_rad(rot.z))) + # Zentriert über dem Fußpunkt: Lokale Mitte × Basis + pos (bei der Box ist + # die lokale Mitte (0, h/2, 0) — bitidentisch zur alten 8-Ecken-Formel). + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + var center := basis * ((mn + mx) * 0.5) \ + + Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + var half := (mx - mn) * 0.5 + var out := AABB(center + basis * -half, Vector3.ZERO) + for sx in [-half.x, half.x]: + for sy in [-half.y, half.y]: + for sz in [-half.z, half.z]: + out = out.expand(center + basis * Vector3(sx, sy, sz)) + return out + + +## Suppe für die Hindernis-AABB (Trimesh): inline-vertices oder aus der +## Quelle-Datei (trimesh_aus_datei, static Cache). Leer bei nicht-Trimesh / +## fehlender Datei — der Caller fällt auf die Mess-AABB aus. +static func _hindernis_suppe(col: Dictionary) -> Array: + if str(col.get("shape", "box")) != "trimesh": + return [] + var verts_v: Variant = col.get("vertices", null) + if verts_v == null and col.has("quelle"): + verts_v = trimesh_aus_datei(str(col["quelle"])) + return verts_v if verts_v is Array else [] diff --git a/svmap/kollider_bau.gd.uid b/svmap/kollider_bau.gd.uid new file mode 100644 index 0000000..d7b9df3 --- /dev/null +++ b/svmap/kollider_bau.gd.uid @@ -0,0 +1 @@ +uid://cncg002d4e5x7 diff --git a/svmap/loadout_katalog.gd b/svmap/loadout_katalog.gd new file mode 100644 index 0000000..d106c6d --- /dev/null +++ b/svmap/loadout_katalog.gd @@ -0,0 +1,201 @@ +class_name LoadoutKatalog +extends RefCounted +## Map-Loadout (Karte „Loadout pro Map"): Waffen-/Aufsatz-Katalog des Editors +## und die Regeln für den optionalen `loadout`-Block der .svmap (V18, 1:1 wie +## MapLoadout im Match-Server, docs/map_format.md). +## +## doc["loadout"] fehlt oder mode "player" = Spieler-Loadout aus der Lobby. +## mode "fixed" = {primary, secondary ("" = keine), attachments:{waffe: "slot=teil;…"}} +## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client). +## In beiden Modi optional: grenades:{id: anzahl} und mines:anzahl = Vorrat pro +## Leben für Spieler und Bots; fehlen sie, gilt der Standard des Servers. +## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py). + +const PATH := "res://library/loadout_katalog.json" +const MODE_FIXED := "fixed" +const MODE_PLAYER := "player" + +## Deutsche Namen der Visiere mit eigener Funktion; alles andere zeigt die Teil-ID. +const TEIL_NAMEN := { + "sight_collimator": "Rotpunktvisier", + "sight_holographic": "Holo-Visier (Ring mit Punkt)", + "sight_offset": "Schrägvisier (seitlich, Punkt)", +} + +## Granatenarten des Servers (library/grenades.json); die ersten drei stellt das Panel zur Wahl. +const GRANATEN := { + "he1": "Splittergranate", + "blend1": "Blendgranate", + "smoke1": "Rauchgranate", + "he2": "Splittergranate leicht", + "blend2": "Blendgranate kurz", + "smoke2": "Nebelgranate", + "bomb": "Bombe", +} +const GRANATEN_PANEL: Array[String] = ["he1", "blend1", "smoke1"] +const MAX_GRANATEN_JE_ART := 5 +const MAX_MINEN := 3 # Spiegel zu MineSystem.MAX_ACTIVE_PER_OWNER +const STANDARD_MINEN := 2 # Spiegel zu MineSystem.START_STOCK + +static var _data: Dictionary = {} +static var _loaded := false + + +static func _ensure() -> void: + if _loaded: + return + _loaded = true + var f := FileAccess.open(PATH, FileAccess.READ) + if f == null: + push_warning("LoadoutKatalog: %s fehlt — Loadout-Auswahl bleibt leer" % PATH) + return + var parsed: Variant = JSON.parse_string(f.get_as_text()) + if parsed is Dictionary: + _data = (parsed as Dictionary).get("waffen", {}) + + +## Alle Waffen-IDs, nach Anzeigename sortiert. +static func waffen_ids() -> Array[String]: + _ensure() + var ids: Array[String] = [] + for k in _data.keys(): + ids.append(str(k)) + ids.sort_custom(func(a: String, b: String) -> bool: + return anzeigename(a).naturalnocasecmp_to(anzeigename(b)) < 0) + return ids + + +static func hat_waffe(id: String) -> bool: + _ensure() + return _data.has(id) + + +static func anzeigename(id: String) -> String: + _ensure() + return str((_data.get(id, {}) as Dictionary).get("name", id)) + + +static func teil_name(part: String) -> String: + return "— nichts —" if part.is_empty() else str(TEIL_NAMEN.get(part, part)) + + +## Aufsatz-Slots einer Waffe: {slot: {default, optional, options}} (leer = keine). +static func slots(id: String) -> Dictionary: + _ensure() + return (_data.get(id, {}) as Dictionary).get("slots", {}) + + +static func decode(text: String) -> Dictionary: + var out := {} + for chunk in text.split(";", false): + var kv := chunk.split("=", true, 1) + if kv.size() == 2 and not kv[0].is_empty(): + out[kv[0]] = kv[1] + return out + + +## Auswahl {slot: teil} → Draht-Text, nur Abweichungen vom Standard, Slots sortiert. +static func encode(id: String, sel: Dictionary) -> String: + var sl := slots(id) + var keys: Array = sel.keys() + keys.sort() + var parts: PackedStringArray = [] + for s in keys: + if sl.has(s) and str(sel[s]) != str((sl[s] as Dictionary).get("default", "")): + parts.append("%s=%s" % [s, sel[s]]) + return ";".join(parts) + + +## Aktuelle Teil-Wahl je Slot (Standard, überlagert vom gespeicherten Text). +static func auswahl(id: String, wire: String) -> Dictionary: + var out := {} + var sl := slots(id) + var dec := decode(wire) + for s in sl.keys(): + out[s] = str(dec.get(s, (sl[s] as Dictionary).get("default", ""))) + return out + + +## Grund, warum der Aufsatz-Text für die Waffe ungültig ist ("" = ok). +static func pruefe_aufsaetze(id: String, wire: String) -> String: + if wire.is_empty(): + return "" + var sl := slots(id) + if sl.is_empty(): + return "Waffe '%s' hat keine Aufsätze" % id + var dec := decode(wire) + if dec.is_empty(): + return "Aufsatz-Text unlesbar" + for s in dec.keys(): + if not sl.has(s): + return "Slot '%s' gibt es an '%s' nicht" % [s, id] + var p := str(dec[s]) + var sd: Dictionary = sl[s] + if p.is_empty(): + if not bool(sd.get("optional", false)): + return "Slot '%s' darf nicht leer sein" % s + elif not (sd.get("options", []) as Array).has(p): + return "Teil '%s' passt nicht an Slot '%s' von '%s'" % [p, s, id] + return "" + + +## V18-Fehlerliste (leer = gültig oder nicht vorhanden). +static func validate(lo: Variant) -> PackedStringArray: + var errors := PackedStringArray() + if lo == null: + return errors + if not (lo is Dictionary): + errors.append("V18: loadout muss ein Objekt sein.") + return errors + var d: Dictionary = lo + var mode := str(d.get("mode", MODE_PLAYER)) + if mode != MODE_FIXED and mode != MODE_PLAYER: + errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode) + return errors + errors.append_array(_pruefe_vorrat(d)) + if mode == MODE_PLAYER: + return errors + var prim := str(d.get("primary", "")).strip_edges().to_lower() + var sec := str(d.get("secondary", "")).strip_edges().to_lower() + if prim.is_empty(): + errors.append("V18: loadout.primary fehlt (Modus 'fixed').") + elif not hat_waffe(prim): + errors.append("V18: loadout.primary '%s' ist keine bekannte Waffe." % prim) + if not sec.is_empty() and not hat_waffe(sec): + errors.append("V18: loadout.secondary '%s' ist keine bekannte Waffe." % sec) + var att: Variant = d.get("attachments", {}) + if not (att is Dictionary): + errors.append("V18: loadout.attachments muss ein Objekt sein.") + return errors + for wid in (att as Dictionary).keys(): + var w := str(wid).strip_edges().to_lower() + if w != prim and w != sec: + errors.append("V18: loadout.attachments['%s'] gehört zu keiner Loadout-Waffe." % w) + continue + var why := pruefe_aufsaetze(w, str(att[wid])) + if why != "": + errors.append("V18: loadout.attachments['%s']: %s." % [w, why]) + return errors + + +static func _pruefe_vorrat(d: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if d.has("grenades"): + var g: Variant = d["grenades"] + if not (g is Dictionary): + errors.append("V18: loadout.grenades muss ein Objekt sein.") + else: + for gid in (g as Dictionary).keys(): + if not GRANATEN.has(str(gid)): + errors.append("V18: loadout.grenades['%s'] ist keine bekannte Granate." % str(gid)) + elif not _ist_anzahl(g[gid], MAX_GRANATEN_JE_ART): + errors.append("V18: loadout.grenades['%s'] muss eine ganze Zahl von 0 bis %d sein." % [str(gid), MAX_GRANATEN_JE_ART]) + if d.has("mines") and not _ist_anzahl(d["mines"], MAX_MINEN): + errors.append("V18: loadout.mines muss eine ganze Zahl von 0 bis %d sein." % MAX_MINEN) + return errors + + +static func _ist_anzahl(v: Variant, max_n: int) -> bool: + if not (v is int or v is float): + return false + return float(v) == floorf(float(v)) and float(v) >= 0.0 and float(v) <= float(max_n) diff --git a/svmap/loadout_katalog.gd.uid b/svmap/loadout_katalog.gd.uid new file mode 100644 index 0000000..1df16fa --- /dev/null +++ b/svmap/loadout_katalog.gd.uid @@ -0,0 +1 @@ +uid://jm1cubdr25s8 diff --git a/svmap/map_document.gd b/svmap/map_document.gd new file mode 100644 index 0000000..f8b427e --- /dev/null +++ b/svmap/map_document.gd @@ -0,0 +1,470 @@ +extends RefCounted +class_name SvmapDocument +## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format (v2, +## docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat → +## kein Konverter, Speichern/Laden direkt auf .svmap (README Architektur). +## +## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd. + +const DEFAULT_RESOLUTION := 129 # Editor-MVP-Perf-Limit (Karte t_9aba04f1); + # das Format erlaubt 17–513, der Editor formt im MVP nur bis 129 — + # neue Maps erfüllen das Limit dadurch automatisch. +const DEFAULT_MIN_HEIGHT := 0.0 +const DEFAULT_MAX_HEIGHT := 50.0 +const DEFAULT_TARGET_GAME_VERSION := "0.1.0" +const DEFAULT_AUTHOR := "ScopedVR Creator" + + +## Neue, leere Map erzeugen. Bedingung: Sie muss SOFORT speicherbar sein +## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte +## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen- +## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern. +static func create_new(map_name: String, size_m: float, weather: String, game_mode := "tdm") -> Dictionary: + var size := maxf(MIN_SIZE_M, minf(size_m, MAX_SIZE_M)) + var res := DEFAULT_RESOLUTION + var zero_heights := PackedByteArray() + zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height + # Feines Material-Raster (Karte 24.10.): ~1 m pro Punkt statt Höhenraster. + var mres := SvmapIO.material_resolution_for(res, size, size) + var zero_materials := PackedByteArray() + zero_materials.resize(mres * mres) # alles 0 → komplette Map Gras + var half := size / 2.0 + var data: Dictionary = { + "format_version": SvmapIO.FORMAT_VERSION, + "info": { + "name": map_name, + "author": DEFAULT_AUTHOR, + "description": "", + "version": 1, + "created_utc": Time.get_datetime_string_from_system(true) + "Z", + "target_game_version": DEFAULT_TARGET_GAME_VERSION, + }, + "terrain": { + "type": "heightmap", + "size_x_m": size, + "size_z_m": size, + "min_height_m": DEFAULT_MIN_HEIGHT, + "max_height_m": DEFAULT_MAX_HEIGHT, + "resolution": res, + "heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights), + # v2 (t_603ec1a8): Material-Schicht — neue Maps starten komplett Gras. + "material_resolution": mres, + "materials_r8_b64": Marshalls.raw_to_base64(zero_materials), + }, + "objects": [], + "spawn_zones": _default_spawn_zones(size), + "bounds": { + "min": [-half, -10.0, -half], + "max": [half, 80.0, half], + }, + "weather": weather, + } + # Spielmodus (28.10.): nur ein abweichender Modus wird geschrieben; Conquest- + # und KotH-Maps starten mit drei Zonen/Hills zwischen den Basen. + game_mode = ModusKatalog.normalize(game_mode) + if game_mode != ModusKatalog.MODE_TDM and ModusKatalog.MODES.has(game_mode): + data["game_mode"] = game_mode + if ModusKatalog.has_zones(game_mode): + data[ModusKatalog.ZONE_KEYS[game_mode]] = ModusKatalog.default_zones(data, game_mode) + return data + + +const MIN_SIZE_M := 256.0 +const MAX_SIZE_M := 4096.0 + + +## Default-Spawns: 4 Punkte je Team als Streck vor den gegenüberliegenden +## Kanten (Team Rot = Osten, Team Blau = Westen — Anzeige-Sache, MVP-Skelett). +static func _default_spawn_zones(size: float) -> Dictionary: + var half := size / 2.0 + var x_blue := -half * 0.8 + var x_red := half * 0.8 + var zs := [-0.3, -0.1, 0.1, 0.3] + var team0_points: Array = [] + var team1_points: Array = [] + for i in zs.size(): + var z: float = zs[i] * size + team0_points.append({"id": "A%d" % (i + 1), "pos": [x_blue, 0.2, z]}) + team1_points.append({"id": "B%d" % (i + 1), "pos": [x_red, 0.2, z]}) + return { + "0": {"label": "Team Blau", "points": team0_points}, + "1": {"label": "Team Rot", "points": team1_points}, + } + + +## Objekt-Anzahl (für Statusleiste/Budget-Anzeige). +static func object_count(data: Dictionary) -> int: + return (data.get("objects", []) as Array).size() + + +## Verbrauchtes Poly-Budget (Σ poly × scale² — wie V8b im Loader). +static func poly_usage(data: Dictionary, manifest: Dictionary) -> float: + var lib: Dictionary = manifest.get("objects", {}) + var total := 0.0 + for obj in (data.get("objects", []) as Array): + if obj is Dictionary and lib.has(str(obj.get("id", ""))): + var poly := float(lib[str(obj["id"])].get("poly", 0)) + var scale := float(obj.get("scale", 1.0)) + total += poly * scale * scale + return total + + +## Höhe des Terrains an Welt-X/Z (Meter). Flache neue Maps → min_height. +## Heightmap R16/LE/Base64 (map_format.md): res×res Punkte über size_x/size_z, +## bilinear interpoliert. Objekt-Platzierung und spätere Terrain-Werkzeuge +## fragen hier nach, statt selbst über Bytes zu stolpern. +static func terrain_height_at(data: Dictionary, x: float, z: float) -> float: + var terrain: Dictionary = data.get("terrain", {}) + var res := int(terrain.get("resolution", 0)) + var size_x := float(terrain.get("size_x_m", 0.0)) + var size_z := float(terrain.get("size_z_m", 0.0)) + var hmin := float(terrain.get("min_height_m", 0.0)) + var hmax := float(terrain.get("max_height_m", 0.0)) + if res < 2 or size_x <= 0.0 or size_z <= 0.0 or hmax <= hmin: + return hmin + var bytes := Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", ""))) + if bytes.size() < res * res * 2: + return hmin + var last := float(res - 1) + var fx := clampf((x / size_x + 0.5) * last, 0.0, last) + var fz := clampf((z / size_z + 0.5) * last, 0.0, last) + var xi := int(floor(fx)) + var zi := int(floor(fz)) + var tx := fx - float(xi) + var tz := fz - float(zi) + var xi1 := mini(xi + 1, res - 1) + var zi1 := mini(zi + 1, res - 1) + var h00 := _height_sample(bytes, res, xi, zi, hmin, hmax) + var h10 := _height_sample(bytes, res, xi1, zi, hmin, hmax) + var h01 := _height_sample(bytes, res, xi, zi1, hmin, hmax) + var h11 := _height_sample(bytes, res, xi1, zi1, hmin, hmax) + return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) + + +static func _height_sample(bytes: PackedByteArray, res: int, xi: int, zi: int, + hmin: float, hmax: float) -> float: + var i := (zi * res + xi) * 2 + var raw := float(bytes[i]) + float(bytes[i + 1]) * 256.0 + return hmin + (raw / 65535.0) * (hmax - hmin) + + +# ----------------------------------------------------- Kartengröße ändern ---- +# Karte t_0d436687 / PLAN.md „Map-Größe ändern" (17.10., Sly-geankert). +# Semantik „Anker Mitte, Rand flach, Resample": die Karte bleibt am Ursprung +# zentriert, bestehendes Terrain behält seine WELTPOSITION (bilinear +# resampeln — kein Stretch), die neue Randzone wird flach (min_height) + +# Material Gras. Reine Funktion (altes Dict → NEUES Dict), damit R3 sie +# atomar hinter ein Undo-Snapshot hängen kann. + +## Dichte-Ziel: nach einem Resize nicht grober als ~2 m/Punkt sampeln. +const RESIZE_DENSITY_TARGET_M := 2.0 +## Auflösungs-Leiter beim Hochdrehen (Format erlaubt 17–513, V6). +const RESIZE_RESOLUTION_LADDER: Array[int] = [129, 257, 513] + + +## Auflösung nach einem Resize: kleinstmögliche Leiter-Sprosse ≥ alter res, +## die die ~2 m/Punkt-Dichte auf der GRÖSSTEN neuen Kante hält; über 513 +## (Format-Maximum) nicht mehr hochdrehen → ab ~1026 m grobt es (Warnband, +## Anzeige kommt in R3). +static func _resize_resolution(old_res: int, new_sx: float, new_sz: float) -> int: + var res := old_res + for step in RESIZE_RESOLUTION_LADDER: + if step < res: + continue + var m_per_point := maxf(new_sx, new_sz) / float(step - 1) + if m_per_point <= RESIZE_DENSITY_TARGET_M: + return step + return SvmapIO.MAX_TERRAIN_RESOLUTION + + +## m/Punkt einer res/size-Kombination — R3-Dialog zeigt das Warnband ab +## ~1024 m (Dichte grobt, Quest-Perf: Objektzahl/Draw-Calls). +static func resize_m_per_point(res: int, size_m: float) -> float: + return size_m / float(maxi(res - 1, 1)) + + +## Terrain auf neue Kartenmaße resampeln (Vergrößern UND Verkleinern — +## beides geht über dieselbe Anker-Mitte-Resample-Schleife: beim Shrink liegt +## jeder neue Gridpunkt innerhalb der alten Fläche, die Rand-flach-Regel +## greift dann nie). R1/R2-Schnitt (PLAN.md Kette): Spawn-Zonen, objects und +## gebaeude werden unverändert ÜBERNOMMEN — auch wenn etwas außerhalb des +## neuen Geländes liegt (Filter nur mit drop_outside via resize_apply, +## Out-of-Bounds-Dialog-Liste via resize_preview). Kein Mutationsspaß: +## der Eingabe-doc bleibt unangetastet, das Ergebnis ist ein frisches Dict +## ohne Prüfsumme (die alte wäre nach dem Resize ungültig — save_to_file +## signiert beim nächsten Speichern neu). +static func resize_terrain(doc: Dictionary, new_size_x_m: float, + new_size_z_m: float) -> Dictionary: + var old_terrain: Dictionary = doc.get("terrain", {}) + var old_res := int(old_terrain.get("resolution", DEFAULT_RESOLUTION)) + var old_sx := float(old_terrain.get("size_x_m", 0.0)) + var old_sz := float(old_terrain.get("size_z_m", 0.0)) + var hmin := float(old_terrain.get("min_height_m", DEFAULT_MIN_HEIGHT)) + var hmax := float(old_terrain.get("max_height_m", DEFAULT_MAX_HEIGHT)) + + var new_sx := clampf(new_size_x_m, MIN_SIZE_M, MAX_SIZE_M) + var new_sz := clampf(new_size_z_m, MIN_SIZE_M, MAX_SIZE_M) + var new_res := _resize_resolution(old_res, new_sx, new_sz) + + var heights_old := SvmapIO.decode_heights(old_terrain) + var mats_old := SvmapIO.decode_materials(old_terrain) + var old_mres := SvmapIO.material_resolution(old_terrain) + var mix_old := SvmapIO.decode_mix(old_terrain) # weiche Ränder (Karte 25.10.) + var mb_old: PackedByteArray = mix_old[0] + var mw_old: PackedByteArray = mix_old[1] + var new_mres := SvmapIO.material_resolution_for(new_res, new_sx, new_sz) + + var heights_new := PackedFloat32Array() + heights_new.resize(new_res * new_res) + var mats_new := PackedByteArray() + mats_new.resize(new_mres * new_mres) + var mb_new := PackedByteArray() + mb_new.resize(new_mres * new_mres) + var mw_new := PackedByteArray() + mw_new.resize(new_mres * new_mres) + + var last_new := float(new_res - 1) + var half_x_old := old_sx * 0.5 + var half_z_old := old_sz * 0.5 + var eps := 0.0001 + for j in new_res: + var z := (float(j) / last_new - 0.5) * new_sz + for i in new_res: + var x := (float(i) / last_new - 0.5) * new_sx + if x < -half_x_old - eps or x > half_x_old + eps \ + or z < -half_z_old - eps or z > half_z_old + eps: + # Neue Randzone: flach auf min_height (wie create_new) + Gras. + heights_new[j * new_res + i] = hmin + continue + # Bestehendes Terrain: Weltposition halten, bilinear sampeln. + heights_new[j * new_res + i] = TerrainField.height_at( + heights_old, old_res, old_sx, old_sz, x, z) + # Material-Schicht auf ihrem eigenen (feinen) Raster, gleiche Regel: + # Weltposition halten, nächster alter Punkt; neue Randzone = Gras (0). + var last_mnew := float(new_mres - 1) + var last_mold := float(old_mres - 1) + for j in new_mres: + var z := (float(j) / last_mnew - 0.5) * new_sz + for i in new_mres: + var x := (float(i) / last_mnew - 0.5) * new_sx + if x < -half_x_old - eps or x > half_x_old + eps \ + or z < -half_z_old - eps or z > half_z_old + eps: + continue + var fx := clampf(roundf((x / old_sx + 0.5) * last_mold), 0.0, last_mold) + var fz := clampf(roundf((z / old_sz + 0.5) * last_mold), 0.0, last_mold) + var src := int(fz) * old_mres + int(fx) + mats_new[j * new_mres + i] = mats_old[src] + mb_new[j * new_mres + i] = mb_old[src] + mw_new[j * new_mres + i] = mw_old[src] + + var terrain := { + "type": "heightmap", + "size_x_m": new_sx, + "size_z_m": new_sz, + "min_height_m": hmin, + "max_height_m": hmax, + "resolution": new_res, + "heightmap_r16_b64": TerrainField.encode(heights_new, hmin, hmax), + "material_resolution": new_mres, + "materials_r8_b64": SvmapIO.encode_materials(mats_new), + } + SvmapIO.encode_mix(terrain, mb_new, mw_new) + + # bounds wie create_new auf die neue Größe — Y-Grenzen vom alten doc + # behalten (Resize ändert nur die Grundfläche, nicht die Luft). + var half_x := new_sx / 2.0 + var half_z := new_sz / 2.0 + var old_bounds: Dictionary = doc.get("bounds", {}) + var old_min: Array = old_bounds.get("min", [-half_x, -10.0, -half_z]) + var old_max: Array = old_bounds.get("max", [half_x, 80.0, half_z]) + var out := { + "format_version": SvmapIO.FORMAT_VERSION, + "info": (doc.get("info", {}) as Dictionary).duplicate(), + "terrain": terrain, + # R1: Objekte/Bauteile/Spawns unverändert übernehmen (Filter in R2). + "objects": (doc.get("objects", []) as Array).duplicate(), + "spawn_zones": (doc.get("spawn_zones", {}) as Dictionary).duplicate(true), + "bounds": { + "min": [-half_x, float(old_min[1]), -half_z], + "max": [half_x, float(old_max[1]), half_z], + }, + "weather": doc.get("weather", ""), + } + # Optionale Zusatz-Blöcke (group_names = Gebäude-/Gruppen-Namen, design_notes, + # Himmel …) gehören nicht dem Resize und wandern unverändert mit — nur die + # Prüfsumme wird verworfen. + for key in doc.keys(): + if not out.has(key) and key != "checksum": + var v: Variant = doc[key] + out[key] = (v as Array).duplicate(true) if v is Array \ + else ((v as Dictionary).duplicate(true) if v is Dictionary else v) + return out + + +# ------------------------------------------------- R2: Out-of-Bounds-Liste ---- +# Karte t_27ba6c37 (PLAN.md „Map-Größe ändern", Punkt 3): beim Verkleinern +# bleiben Objekte/Bauteile/Spawns-Kategorien erhalten, ABER der Dialog listet +# vorher, was komplett außerhalb des neuen Geländes läge. Semantik: +# - Komplett außerhalb → gelistet (resize_apply(drop_outside=true) entfernt). +# - Teils am Rand überlappend → gelten als DRIN, werden unangetastet +# übernommen (kein Clampen-Magie — sie dürfen über die Kante ragen). +# Fußabdruck-Quellen je Kategorie (doc-lokale Fakten, kein Manifest nötig): +# - Objekte: Anker-Punkt pos (svmap speichert keinen Objekt-Fußabdruck; die +# Bibliothek lebt außerhalb der Map-Datei → die Anker-Regel ist die +# dokumentierte Wahrheit, R3 zeigt im Dialog was passiert). +# - Bauteile: das Teil trägt seinen eigenen Fußabdruck ("punkte", lokal, +# mit "rot" gedreht) — EIN Eckpunkt innerhalb der neuen Grenzen = drin. +# - Spawn-Punkte: Punkte (der Zonen-Kreis ist Editor-Visualisierung, kein +# Formatfeld — SpawnOps-Kopf) → Punkt-Regel. +# Grenzfall eps wie resize_terrain: exakt auf der Kante zählt als drin. + + +static func _xz_in_bounds(x: float, z: float, half_x: float, half_z: float, + eps: float) -> bool: + return x >= -half_x - eps and x <= half_x + eps \ + and z >= -half_z - eps and z <= half_z + eps + + +## Out-of-Bounds-Vorschau für den R3-Dialog (keine Mutation, keine Terrain- +## Arbeit). Rückgabe: +## objects_out: Indizes in doc["objects"] (Reihenfolge aufsteigend) +## parts_out: Indizes in doc["gebaeude"] +## spawns_out: Liste {"team": 0|1, "index": int, "id": String} je Punkt +## Die Größen werden wie in resize_terrain geclampt (256–4096 m), damit +## Preview und Apply garantiert dasselbe Gebiet meinen. +static func resize_preview(doc: Dictionary, new_size_x_m: float, + new_size_z_m: float) -> Dictionary: + var half_x := clampf(new_size_x_m, MIN_SIZE_M, MAX_SIZE_M) * 0.5 + var half_z := clampf(new_size_z_m, MIN_SIZE_M, MAX_SIZE_M) * 0.5 + var eps := 0.0001 + + var objects_out: Array = [] + var objects: Array = doc.get("objects", []) + for i in objects.size(): + var obj = objects[i] + 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 + if not _xz_in_bounds(float(pos[0]), float(pos[2]), half_x, half_z, eps): + objects_out.append(i) + + var parts_out: Array = [] + var pieces: Array = doc.get("gebaeude", []) + for i in pieces.size(): + var teil = pieces[i] + if not (teil is Dictionary): + continue + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() < 3: + continue + var punkte = teil.get("punkte", []) + var inside := false + if not (punkte is Array) or (punkte as Array).is_empty(): + # Kein Fußabdruck bekannt → Anker-Punkt entscheidet. + inside = _xz_in_bounds(float(pos[0]), float(pos[2]), + half_x, half_z, eps) + else: + var rot: float = deg_to_rad(float(teil.get("rot", 0.0))) + var c: float = cos(rot) + var s: float = sin(rot) + for p in punkte: + if not (p is Array) or (p as Array).size() < 3: + continue + # Lokaler Fußabdruck-Punkt → Welt (Rotation um +Y, Godot-Basis): + # x' = x·cos + z·sin, z' = −x·sin + z·cos. + var wx: float = float(pos[0]) + float(p[0]) * c + float(p[2]) * s + var wz: float = float(pos[2]) - float(p[0]) * s + float(p[2]) * c + if _xz_in_bounds(wx, wz, half_x, half_z, eps): + inside = true + break + if not inside: + parts_out.append(i) + + var spawns_out: Array = [] + var zones = doc.get("spawn_zones", {}) + if zones is Dictionary: + for team_key in (zones as Dictionary).keys(): + var zone = zones[team_key] + if not (zone is Dictionary): + continue + var points = (zone as Dictionary).get("points", []) + if not (points is Array): + continue + for pi in (points as Array).size(): + var pt = points[pi] + if not (pt is Dictionary): + continue + var pos: Array = pt.get("pos", []) + if (pos as Array).size() < 3: + continue + if not _xz_in_bounds(float(pos[0]), float(pos[2]), + half_x, half_z, eps): + spawns_out.append({ + "team": int(team_key), + "index": pi, + "id": str(pt.get("id", "")), + }) + return { + "objects_out": objects_out, + "parts_out": parts_out, + "spawns_out": spawns_out, + } + + +## Resize anwenden (Terrain resampeln via resize_terrain) und optional die +## Out-of-Bounds-Einträge ENTFERNEN. drop_outside=false → Ergebnis identisch +## zu resize_terrain (reine Preview-Nutzung im Dialog). drop_outside=true → +## die in resize_preview gelisteten Objekte/Bauteile/Spawn-Punkte werden aus +## dem ERGEBNIS entfernt (der Eingabe-doc bleibt auch dann unangetastet — +## Undo-Snapshot-Sicherheit wie in R1). +static func resize_apply(doc: Dictionary, new_size_x_m: float, + new_size_z_m: float, drop_outside: bool) -> Dictionary: + var result := resize_terrain(doc, new_size_x_m, new_size_z_m) + if not drop_outside: + return result + var preview := resize_preview(doc, new_size_x_m, new_size_z_m) + + # Absteigend löschen, damit die Indizes der Preview stabil bleiben. + var out_idx: Array = (preview["objects_out"] as Array).duplicate() + out_idx.sort() + out_idx.reverse() + var objects: Array = result.get("objects", []) + for i in out_idx: + if int(i) < objects.size(): + objects.remove_at(int(i)) + + var parts_idx: Array = (preview["parts_out"] as Array).duplicate() + parts_idx.sort() + parts_idx.reverse() + var gebaeude: Array = result.get("gebaeude", []) + for i in parts_idx: + if int(i) < gebaeude.size(): + gebaeude.remove_at(int(i)) + + # Spawn-Punkte je Team rausfiltern (Struktur bleibt: Teams ohne Punkte + # bleiben als Zone-Block erhalten — der Spawn-Editor füllt sie wieder). + var spawns_out: Array = preview["spawns_out"] + var per_team := {0: {}, 1: {}} # team → {punkt-index: true} + for entry in spawns_out: + if entry is Dictionary: + per_team[int(entry.get("team", -1))][int(entry.get("index", -1))] = true + var zones = result.get("spawn_zones", {}) + if zones is Dictionary: + for team_key in (zones as Dictionary).keys(): + var zone = zones[team_key] + if not (zone is Dictionary): + continue + var points = (zone as Dictionary).get("points", []) + if not (points is Array): + continue + var drop: Dictionary = per_team.get(int(team_key), {}) + if drop.is_empty(): + continue + var kept: Array = [] + for pi in (points as Array).size(): + if not drop.has(pi): + kept.append(points[pi]) + zone["points"] = kept + return result diff --git a/svmap/map_document.gd.uid b/svmap/map_document.gd.uid new file mode 100644 index 0000000..79c3c6e --- /dev/null +++ b/svmap/map_document.gd.uid @@ -0,0 +1 @@ +uid://cmyaqvftm41uf diff --git a/svmap/modus_katalog.gd b/svmap/modus_katalog.gd new file mode 100644 index 0000000..89649e2 --- /dev/null +++ b/svmap/modus_katalog.gd @@ -0,0 +1,264 @@ +class_name ModusKatalog +extends RefCounted +## Spielmodus, Zonen/Hills und Modus-Spawns der Map (28.10.). +## doc["game_mode"] ("tdm" | "conquest" | "koth" | "dm", fehlt = tdm), +## doc["conquest_zones"] und doc["hills"] [{id, label, pos:[x,y,z], radius_m}], +## doc["mode_spawns"] {modus: {"0"/"1"/"all": {points:[{id,pos}]}}} — Regeln 1:1 +## wie SvmapLoader im Match-Server (V21–V24, docs/map_format.md). +## Alt-Namen: game_mode "checkpoints" und Feld "checkpoints" (= Conquest) werden +## beim Laden umbenannt (migrate). + +const MODE_TDM := "tdm" +const MODE_CONQUEST := "conquest" +const MODE_KOTH := "koth" +const MODE_DM := "dm" +const MODES: Array[String] = [MODE_TDM, MODE_CONQUEST, MODE_KOTH, MODE_DM] +const MODE_LABELS := { + MODE_TDM: "Team-Deathmatch", + MODE_CONQUEST: "Conquest (mehrere Zonen einnehmen)", + MODE_KOTH: "King of the Hill (ein wandernder Hill)", + MODE_DM: "Deathmatch (alle gegen alle)", +} +const MODE_SHORT := { + MODE_TDM: "Team-Deathmatch", + MODE_CONQUEST: "Conquest", + MODE_KOTH: "King of the Hill", + MODE_DM: "Deathmatch", +} +const MODE_ALIASES := {"checkpoints": MODE_CONQUEST} +## Feld mit den Zonen je Modus (nur Conquest und KotH haben welche). +const ZONE_KEYS := {MODE_CONQUEST: "conquest_zones", MODE_KOTH: "hills"} +const ZONE_NAMES := {MODE_CONQUEST: "Zone", MODE_KOTH: "Hill"} +const ZONE_PREFIX := {MODE_CONQUEST: "C", MODE_KOTH: "H"} +const DEFAULT_RADIUS := 15.0 +const MIN_RADIUS := 3.0 +const MAX_RADIUS := 60.0 +const SPAWN_SET_BASE := "base" + + +static func normalize(m: String) -> String: + return str(MODE_ALIASES.get(m, m)) + + +static func mode_of(doc: Dictionary) -> String: + var m := normalize(str(doc.get("game_mode", MODE_TDM))) + return m if MODES.has(m) else MODE_TDM + + +static func has_zones(mode: String) -> bool: + return ZONE_KEYS.has(mode) + + +## Zonen/Hills des Modus (leer für Modi ohne Zonen). +static func zones(doc: Dictionary, mode: String) -> Array: + if not ZONE_KEYS.has(mode): + return [] + var c: Variant = doc.get(ZONE_KEYS[mode], []) + return c if c is Array else [] + + +## Nächste freie ID "C1", "C2", … (Conquest) bzw. "H1", … (Hills). +static func next_id(doc: Dictionary, mode: String) -> String: + var used := {} + for c in zones(doc, mode): + if c is Dictionary: + used[str(c.get("id", ""))] = true + var prefix: String = ZONE_PREFIX.get(mode, "Z") + var i := 1 + while used.has("%s%d" % [prefix, i]): + i += 1 + return "%s%d" % [prefix, i] + + +## Neue Zone auf dem Boden (y = Terrain-Höhe). +static func make_zone(doc: Dictionary, mode: String, x: float, z: float, label := "") -> Dictionary: + var id := next_id(doc, mode) + return { + "id": id, + "label": label if label != "" else "%s %s" % [ZONE_NAMES.get(mode, "Zone"), id.substr(1)], + "pos": [x, SvmapDocument.terrain_height_at(doc, x, z), z], + "radius_m": DEFAULT_RADIUS, + } + + +## Drei Start-Zonen zwischen den Basen (West–Ost-Linie) für neue Maps des Modus. +static func default_zones(doc: Dictionary, mode: String) -> Array: + var size := float(doc.get("terrain", {}).get("size_x_m", 1024.0)) + var out: Array = [] + var names := ["Westposten", "Mitte", "Ostposten"] if mode == MODE_CONQUEST else ["Westhang", "Talmitte", "Osthang"] + var xs := [-0.25, 0.0, 0.25] + for i in 3: + var probe := {ZONE_KEYS[mode]: out, "terrain": doc.get("terrain", {})} + out.append(make_zone(probe, mode, xs[i] * size, 0.0, names[i])) + return out + + +## Alte Namen auf die heutigen umstellen (nach erfolgreichem Laden). +static func migrate(doc: Dictionary) -> void: + if str(doc.get("game_mode", "")) == "checkpoints": + doc["game_mode"] = MODE_CONQUEST + if doc.has("checkpoints"): + if not doc.has("conquest_zones"): + doc["conquest_zones"] = doc["checkpoints"] + doc.erase("checkpoints") + + +# ----------------------------------------------------------- Modus-Spawns -- + +## Team-Schlüssel eines Spawn-Satzes: Deathmatch hat nur "all". +static func spawn_keys(mode: String) -> Array: + return ["all"] if mode == MODE_DM else ["0", "1"] + + +static func spawn_set(doc: Dictionary, mode: String) -> Dictionary: + var ms: Variant = doc.get("mode_spawns", {}) + if ms is Dictionary and (ms as Dictionary).get(mode, null) is Dictionary: + return (ms as Dictionary)[mode] + return {} + + +static func has_own_spawns(doc: Dictionary, mode: String) -> bool: + return not spawn_set(doc, mode).is_empty() + + +## Eigene Spawns für den Modus anlegen: Kopie der Basis-Spawns (Deathmatch: beide +## Teams in einem Topf). IDs bekommen den Modus-Präfix, damit sie map-weit eindeutig sind. +static func create_spawn_set(doc: Dictionary, mode: String) -> void: + var ms: Dictionary = doc.get("mode_spawns", {}) if doc.get("mode_spawns", {}) is Dictionary else {} + var base: Dictionary = doc.get("spawn_zones", {}) + var out := {} + var tag := mode.to_upper().substr(0, 1) if mode != MODE_DM else "D" + var n := 0 + for key in spawn_keys(mode): + var pts: Array = [] + var src_teams: Array = ["0", "1"] if mode == MODE_DM else [key] + for t in src_teams: + var z: Variant = base.get(t, {}) + if not (z is Dictionary): + continue + for p in (z as Dictionary).get("points", []): + if p is Dictionary: + n += 1 + pts.append({"id": "%s%s%d" % [tag, "" if mode == MODE_DM else ("A" if t == "0" else "B"), n], + "pos": (p.get("pos", [0, 0, 0]) as Array).duplicate()}) + out[key] = {"points": pts} + ms[mode] = out + doc["mode_spawns"] = ms + + +static func remove_spawn_set(doc: Dictionary, mode: String) -> void: + var ms: Variant = doc.get("mode_spawns", null) + if not (ms is Dictionary): + return + (ms as Dictionary).erase(mode) + if (ms as Dictionary).is_empty(): + doc.erase("mode_spawns") + + +# ------------------------------------------------------------- Validierung -- + +static func validate(data: Dictionary, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var raw = data.get("game_mode", "") + var mode := normalize(str(raw)) if raw != null else "" + if mode != "" and not MODES.has(mode): + errors.append("V21: game_mode '%s' unbekannt (%s)." % [mode, ", ".join(MODES)]) + var cz = data.get("conquest_zones", data.get("checkpoints", [])) + errors.append_array(_validate_zone_list(cz, "V22", "Conquest-Zone", bounds)) + if mode == MODE_CONQUEST and cz is Array and (cz as Array).is_empty(): + errors.append("V21: game_mode 'conquest' braucht mindestens eine Zone.") + var hl = data.get("hills", []) + errors.append_array(_validate_zone_list(hl, "V23", "Hill", bounds)) + if mode == MODE_KOTH and hl is Array and (hl as Array).is_empty(): + errors.append("V21: game_mode 'koth' braucht mindestens einen Hill.") + errors.append_array(_validate_mode_spawns(data.get("mode_spawns", null), bounds)) + return errors + + +static func _validate_zone_list(cps, code: String, what: String, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if not (cps is Array): + errors.append("%s: %s-Liste muss ein Array sein." % [code, what]) + return errors + var seen := {} + for i in (cps as Array).size(): + var c = cps[i] + if not (c is Dictionary): + errors.append("%s: %s %d ist kein Objekt." % [code, what, i]) + continue + var cid = c.get("id", "") + if not (cid is String) or (cid as String).strip_edges().is_empty(): + errors.append("%s: %s %d: id fehlt/leer." % [code, what, i]) + elif seen.has(cid): + errors.append("%s: %s-ID '%s' ist doppelt." % [code, what, cid]) + else: + seen[cid] = true + var pos = c.get("pos", null) + if not (pos is Array) or (pos as Array).size() != 3 \ + or not _num(pos[0]) or not _num(pos[1]) or not _num(pos[2]): + errors.append("%s: %s '%s': pos muss [x, y, z] mit endlichen Werten sein." % [code, what, str(cid)]) + elif not _inside(pos, bounds): + errors.append("%s: %s '%s' liegt außerhalb der Match-Grenzen." % [code, what, str(cid)]) + var r = c.get("radius_m", DEFAULT_RADIUS) + if not _num(r) or float(r) < MIN_RADIUS or float(r) > MAX_RADIUS: + errors.append("%s: %s '%s': radius_m muss 3–60 sein." % [code, what, str(cid)]) + if c.has("yaw_deg") and not _num(c["yaw_deg"]): + errors.append("%s: %s '%s': yaw_deg muss endlich sein." % [code, what, str(cid)]) + return errors + + +static func _validate_mode_spawns(ms, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if ms == null: + return errors + if not (ms is Dictionary): + return PackedStringArray(["V24: mode_spawns muss ein Objekt sein."]) + for mode in (ms as Dictionary).keys(): + if not MODES.has(str(mode)): + errors.append("V24: mode_spawns: Modus '%s' unbekannt." % str(mode)) + continue + var m = ms[mode] + if not (m is Dictionary): + errors.append("V24: mode_spawns.%s muss ein Objekt sein." % str(mode)) + continue + var seen := {} + for key in (m as Dictionary).keys(): + var allowed := spawn_keys(str(mode)) + if not allowed.has(str(key)): + errors.append("V24: mode_spawns.%s: Schlüssel '%s' nicht erlaubt (%s)." % [str(mode), str(key), ", ".join(allowed)]) + continue + var pts = m[key].get("points", []) if m[key] is Dictionary else null + if not (pts is Array) or (pts as Array).is_empty(): + errors.append("V24: mode_spawns.%s.%s braucht mindestens einen Punkt." % [str(mode), str(key)]) + continue + for i in (pts as Array).size(): + var p = pts[i] + var pid = p.get("id", "") if p is Dictionary else "" + if not (pid is String) or (pid as String).strip_edges().is_empty(): + errors.append("V24: mode_spawns.%s.%s Punkt %d: id fehlt/leer." % [str(mode), str(key), i]) + continue + if seen.has(pid): + errors.append("V24: Spawn-ID '%s' ist doppelt (Modus %s)." % [pid, str(mode)]) + seen[pid] = true + var pos = p.get("pos", null) + if not (pos is Array) or (pos as Array).size() != 3 \ + or not _num(pos[0]) or not _num(pos[1]) or not _num(pos[2]): + errors.append("V24: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % pid) + elif not _inside(pos, bounds): + errors.append("V24: Spawn '%s' liegt außerhalb der Match-Grenzen." % pid) + if p.has("yaw_deg") and not _num(p["yaw_deg"]): + errors.append("V24: Spawn '%s': yaw_deg muss endlich sein." % pid) + return errors + + +static func _num(v: Variant) -> bool: + return (v is int or v is float) and is_finite(float(v)) + + +static func _inside(pos: Array, bounds: Dictionary) -> bool: + var mn: Array = bounds.get("min", [0, 0, 0]) + var mx: Array = bounds.get("max", [0, 0, 0]) + for i in 3: + if float(pos[i]) < float(mn[i]) - 1.0 or float(pos[i]) > float(mx[i]) + 1.0: + return false + return true diff --git a/svmap/modus_katalog.gd.uid b/svmap/modus_katalog.gd.uid new file mode 100644 index 0000000..3234abe --- /dev/null +++ b/svmap/modus_katalog.gd.uid @@ -0,0 +1 @@ +uid://cprm4ke4dpj58 diff --git a/svmap/skybox_katalog.gd b/svmap/skybox_katalog.gd new file mode 100644 index 0000000..2aebaba --- /dev/null +++ b/svmap/skybox_katalog.gd @@ -0,0 +1,125 @@ +class_name SkyboxKatalog +## Skybox-Katalog (Karte Skybox-Auswahl): welche Himmels-Panoramas eine Map +## über das optionale svmap-Feld `weather_sky` wählen kann. 1:1-Spiegel von +## client/scripts/world/skybox_katalog.gd — beide Seiten pflegen dieselben IDs. Der Match-Server kennt nur das ID-Muster (V17), keine Texturen. +## +## IDs: "" = Preset entscheidet (alle Presets: ProceduralSky), "prozedural" = +## ProceduralSky erzwingen, "sky_panorama" = Pack-Wüste (Skybox_3, Altbestand), +## "sky_NN" = Panorama aus assets/textures/skybox/ (2048×1024, VRAM-komprimiert, +## ~1,4 MB je Textur; es wird immer nur EINE geladen). Unbekannte ID → Fallback +## ProceduralSky (alte Clients/neuere Maps brechen nie). +## +## `fog`: Farbe am Horizont des Panoramas (aerial perspective), gemessen am +## Texturband direkt über dem Horizont. + +const ID_PACK_WUESTE := "sky_panorama" +const ID_PROZEDURAL := "prozedural" +## Relativ zum Client-Klon (Nachbar-Ordner, keine Duplikate im Editor). +const TEXTUR_DIR_REL := "assets/textures/skybox/" +const PACK_WUESTE_REL := "assets/textures/military_desert/Skybox_3.png" + +## Reihenfolge = Reihenfolge im Editor-Auswahlfeld. +const EINTRAEGE := [ + {"id": "sky_panorama", "name": "Wüste (Pack)", "fog": Color(0.93, 0.80, 0.66)}, + {"id": "sky_03", "name": "Goldene Wolken (Sonnenuntergang)", "fog": Color(0.57, 0.63, 0.47)}, + {"id": "sky_06", "name": "Glutroter Abend", "fog": Color(0.66, 0.40, 0.45)}, + {"id": "sky_12", "name": "Roter Horizont (Abendrot)", "fog": Color(0.87, 0.44, 0.40)}, + {"id": "sky_14", "name": "Wolkenfront (Tag)", "fog": Color(0.48, 0.58, 0.71)}, + {"id": "sky_17", "name": "Violette Nacht (Milchstraße)", "fog": Color(0.58, 0.45, 0.65)}, + {"id": "sky_82", "name": "Dämmerung mit Milchstraße", "fog": Color(0.54, 0.48, 0.53)}, + {"id": "sky_84", "name": "Sternenhimmel mit Wolkenbändern", "fog": Color(0.58, 0.66, 0.65)}, + {"id": "sky_90", "name": "Nachtwolken", "fog": Color(0.22, 0.36, 0.53)}, + {"id": "sky_94", "name": "Sonne hinter Wolken", "fog": Color(0.47, 0.51, 0.61)}, + {"id": "sky_98", "name": "Sternschnuppen (Nacht)", "fog": Color(0.33, 0.54, 0.71)}, + {"id": "sky_103", "name": "Sonnenaufgang (Strahlen)", "fog": Color(0.50, 0.69, 0.75)}, + {"id": "sky_107", "name": "Goldene Stunde", "fog": Color(0.79, 0.54, 0.33)}, + {"id": "sky_115", "name": "Abendglühen (Wolkentürme)", "fog": Color(0.59, 0.44, 0.43)}, +] + + +static func eintrag(id: String) -> Dictionary: + for e in EINTRAEGE: + if e["id"] == id: + return e + return {} + + +static func ist_bekannt(id: String) -> bool: + return id == ID_PROZEDURAL or not eintrag(id).is_empty() + + +## V17: gültig = leer/fehlend, "prozedural" oder bekannte ID. +static func ist_gueltig(id: Variant) -> bool: + if id == null: + return true + if not (id is String): + return false + return id == "" or ist_bekannt(id) + + +## Pfad relativ zum Client-Klon ("" = ProceduralSky). +static func textur_rel(id: String) -> String: + if id == ID_PACK_WUESTE: + return PACK_WUESTE_REL + if eintrag(id).is_empty(): + return "" + return TEXTUR_DIR_REL + id + ".png" + + +## Absoluter Pfad im Nachbar-Klon ("" = ID unbekannt oder kein Client-Klon). +static func textur_pfad(id: String) -> String: + var rel := textur_rel(id) + if rel.is_empty(): + return "" + var root := TestRun.find_client(ProjectSettings.globalize_path("res://"), + TestRun.load_cfg()) + return "" if root.is_empty() else root.path_join(rel) + + +static func fog_farbe(id: String, fallback: Color, energie: float = 1.0) -> Color: + if id == ID_PACK_WUESTE: + return fallback + var e := eintrag(id) + return (e["fog"] * minf(energie, 1.0)) if not e.is_empty() else fallback + + +## Geladene Panorama-Texturen (volle Größe, mit Mipmaps) — ein Eintrag je ID, +## damit jeder Rebuild nicht erneut 2k-PNGs dekodiert. null = nicht ladbar. +static var _tex_cache: Dictionary = {} +static var _thumb_cache: Dictionary = {} + + +static func panorama_textur(id: String) -> Texture2D: + if _tex_cache.has(id): + return _tex_cache[id] + var tex: Texture2D = null + var pfad := textur_pfad(id) + if not pfad.is_empty() and FileAccess.file_exists(pfad): + var img := Image.load_from_file(pfad) + if img != null: + if img.is_compressed(): + img.decompress() + img.generate_mipmaps() + tex = ImageTexture.create_from_image(img) + if tex != null: + _tex_cache[id] = tex # Fehlschläge NICHT cachen (Klon kann später da sein) + return tex + + +## Vorschau-Bild für das Auswahlfeld: obere Panorama-Hälfte (Himmel), klein. +static func vorschau(id: String) -> Texture2D: + if _thumb_cache.has(id): + return _thumb_cache[id] + var pfad := textur_pfad(id) + if pfad.is_empty() or not FileAccess.file_exists(pfad): + return null + var img := Image.load_from_file(pfad) + if img == null: + return null + if img.is_compressed(): + img.decompress() + var halb := img.get_region(Rect2i(0, 0, img.get_width(), img.get_height() / 2)) + halb.resize(320, 80, Image.INTERPOLATE_LANCZOS) + var tex := ImageTexture.create_from_image(halb) + _thumb_cache[id] = tex + return tex diff --git a/svmap/skybox_katalog.gd.uid b/svmap/skybox_katalog.gd.uid new file mode 100644 index 0000000..b8abc2c --- /dev/null +++ b/svmap/skybox_katalog.gd.uid @@ -0,0 +1 @@ +uid://cydcauar68si5 diff --git a/svmap/svmap_io.gd b/svmap/svmap_io.gd new file mode 100644 index 0000000..72c2a49 --- /dev/null +++ b/svmap/svmap_io.gd @@ -0,0 +1,916 @@ +extends RefCounted +class_name SvmapIO +## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v2). +## +## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f +## + v2-Erweiterung t_603ec1a8: Material-Schicht am Höhenfeld). +## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku +## ist der Vertrag. Validierungs-IDs V1..V15 und Kanonisierung/Prüfsumme sind +## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom +## Match-Server akzeptiert wird (und umgekehrt). +## +## Format v2: terrain.materials_r8_b64 (OPTIONAL, Base64 uint8 je Sample, +## 0=Gras/1=Sand/2=Fels). Fehlt es (Legacy-v1), gilt alles Gras. Geladen +## werden v1 UND v2; gespeichert wird immer v2 (mit Schicht). +## +## API (alles static, testbar ohne UI): +## parse_and_validate(text, manifest = {}) -> {ok, errors, data} +## load_from_file(path) -> {ok, errors, data} +## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben) +## validate_map(data, manifest) -> PackedStringArray +## sign_map(data) -> void (setzt/erneuert Prüfsumme) +## decode_materials(terrain) / encode_materials(mats) +## +const FORMAT_VERSION := 2 +## Akzeptierte Format-Versionen (V3): 1 = Legacy ohne Material-Schicht, +## 2 = aktuell. Neu gespeicherte Maps tragen immer FORMAT_VERSION. +const SUPPORTED_FORMAT_VERSIONS: Array[int] = [1, 2] +## Material-IDs (Index = ID). 0 = Gras = Default für Maps ohne Schicht. +## Karte t_ab408127 (Sly-Vielfalt): Palette von 3 auf 20 erweitert — NEUE IDs +## NUR ANGEHÄNGT, Legacy-Zuordnung (0=Gras, 1=Sand, 2=Fels) bleibt unverändert, +## damit alte Maps byte-identisch laden. Format v2 bleibt (Feldgröße = res², +## Werte = uint8 je Sample — der Bereich wächst nur implizit mit der Länge). +const MATERIAL_IDS: Array[String] = [ + "gras_steppe", # 0 = Legacy-Gras (Default/Standard-Boden) + "sand_rippen", # 1 = Legacy-Sand (Dünen/offene Flächen) + "fels_granit", # 2 = Legacy-Fels (Berge/Klippen) + "gras_satt", # 3 = gesundes, sattes Grün + "gras_vertrocknet", # 4 = ausgedörrt, gelblich + "gras_moosig", # 5 = weiches Moosgrün + "erde_braun", # 6 = feuchte braune Erde + "erde_getreten", # 7 = festgetretener Pfad + "erde_umgeackert", # 8 = umgegrabene/zerwühlte Erde + "schotter", # 9 = Kies/Schotter + "lehm_rot", # 10 = rotbrauner Lehm + "sand_hell", # 11 = heller Flugsand + "sand_rost", # 12 = rostroter Sand + "fels_basalt", # 13 = dunkler Basalt + "fels_geroell", # 14 = Geröllhalde + "schlamm", # 15 = Matsch/Schlamm + "schnee_frisch", # 16 = Neuschnee + "schnee_matschig", # 17 = angeschmolzen/angesaut + "asche", # 18 = Asche/verkohlt + "stoppeln", # 19 = abgeerntetes Feld +] +const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"] +## Skybox-Option (Karte t_f72913d4): Werte des optionalen Feldes `weather_sky` +## — identische Semantik wie der Client (Weather.apply_preset sky_override): +## "sky_panorama" = Pack-Panorama erzwingen, "prozedural" = ProceduralSky +## erzwingen, leer/fehlend = Preset entscheidet (in allen Presets aus). +const WEATHER_SKY_OPTIONS: Array[String] = ["sky_panorama", "prozedural"] # ALT; gültig = SkyboxKatalog.ist_gueltig + +# Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4). +const MAX_FILE_BYTES := 50 * 1024 * 1024 # V1 +const MAX_OBJECTS := 5000 # V8a +const MAX_TOTAL_POLY := 1_500_000 # V8b (× scale² gewichtet) +const MAX_TERRAIN_RESOLUTION := 513 # V6 +const MIN_TERRAIN_RESOLUTION := 17 +const MAX_TERRAIN_SIZE_M := 8192.0 # V6/V14 +const MIN_TERRAIN_SIZE_M := 16.0 +const MAX_HEIGHT_RANGE_M := 500.0 # V6 +const MAX_BOUNDS_EXTENT_M := 8192.0 # V14 +const BOUNDS_TOLERANCE_M := 1.0 # V9 +const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11) +const SYM_KEY_MAX_LEN := 64 # V16 (Symmetrie-Partner-Kennung) +## Feines Material-Raster (Karte 24.10., Sly: „feiner malen“): optionales +## terrain.material_resolution — eigenes Raster für materials_r8_b64, feiner als +## das Höhenfeld. Fehlt es, gilt resolution (alte Maps unverändert). Muss +## ≥ resolution sein und auf dem Höhenraster liegen ((m−1) Vielfaches von +## (res−1)), damit jeder Höhenpunkt genau einen Materialpunkt hat. +const MAX_MATERIAL_RESOLUTION := 1025 # V15 (1025² = 1 MB) +const MATERIAL_TARGET_M := 1.0 # Ziel: 1 m pro Materialpunkt + +const MANIFEST_PATH := "res://library/objects.json" +const JSON_INDENT := " " + + +static func _add(result: Dictionary, msg: String) -> void: + (result["errors"] as Array).append(msg) + + +# --------------------------------------------------------------- Öffentlich -- + +## Komplette Pipeline: JSON-Text → Validierung (inkl. Prüfsumme) → Map-Daten. +## errors bewusst als Array: Godot 4 kopiert PackedArrays beim Dict-Zugriff, +## Mutationen würden sonst verloren gehen. +static func parse_and_validate(text: String, manifest: Dictionary = {}) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}} + if text.length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [text.length(), MAX_FILE_BYTES]) + return result + var parsed: Variant = JSON.parse_string(text) + if parsed == null or not (parsed is Dictionary): + _add(result, "V2: Kein gültiges JSON-Objekt (Datei beschädigt oder keine .svmap?).") + return result + var data: Dictionary = parsed + var errors := validate_map(data, manifest) + if not errors.is_empty(): + result["errors"] = Array(errors) + return result + ModusKatalog.migrate(data) # Alt-Namen (checkpoints → conquest) NACH der Prüfsumme umstellen + result["ok"] = true + result["data"] = data + return result + + +static func load_from_file(path: String) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}} + if not FileAccess.file_exists(path): + _add(result, "Datei nicht gefunden: %s" % path) + return result + var fa := FileAccess.open(path, FileAccess.READ) + if fa == null: + _add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return result + if fa.get_length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) + return result + return parse_and_validate(fa.get_as_text()) + + +## Kern-Validierung → Kanonisieren → Signieren → Schreiben. Nur gültige Maps +## landen auf Disk. Kanonisieren VOR dem Schreiben (alle Zahlen → float): +## macht Dateien deterministisch — speichern→laden→speichern bleibt +## byte-identisch, weil Godots JSON-Parser ints ohnehin als float zurückgibt. +## Kompatibel zum Server: dessen Prüfsumme läuft über die kanonisierte Payload, +## int- und float-Form kanonisieren zum selben Hash. +## Schema v2 (Karte t_9814f18f): die Payload wird VOR dem Signieren quantisiert +## (12 signifikante Stellen) — die Datei enthält keine Rausch-Floats mehr, und +## der v2-Hash bleibt unter allen Godot-Versionen stabil (FirstFight-Fall: +## ein 15-stelliger Python-Float-Text war unter Legacy-Hash nicht verifizierbar). +static func save_to_file(path: String, data: Dictionary) -> Dictionary: + var signed := to_signed_text(data) + if not bool(signed["ok"]): + return {"ok": false, "errors": signed["errors"]} + var fa := FileAccess.open(path, FileAccess.WRITE) + if fa == null: + var out := {"ok": false, "errors": []} + _add(out, "Datei nicht schreibbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return out + fa.store_string(str(signed["text"])) + return {"ok": true, "errors": PackedStringArray()} + + +## Wie save_to_file, aber ohne Datei: liefert den fertigen, kanonisierten und +## signierten .svmap-Text (für den Server-Upload, Etappe 3). Das Eingabe-doc +## bleibt unangetastet. {ok, errors, text} +static func to_signed_text(data: Dictionary) -> Dictionary: + var errors := _validate_core(data) + if not errors.is_empty(): + return {"ok": false, "errors": errors, "text": ""} + var canonical: Dictionary = _canonicalize(data) + _quantisieren_deep(canonical) + sign_map(canonical) + return {"ok": true, "errors": PackedStringArray(), "text": JSON.stringify(canonical, JSON_INDENT)} + + +## Validierung OHNE Prüfsumme (V3–V12 + V14); validate_map = Kern + Prüfsumme. +## manifest = Bibliotheks-Registry (leer → Default aus res://library/objects.json). +static func validate_map(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: + var errors := _validate_core(data, manifest) + if not errors.is_empty(): + return errors + return _validate_checksum(data) + + +## Setzt/erneuert die Prüfsumme (save = neue Map-Version). +## Schema v2: Hash über die quantisierte Payload (12 signifikante Stellen). +static func sign_map(data: Dictionary) -> void: + data["checksum"] = {"algorithm": "sha256", "schema": "v2", "value": _payload_sha256_v2(data)} + + +# ------------------------------------------------------------- Validierung -- + +static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: + var errors := PackedStringArray() + + # V3: Versions-Stempel (v1 = Legacy ohne Material-Schicht, v2 = aktuell) + var fv = data.get("format_version", null) + if not (fv is int or fv is float) or not SUPPORTED_FORMAT_VERSIONS.has(int(fv)): + var wanted := "" + for v in SUPPORTED_FORMAT_VERSIONS: + wanted += (" oder " if wanted != "" else "") + str(v) + errors.append("V3: format_version muss %s sein (ist %s). Diese Datei ist keine .svmap-Map." % [wanted, str(fv)]) + return errors # Ohne passende Version ist alles Weitere sinnlos. + + # V4: Pflichtblöcke + Typen (checksum prüft validate_map separat). + for key in ["info", "terrain", "spawn_zones", "bounds"]: + if not data.has(key) or not (data[key] is Dictionary): + errors.append("V4: Pflichtblock '%s' fehlt oder ist kein Objekt." % key) + if not data.has("objects") or not (data["objects"] is Array): + errors.append("V4: Pflichtblock 'objects' fehlt oder ist kein Array.") + if not errors.is_empty(): + return errors + + errors.append_array(_validate_info(data["info"])) + errors.append_array(_validate_terrain(data["terrain"])) + errors.append_array(_validate_bounds(data["bounds"])) + + var terrain: Dictionary = data["terrain"] + var bounds: Dictionary = data["bounds"] + errors.append_array(_validate_objects(data["objects"], bounds, manifest)) + errors.append_array(_validate_spawns(data["spawn_zones"], bounds)) + + # V12: Wetter (optional, aber dann gültig) + var weather = data.get("weather", "") + if weather != null and str(weather) != "": + if not (weather is String) or not WEATHER_PRESETS.has(weather): + errors.append("V12: weather '%s' ist kein gültiges Preset (%s)." % [ + str(weather), ", ".join(WEATHER_PRESETS)]) + # V17: weather_sky (optional, Karte t_f72913d4) — leer/fehlend = Standard. + # Checksum-neutral wie tint/gebaeude: der Server kennt das Feld nicht, die + # Prüfsumme läuft über die ganze Payload — unbekannte Zusatzfelder stören + # die Server-Validierung nicht, nur WERT-Fehler fangen wir hier ab. + var weather_sky = data.get("weather_sky", "") + if weather_sky != null and str(weather_sky) != "": + if not SkyboxKatalog.ist_gueltig(weather_sky): + var ids: PackedStringArray = ["prozedural"] + for e in SkyboxKatalog.EINTRAEGE: + ids.append(e["id"]) + errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [ + str(weather_sky), ", ".join(ids)]) + # V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog. + errors.append_array(LoadoutKatalog.validate(data.get("loadout", null))) + # V21–V24: Spielmodus, Zonen, Hills, Modus-Spawns (optional), siehe ModusKatalog. + errors.append_array(ModusKatalog.validate(data, bounds)) + return errors + + +static func _validate_info(info: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var name_v = info.get("name", "") + if not (name_v is String) or (name_v as String).strip_edges().is_empty() \ + or (name_v as String).length() > 64: + errors.append("V4: info.name muss 1–64 Zeichen lang sein.") + var author = info.get("author", "") + if not (author is String) or (author as String).strip_edges().is_empty(): + errors.append("V4: info.author darf nicht leer sein.") + var version = info.get("version", 0) + if not (version is int or version is float) or int(version) < 1: + errors.append("V4: info.version muss eine Zahl ≥ 1 sein.") + var tgv = info.get("target_game_version", "") + if tgv != null and str(tgv) != "" and not (tgv is String): + errors.append("V4: info.target_game_version muss ein String sein.") + return errors + + +static func _validate_terrain(terrain: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if terrain.get("type", "") != "heightmap": + errors.append("V4: terrain.type muss 'heightmap' sein.") + return errors + var res = terrain.get("resolution", 0) + if not (res is int or res is float) or int(res) < MIN_TERRAIN_RESOLUTION \ + or int(res) > MAX_TERRAIN_RESOLUTION: + errors.append("V6: terrain.resolution muss %d–%d sein (ist %s)." % [ + MIN_TERRAIN_RESOLUTION, MAX_TERRAIN_RESOLUTION, str(res)]) + return errors + var sx = terrain.get("size_x_m", 0.0) + var sz = terrain.get("size_z_m", 0.0) + for pair in [["size_x_m", sx], ["size_z_m", sz]]: + if not _is_finite_num(pair[1]) or float(pair[1]) < MIN_TERRAIN_SIZE_M \ + or float(pair[1]) > MAX_TERRAIN_SIZE_M: + errors.append("V6: terrain.%s muss %.0f–%.0f m sein (ist %s)." % [ + pair[0], MIN_TERRAIN_SIZE_M, MAX_TERRAIN_SIZE_M, str(pair[1])]) + var hmin = terrain.get("min_height_m", 0.0) + var hmax = terrain.get("max_height_m", 0.0) + if not _is_finite_num(hmin) or not _is_finite_num(hmax) or float(hmin) >= float(hmax) \ + or (float(hmax) - float(hmin)) > MAX_HEIGHT_RANGE_M: + errors.append("V6: terrain.min_height_m < max_height_m (Spannweite ≤ %.0f m) verletzt." % MAX_HEIGHT_RANGE_M) + # V5: Höhenfeld-Länge + var res_i := int(res) + var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", ""))) + if raw.size() != res_i * res_i * 2: + errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [ + raw.size(), res_i * res_i * 2]) + # V15: Material-Schicht (v2, optional — fehlt sie, gilt alles Gras). + var mres_v = terrain.get("material_resolution", res_i) + var mres_i := res_i + if not (mres_v is int or mres_v is float) or float(mres_v) != floorf(float(mres_v)) \ + or int(mres_v) < res_i or int(mres_v) > MAX_MATERIAL_RESOLUTION \ + or (res_i > 1 and (int(mres_v) - 1) % (res_i - 1) != 0): + errors.append("V15: terrain.material_resolution muss eine ganze Zahl %d–%d sein, deren (Wert−1) ein Vielfaches von (resolution−1) ist (ist %s)." % [ + res_i, MAX_MATERIAL_RESOLUTION, str(mres_v)]) + else: + mres_i = int(mres_v) + var mats = terrain.get("materials_r8_b64", "") + if mats is String and (mats as String) != "": + var mraw := Marshalls.base64_to_raw(mats as String) + if mraw.size() != mres_i * mres_i: + errors.append("V15: materials_r8_b64 dekodiert zu %d Bytes, erwartet %d (= material_resolution²)." % [ + mraw.size(), mres_i * mres_i]) + else: + for v in mraw: + if int(v) >= MATERIAL_IDS.size(): + errors.append("V15: materials_r8_b64 enthält unbekannte Material-ID %d (erlaubt: 0–%d)." % [ + int(v), MATERIAL_IDS.size() - 1]) + break + elif not (mats is String): + errors.append("V15: materials_r8_b64 muss ein Base64-String sein (ist %s)." % str(mats)) + errors.append_array(_validate_mix(terrain, mres_i)) + # V20 (Karte t_28fdef03): Wasser-Level (optional). + errors.append_array(_validate_wasser(terrain)) + return errors + + +static func _validate_bounds(bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var mn = bounds.get("min", null) + var mx = bounds.get("max", null) + if not (mn is Array) or not (mx is Array) or (mn as Array).size() != 3 \ + or (mx as Array).size() != 3: + errors.append("V4: bounds.min/max müssen je [x, y, z] sein.") + return errors + for i in 3: + if not _is_finite_num(mn[i]) or not _is_finite_num(mx[i]): + errors.append("V9: bounds enthalten nicht-endliche Werte.") + return errors + if float(mx[i]) <= float(mn[i]): + errors.append("V14: bounds.max[%d] muss > bounds.min[%d] sein." % [i, i]) + var extent := Vector3(float(mx[0]) - float(mn[0]), float(mx[1]) - float(mn[1]), float(mx[2]) - float(mn[2])) + if extent.length() > MAX_BOUNDS_EXTENT_M: + errors.append("V14: bounds-Diagonale %.0f m > %.0f m." % [extent.length(), MAX_BOUNDS_EXTENT_M]) + return errors + + +static func _validate_objects(objects: Array, bounds: Dictionary, manifest: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if objects.size() > MAX_OBJECTS: + errors.append("V8: %d Objekte > Limit %d." % [objects.size(), MAX_OBJECTS]) + return errors + var lib: Dictionary = _manifest_or(manifest).get("objects", {}) + var total_poly := 0.0 + for i in objects.size(): + var obj = objects[i] + if not (obj is Dictionary): + errors.append("V4: objects[%d] ist kein Objekt." % i) + continue + var oid = obj.get("id", "") + if not (oid is String) or not lib.has(oid): + errors.append("V7: objects[%d]: unbekannte Objekt-ID '%s' (nicht in der Bibliothek)." % [i, str(oid)]) + continue + var pos = obj.get("pos", null) + if not (pos is Array) or (pos as Array).size() != 3 \ + or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]): + errors.append("V9: objects[%d]: pos muss [x, y, z] mit endlichen Werten sein." % i) + elif not _inside_bounds(pos, bounds): + errors.append("V9: objects[%d] (%s) liegt außerhalb der Match-Grenzen." % [i, str(oid)]) + var scale = obj.get("scale", 1.0) + if not _is_finite_num(scale) or float(scale) < 0.05 or float(scale) > 50.0: + errors.append("V10: objects[%d]: scale muss 0.05–50 sein (ist %s)." % [i, str(scale)]) + continue + var rot = obj.get("rot_y_deg", 0.0) + if not _is_finite_num(rot): + errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i) + # V17 (Karte t_e166e656, Platzierungsfreiheit): optionales `rot` — + # Euler x/y/z in Grad (Godot-Konvention YXZ). Vorhanden → er ersetzt + # rot_y_deg KOMPLETT (3-Achsen-Kippen); fehlt er → Fallback rot_y_deg. + # Identischer Check wie im Match-Server (svmap_loader._validate_objects). + var rot_v: Variant = obj.get("rot", null) + if rot_v != null: + if not (rot_v is Array) or (rot_v as Array).size() != 3 \ + or not _is_finite_num(rot_v[0]) \ + or not _is_finite_num(rot_v[1]) \ + or not _is_finite_num(rot_v[2]): + errors.append("V17: objects[%d]: rot muss [x, y, z] mit endlichen Zahlen sein (ist %s)." % [i, str(rot_v)]) + # V16 (Karte t_f5935023): optionales `sym` (Symmetrie-Partner-Kennung). + # Editor-Format-Erweiterung: der Match-Server ignoriert unbekannte + # Felder, die Prüfsumme läuft über die ganze Payload → kompatibel. + # Fehlt das Feld (alte Karten) → keine Prüfung, alles unverändert. + var sym = obj.get("sym", null) + if sym != null: + if not (sym is String): + errors.append("V16: objects[%d]: sym muss ein String sein (ist %s)." % [i, str(sym)]) + elif (sym as String).strip_edges().is_empty(): + errors.append("V16: objects[%d]: sym darf nicht leer sein." % i) + elif (sym as String).length() > SYM_KEY_MAX_LEN: + errors.append("V16: objects[%d]: sym länger als %d Zeichen." % [i, SYM_KEY_MAX_LEN]) + # V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes) + var poly = float(lib[oid].get("poly", 0)) + total_poly += poly * float(scale) * float(scale) + # V16: eine sym-Kennung verbindet GENAU EIN Paar — mehr als 2 Objekte + # mit derselben Kennung sind ein Verkettungsfehler. + var sym_counts: Dictionary = {} + for obj2 in objects: + if not (obj2 is Dictionary): + continue + var s2 = (obj2 as Dictionary).get("sym", null) + if s2 is String and (s2 as String) != "": + var k := str(s2) + sym_counts[k] = int(sym_counts.get(k, 0)) + 1 + for k2 in sym_counts.keys(): + if int(sym_counts[k2]) > 2: + errors.append("V16: sym-Kennung '%s' verbindet %d Objekte — ein Paar hat genau 2." % [ + k2, int(sym_counts[k2])]) + if total_poly > MAX_TOTAL_POLY: + errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY]) + return errors + + +static func _validate_spawns(zones: Dictionary, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var seen_ids := {} + for team in [0, 1]: + var key := str(team) + if not zones.has(key) or not (zones[key] is Dictionary): + errors.append("V11: spawn_zones für Team %d fehlt." % team) + continue + var points = zones[key].get("points", []) + if not (points is Array) or points.size() < MIN_SPAWN_POINTS_PER_TEAM: + errors.append("V11: Team %d hat %s Spawn-Punkte, mindestens %d nötig." % [ + team, str(points.size()), MIN_SPAWN_POINTS_PER_TEAM]) + continue + for i in points.size(): + var p = points[i] + if not (p is Dictionary): + errors.append("V11: Team %d Punkt %d ist kein Objekt." % [team, i]) + continue + var pid = p.get("id", "") + if not (pid is String) or (pid as String).strip_edges().is_empty(): + errors.append("V11: Team %d Punkt %d: id fehlt/leer." % [team, i]) + elif seen_ids.has(pid): + errors.append("V11: Spawn-ID '%s' ist doppelt (Team %d, Punkt %d)." % [pid, team, i]) + else: + seen_ids[pid] = true + var pos = p.get("pos", null) + if not (pos is Array) or (pos as Array).size() != 3 \ + or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]): + errors.append("V9: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(pid)) + elif not _inside_bounds(pos, bounds): + errors.append("V9: Spawn '%s' liegt außerhalb der Match-Grenzen." % str(pid)) + if p.has("yaw_deg") and not _is_finite_num(p["yaw_deg"]): + errors.append("V9: Spawn '%s': yaw_deg muss endlich sein." % str(pid)) + return errors + + +static func _validate_checksum(data: Dictionary) -> PackedStringArray: + if not data.has("checksum") or not (data["checksum"] is Dictionary): + return PackedStringArray(["V4: checksum-Block fehlt oder ist kein Objekt."]) + var cs: Dictionary = data["checksum"] + var algo = cs.get("algorithm", "") + var value = cs.get("value", "") + if algo != "sha256": + return PackedStringArray(["V13: checksum.algorithm muss 'sha256' sein (ist %s)." % str(algo)]) + if not (value is String) or not (value as String).is_valid_hex_number() \ + or (value as String).length() != 64: + return PackedStringArray(["V13: checksum.value muss ein 64-Zeichen-Hex-String sein."]) + # Prüfsummen-Schema v2 (Karte t_9814f18f, FirstFight-Vorfall 20.10.): der + # Hash läuft über die quantisierte Payload (12 signifikante Stellen) — + # formatunabhängig. Ein fremder Writer (z. B. Python-repr-Floats mit 15+ + # Stellen) erzeugt denselben quantisierten Text wie Godot selbst. + # Legacy (schema-Feld fehlt): Hash über die VOLLE Textform — beim Laden + # als Fallback akzeptiert, sign_map schreibt beim nächsten Save v2. + var schema := str(cs.get("schema", "legacy")) + var expected := _payload_sha256_v2(data) if schema == "v2" else _payload_sha256(data) + if (value as String).to_lower() != expected: + return PackedStringArray(["V13: Prüfsumme stimmt nicht — die Map wurde nach dem Speichern verändert oder ist beschädigt."]) + return PackedStringArray() + + +# ---------------------------------------------------------------- Prüfung -- + +## SHA-256 über die kanonisierte Payload (alles außer 'checksum'). +## Kanonisierung: alle Zahlen rekursiv → float (sonst unterscheiden sich +## save- und load-seitiges JSON.stringify bei int/float — docs/map_format.md §5). +## EXAKT dieselbe Prozedur wie der Server-Loader → Prüsummen sind kompatibel. +static func _payload_sha256(data: Dictionary) -> String: + var payload: Dictionary = _canonicalize(data) + payload.erase("checksum") + var text := JSON.stringify(payload, JSON_INDENT) + return text.sha256_text() + + +## SHA-256 über die quantisierte Payload (Schema v2, Karte t_9814f18f): +## Kanonisieren → alle Floats auf 12 signifikante Stellen quantisieren → +## stringify → sha256. Quantisierung macht den Hash formatunabhängig: +## 14-stelliger Godot-Text und Python-repr desselben quantisierten Werts +## erzeugen denselben Hash (beide laufen durch denselben Godot-Parser + +## denselben stringify-Snap). Details: docs/map_format.md §5. +static func _payload_sha256_v2(data: Dictionary) -> String: + var payload: Dictionary = _canonicalize(data) + payload.erase("checksum") + _quantisieren_deep(payload) + var text := JSON.stringify(payload, JSON_INDENT) + return text.sha256_text() + + +## Float-Quantisierung auf 12 signifikante Stellen (Schema v2). +## +## Zwei Stufen: +## 1. Mathematisch: round(a / 10^(lg-11)) · 10^(lg-11) mit lg = Exponent der +## führenden Zehnerpotenz (log10). Werte unter 1e-12 → 0.0 (unter der +## 12-sig-Auflösung; Positionen sind Meter — Sub-Pikometer ist gameplay-egal). +## 2. SNAP über die Godot-Textform (JSON.stringify): das Runden auf 12 sig +## kann mathematisch einen "Rausch-Double" hinterlassen (z. B. +## 983194046021 · 1e-10 → 98.31940460210001). Der stringify-Text (max +## 15 signifikante Stellen, Godot-kanonisch) zurückgeparst liefert den +## DOUBLE, den jeder Godot-Leser aus diesem Text bekommt — der Snap +## macht quant(v) idempotent und damit den Hash reproduzierbar. +## Geparst wird IMMER über JSON.parse_string/stringify — so erzeugen +## Produzenten mit anderem Float-Textformat (Python-repr & Co.) denselben +## quantisierten double, sofern die Texte denselben Wert darstellen. +static func _quantisieren(v: float) -> float: + if not is_finite(v): + return v # NaN/INF — V9 fängt sie vor dem Hash ab; unverändert lassen. + if v == 0.0: + return 0.0 + var a := absf(v) + var lg: float = floor(log(a) / LOG10) + if lg >= 12.0: + var m: float = pow(10.0, lg - 11.0) + return JSON.parse_string(JSON.stringify(signf(v) * roundf(a / m) * m)) + if lg <= -12.0: + return 0.0 + var mant: float = a / pow(10.0, lg) + var r: float = roundf(mant * 100000000000.0) / 100000000000.0 + if r >= 1000000000000.0: + # Aufrunden in die nächste Dekade (z. B. 999.9999999995 → 1000) + lg += 1.0 + r /= 10.0 + return JSON.parse_string(JSON.stringify(r * pow(10.0, lg) * signf(v))) + + +const LOG10 := 2.302585092994046 + + +static func _quantisieren_deep(value: Variant) -> void: + if value is Array: + for i in value.size(): + var item: Variant = value[i] + if item is float: + value[i] = _quantisieren(item) + elif item is Array or item is Dictionary: + _quantisieren_deep(item) + elif value is Dictionary: + for k in value.keys(): + var child: Variant = value[k] + if child is float: + value[k] = _quantisieren(child) + elif child is Array or child is Dictionary: + _quantisieren_deep(child) + + +static func _canonicalize(value: Variant) -> Variant: + if value is Array: + var arr := [] + for v in value: + arr.append(_canonicalize(v)) + return arr + if value is Dictionary: + var dict := {} + for k in value.keys(): + dict[str(k)] = _canonicalize(value[k]) + return dict + if value is int: + return float(value) + if value is bool or value is String or value is float or value == null: + return value + # PackedByteArray & Co. dürfen im svmap-Dict nicht vorkommen — als String. + return str(value) + + +static func _is_finite_num(v: Variant) -> bool: + return (v is int or v is float) and is_finite(float(v)) + + +static func _inside_bounds(pos: Array, bounds: Dictionary) -> bool: + var mn: Array = bounds["min"] + var mx: Array = bounds["max"] + for i in 3: + if float(pos[i]) < float(mn[i]) - BOUNDS_TOLERANCE_M \ + or float(pos[i]) > float(mx[i]) + BOUNDS_TOLERANCE_M: + return false + return true + + +# ------------------------------------------------------------------ Utils -- + +static func _manifest_or(manifest: Dictionary) -> Dictionary: + if not manifest.is_empty(): + return manifest + if FileAccess.file_exists(MANIFEST_PATH): + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH)) + if parsed is Dictionary: + return parsed + return {} + + +## Höhenfeld dekodieren: uint16 little-endian, row-major, absolut in Metern. +## h = min_height + (v / 65535) * (max_height - min_height) +static func decode_heights(terrain: Dictionary) -> PackedFloat32Array: + var res := int(terrain["resolution"]) + var raw := Marshalls.base64_to_raw(str(terrain["heightmap_r16_b64"])) + var hmin := float(terrain["min_height_m"]) + var hmax := float(terrain["max_height_m"]) + var out := PackedFloat32Array() + out.resize(res * res) + for i in res * res: + var v: int = raw.decode_u16(i * 2) + out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin) + return out + + +## Material-Schicht dekodieren (v2): uint8 je Sample, 0=Gras/1=Sand/2=Fels. +## Fehlt das Feld (Legacy-v1) → alles Gras (Default). Identische Indexierung +## wie das Höhenfeld — TerrainField.apply_material_brush arbeitet darauf. +static func decode_materials(terrain: Dictionary) -> PackedByteArray: + var res := material_resolution(terrain) + var out := PackedByteArray() + out.resize(maxi(res * res, 0)) # PackedByteArray füllt mit 0 = Gras + var mats = terrain.get("materials_r8_b64", "") + if mats is String and (mats as String) != "": + var mraw := Marshalls.base64_to_raw(mats as String) + var n := res * res + for i in n: + if i < mraw.size() and int(mraw[i]) < MATERIAL_IDS.size(): + out[i] = mraw[i] + return out + + +## V15b (Karte 25.10., weiche Übergänge): optionale Misch-Schicht — +## materials_b_r8_b64 (zweites Material je Punkt) + materials_w_r8_b64 (Anteil +## des zweiten Materials, 0–255). Nur BEIDE oder KEINE; Länge material_resolution². +static func _validate_mix(terrain: Dictionary, mres: int) -> PackedStringArray: + var errors := PackedStringArray() + var hb := terrain.has("materials_b_r8_b64") + var hw := terrain.has("materials_w_r8_b64") + if not hb and not hw: + return errors + if hb != hw: + errors.append("V15: materials_b_r8_b64 und materials_w_r8_b64 gibt es nur zusammen.") + return errors + var b = terrain["materials_b_r8_b64"] + var w = terrain["materials_w_r8_b64"] + if not (b is String) or not (w is String): + errors.append("V15: Misch-Schicht muss aus Base64-Strings bestehen.") + return errors + var braw := Marshalls.base64_to_raw(b as String) + var wraw := Marshalls.base64_to_raw(w as String) + if braw.size() != mres * mres or wraw.size() != mres * mres: + errors.append("V15: Misch-Schicht hat %d/%d Bytes, erwartet je %d (= material_resolution²)." % [ + braw.size(), wraw.size(), mres * mres]) + return errors + for v in braw: + if int(v) >= MATERIAL_IDS.size(): + errors.append("V15: materials_b_r8_b64 enthält unbekannte Material-ID %d." % int(v)) + break + return errors + + +## Misch-Schicht dekodieren: [b, w] (je material_resolution², fehlt sie → Nullen). +static func decode_mix(terrain: Dictionary) -> Array: + var n := material_resolution(terrain) + var b := PackedByteArray() + var w := PackedByteArray() + b.resize(maxi(n * n, 0)) + w.resize(maxi(n * n, 0)) + var bs = terrain.get("materials_b_r8_b64", "") + var ws = terrain.get("materials_w_r8_b64", "") + if bs is String and ws is String and (bs as String) != "" and (ws as String) != "": + var braw := Marshalls.base64_to_raw(bs as String) + var wraw := Marshalls.base64_to_raw(ws as String) + if braw.size() == b.size() and wraw.size() == w.size(): + for i in braw.size(): + if int(braw[i]) < MATERIAL_IDS.size(): + b[i] = braw[i] + w = wraw + return [b, w] + + +## Misch-Schicht schreiben — ganz ohne Mischung (alle w = 0) fallen die Felder +## weg (Datei bleibt so klein wie vorher, alte Leser unberührt). +static func encode_mix(terrain: Dictionary, b: PackedByteArray, w: PackedByteArray) -> void: + var any := false + for v in w: + if v != 0: + any = true + break + if not any: + terrain.erase("materials_b_r8_b64") + terrain.erase("materials_w_r8_b64") + return + terrain["materials_b_r8_b64"] = encode_materials(b) + terrain["materials_w_r8_b64"] = Marshalls.raw_to_base64(w) + + +## Raster der Material-Schicht (feines Raster, Karte 24.10.) — ohne Feld +## gilt das Höhenraster (alte Maps). +static func material_resolution(terrain: Dictionary) -> int: + var v = terrain.get("material_resolution", null) + if v is int or v is float: + return int(v) + return int(terrain.get("resolution", 0)) + + +## Ziel-Raster für eine Karte: ~MATERIAL_TARGET_M pro Punkt, auf dem +## Höhenraster ausgerichtet, gedeckelt auf MAX_MATERIAL_RESOLUTION. +## 1024 m / res 129 → 1025 (1 m); 256 m / 129 → 257 (1 m); 2048 m → 1025 (2 m). +static func material_resolution_for(res: int, size_x: float, size_z: float) -> int: + if res < 2: + return res + var cell := maxf(size_x, size_z) / float(res - 1) + var k := maxi(1, int(ceilf(cell / MATERIAL_TARGET_M - 0.001))) + while k > 1 and (res - 1) * k + 1 > MAX_MATERIAL_RESOLUTION: + k -= 1 + return (res - 1) * k + 1 + + +## Material-Schicht auf ein anderes Raster umrechnen (nächster Punkt über die +## normierte Position — beide Raster spannen dieselbe Kartenfläche auf). +static func resample_materials(mats: PackedByteArray, from_res: int, to_res: int) -> PackedByteArray: + var out := PackedByteArray() + out.resize(maxi(to_res * to_res, 0)) + if from_res < 2 or to_res < 2 or mats.size() < from_res * from_res: + return out + var f := float(from_res - 1) / float(to_res - 1) + for j in to_res: + var sj := mini(int(roundf(float(j) * f)), from_res - 1) * from_res + var row := j * to_res + for i in to_res: + out[row + i] = mats[sj + mini(int(roundf(float(i) * f)), from_res - 1)] + return out + + +## Alte Karte (Material-Raster = Höhenraster) auf das feine Raster heben. +## true = geändert. Optisch identisch (nächster Punkt), nur ab jetzt feiner malbar. +static func upgrade_material_layer(terrain: Dictionary) -> bool: + var res := int(terrain.get("resolution", 0)) + if res < 2: + return false + var target := material_resolution_for(res, float(terrain.get("size_x_m", 0.0)), + float(terrain.get("size_z_m", 0.0))) + var cur := material_resolution(terrain) + if cur >= target: + return false + var mats := resample_materials(decode_materials(terrain), cur, target) + var mix := decode_mix(terrain) + var mb := resample_materials(mix[0], cur, target) + var mw := resample_materials(mix[1], cur, target) + terrain["material_resolution"] = target + terrain["materials_r8_b64"] = encode_materials(mats) + encode_mix(terrain, mb, mw) + return true + + +## Material-ID am Höhenpunkt (x, z) des Höhenrasters — für Vertex-Farben. +static func material_at_height_point(mats: PackedByteArray, mres: int, res: int, + x: int, z: int) -> int: + if mres < 2 or res < 2 or mats.size() < mres * mres: + return 0 + var f := float(mres - 1) / float(res - 1) + return int(mats[mini(int(roundf(float(z) * f)), mres - 1) * mres + mini(int(roundf(float(x) * f)), mres - 1)]) + + +## Material-Schicht kodieren: uint8 je Sample (Werte 0..MATERIAL_IDS.size()-1). +static func encode_materials(mats: PackedByteArray) -> String: + var bytes := PackedByteArray() + bytes.resize(mats.size()) + for i in mats.size(): + bytes[i] = clampi(mats[i], 0, MATERIAL_IDS.size() - 1) + return Marshalls.raw_to_base64(bytes) + + +# ------------------------------------- Fremdformat-Dialog (t_9814f18f) ------ + +## true, wenn ein Load NUR an der Pruefsumme haengt (V13) — die Kern-Validierung +## (V3–V16) ist gruen. Kriterium fuer den „Trotzdem laden (Pruefsumme erneuern)“- +## Dialog im Editor: die Datei kommt aus einem fremden Writer (FirstFight-Fall +## 20.10.: Python-repr-Floats), der Inhalt selbst ist intakt. +static func checksum_only_failure(errors: Array) -> bool: + if errors.is_empty(): + return false + for e in errors: + if not str(e).begins_with("V13:"): + return false + return true + + +## true, wenn ein Load NUR an unbekannten Objekt-IDs (V7) und/oder der Prüfsumme +## (V13) hängt — Kriterium für die Migration beim Öffnen (SvmapMigration). +static func migrierbar(errors: Array) -> bool: + if errors.is_empty(): + return false + var v7 := false + for e in errors: + if str(e).begins_with("V7:"): + v7 = true + elif not str(e).begins_with("V13:"): + return false + return v7 + + +## Laden mit Migration veralteter Objekt-IDs: JSON parsen, unbekannte IDs per +## SvmapMigration ersetzen/überspringen, Kern-Validierung, v2-Prüfsumme erneuern. +## Rückgabe wie load_from_file plus "migration" (Bericht von migriere_objekte). +static func load_from_file_migriert(path: String) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}, "migration": {}} + if not FileAccess.file_exists(path): + _add(result, "Datei nicht gefunden: %s" % path) + return result + var fa := FileAccess.open(path, FileAccess.READ) + if fa == null: + _add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return result + if fa.get_length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) + return result + var parsed: Variant = JSON.parse_string(fa.get_as_text()) + if parsed == null or not (parsed is Dictionary): + _add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).") + return result + var data: Dictionary = parsed + result["migration"] = SvmapMigration.migriere_objekte(data, _manifest_or({})) + var errors := _validate_core(data) + if not errors.is_empty(): + result["errors"] = Array(errors) + return result + sign_map(data) + result["ok"] = true + result["data"] = data + return result + + +## Laden mit Pruefsummen-Erneuerung (t_9814f18f, „Trotzdem laden“-Weg): +## Datei lesen + JSON parsen, dann NUR die Kern-Validierung (V3–V16 ohne +## Pruefsumme). Ist die gruen, traegt sign_map() die v2-Pruefsumme ins Dokument +## ein — beim naechsten Speichern landet sie in der Datei. Schlaegt die +## Kern-Validierung durch, bleibt der harte Abbruch (echter Schaden). +## Rueckgabe wie load_from_file: {ok, errors, data}. +static func load_from_file_resign(path: String) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}} + if not FileAccess.file_exists(path): + _add(result, "Datei nicht gefunden: %s" % path) + return result + var fa := FileAccess.open(path, FileAccess.READ) + if fa == null: + _add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return result + if fa.get_length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) + return result + var parsed: Variant = JSON.parse_string(fa.get_as_text()) + if parsed == null or not (parsed is Dictionary): + _add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).") + return result + var data: Dictionary = parsed + var errors := _validate_core(data) + if not errors.is_empty(): + result["errors"] = Array(errors) + return result + sign_map(data) + result["ok"] = true + result["data"] = data + return result + + +# ------------------------------------- Platzierungsfreiheit (Karte t_e166e656) -- + +## Objekt-Rotation als Euler-Grad (Karte t_e166e656): `rot` (Euler x/y/z in +## Grad, Godot-Konvention YXZ) vorhanden → verwenden; fehlt er → Fallback +## `rot_y_deg` (nur Y — alte Karten unverändert). Identische Regel im +## Quest-Client (svmap_world._objekt_rotation) und Match-Server +## (svmap_loader._objekt_rotation) — Identitäts-Vertrag über alle drei Repos. +static func objekt_rotation(obj: Dictionary) -> Vector3: + var rot_v: Variant = obj.get("rot", null) + if rot_v is Array and (rot_v as Array).size() == 3: + var a: Array = rot_v + return Vector3(float(a[0]), float(a[1]), float(a[2])) + return Vector3(0.0, float(obj.get("rot_y_deg", 0.0)), 0.0) + + +## rot-Feld KORREKT ins Objekt schreiben (Editor-Seite der Regel): nur wenn +## tatsächlich um X oder Z gekippt ist, wird `rot` geschrieben (zusätzlich +## bleibt rot_y_deg immer aktuell = die Y-Komponente, damit alte Builds die +## Drehung wenigstens um Y richtig lesen). x = z = 0 → Feld wieder entfernen +## (Datei bleibt legacy-sauber). +static func rot_setzen(obj: Dictionary, euler: Vector3) -> void: + obj["rot_y_deg"] = euler.y + if absf(euler.x) < 0.0001 and absf(euler.z) < 0.0001: + obj.erase("rot") + else: + obj["rot"] = [euler.x, euler.y, euler.z] + + +# ------------------------------------------------- Wasser (Karte t_28fdef03) -- + +## V20 (Karte t_28fdef03): optionales svmap-Feld terrain.water_level (float y). +## Abwesenheit = kein Wasser (Legacy-Maps unverändert). Gleiche Regel wie im +## Match-Server (svmap_loader._validate_wasser): endliche Zahl im/am Terrain- +## Höhenbereich ±10 m — sonst wäre das Level sinnlos (unendlich tief unter +## Terrain oder meilen über dem Gelände schwebend). +static func _validate_wasser(terrain: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var wl = terrain.get("water_level", null) + if wl == null: + return errors + if not _is_finite_num(wl): + errors.append("V20: terrain.water_level muss eine endliche Zahl sein (ist %s)." % str(wl)) + return errors + var hmin = terrain.get("min_height_m", 0.0) + var hmax = terrain.get("max_height_m", 0.0) + if not _is_finite_num(hmin) or not _is_finite_num(hmax): + return errors # V6 meldet das bereits; hier nichts doppelt. + if float(wl) < float(hmin) - 10.0 or float(wl) > float(hmax) + 10.0: + errors.append("V20: terrain.water_level (%.2f m) liegt außerhalb des Terrain-Höhenbereichs [%.2f, %.2f] ±10 m." % [ + float(wl), float(hmin), float(hmax)]) + return errors diff --git a/svmap/svmap_io.gd.uid b/svmap/svmap_io.gd.uid new file mode 100644 index 0000000..3ef96d6 --- /dev/null +++ b/svmap/svmap_io.gd.uid @@ -0,0 +1 @@ +uid://b0n1rh37a72rd diff --git a/svmap/svmap_migration.gd b/svmap/svmap_migration.gd new file mode 100644 index 0000000..5908844 --- /dev/null +++ b/svmap/svmap_migration.gd @@ -0,0 +1,67 @@ +extends RefCounted +class_name SvmapMigration +## Migration veralteter Objekt-IDs beim Laden (Karte 25.10.: test.svmap mit +## gelöschten Pilz-Objekten ließ sich nicht mehr öffnen, V7). +## +## Regel: Eine ID, die nicht (mehr) in der Bibliothek steht, wird per Ersatz-Tabelle +## durch ein passendes Pack-Objekt ersetzt (Position, Drehung, Skalierung, Gruppe +## bleiben). Gibt es keinen Ersatz, wird das Objekt übersprungen und gemeldet — +## nie ein Absturz, nie stilles Verschwinden. Die Datei selbst ändert sich erst +## beim Speichern (Undo/Neu-Laden bleibt möglich). + +## Präfix → Ersatz-ID (Pilze waren kleine kollisionslose Bodendeko → Gras 003). +const ERSATZ_PRAEFIX := { + "pilz_": "grass_003", +} + + +## Ersatz-ID für eine unbekannte ID ("" = keiner). +static func ersatz_fuer(oid: String, lib: Dictionary) -> String: + for praefix in ERSATZ_PRAEFIX: + if oid.begins_with(praefix): + var neu: String = ERSATZ_PRAEFIX[praefix] + return neu if lib.has(neu) else "" + return "" + + +## Migriert data["objects"] in place. Rückgabe: +## {"ersetzt": {alt_id: {"neu": id, "anzahl": n}}, "entfernt": {alt_id: n}, "gesamt": n} +static func migriere_objekte(data: Dictionary, manifest: Dictionary) -> Dictionary: + var bericht := {"ersetzt": {}, "entfernt": {}, "gesamt": 0} + var objects: Variant = data.get("objects", null) + if not (objects is Array): + return bericht + var lib: Dictionary = manifest.get("objects", {}) + var neu_liste: Array = [] + for obj in (objects as Array): + if not (obj is Dictionary) or not (obj.get("id", null) is String) \ + or lib.has(obj["id"]): + neu_liste.append(obj) + continue + var alt: String = obj["id"] + var ersatz := ersatz_fuer(alt, lib) + bericht["gesamt"] += 1 + if ersatz.is_empty(): + bericht["entfernt"][alt] = int(bericht["entfernt"].get(alt, 0)) + 1 + continue + var kopie: Dictionary = (obj as Dictionary).duplicate(true) + kopie["id"] = ersatz + neu_liste.append(kopie) + var eintrag: Dictionary = bericht["ersetzt"].get(alt, {"neu": ersatz, "anzahl": 0}) + eintrag["anzahl"] = int(eintrag["anzahl"]) + 1 + bericht["ersetzt"][alt] = eintrag + if int(bericht["gesamt"]) > 0: + data["objects"] = neu_liste + return bericht + + +## Menschenlesbare Zusammenfassung (deutsch) für die Statuszeile. +static func zusammenfassung(bericht: Dictionary, lib: Dictionary) -> String: + var teile: PackedStringArray = [] + for alt in bericht.get("ersetzt", {}): + var e: Dictionary = bericht["ersetzt"][alt] + var neu_name := str(lib.get(e["neu"], {}).get("name_de", e["neu"])) + teile.append("%d× „%s“ durch „%s“ ersetzt" % [e["anzahl"], alt, neu_name]) + for alt in bericht.get("entfernt", {}): + teile.append("%d× „%s“ übersprungen (kein Ersatz)" % [bericht["entfernt"][alt], alt]) + return ", ".join(teile) diff --git a/svmap/svmap_migration.gd.uid b/svmap/svmap_migration.gd.uid new file mode 100644 index 0000000..c5742e8 --- /dev/null +++ b/svmap/svmap_migration.gd.uid @@ -0,0 +1 @@ +uid://dhfmxbbnonapa diff --git a/svmap/svmap_tint.gd b/svmap/svmap_tint.gd new file mode 100644 index 0000000..501bb06 --- /dev/null +++ b/svmap/svmap_tint.gd @@ -0,0 +1,149 @@ +extends RefCounted +class_name SvmapTint +## Per-Objekt-Farbe (Tint) für den Map Builder — Karte t_b0edb807. +## +## Format (svmap, OPTIONALES Feld je Objekt): +## "tint": [r, g, b] — Display-Farben (sRGB, 0..1, auf 4 Stellen gerundet) +## +## FARBRAUM-REGEL (Lektion D9, 19.10.): gespeichert wird der ANZEIGE-Wert +## (sRGB, wie der Godot-Farbpicker ihn zeigt). Beim Anwenden wird der Wert +## DIREKT in `albedo_color` geschrieben — Godot interpretiert albedo_color +## als Anzeige-Farbe (flach beleuchtet zeigt der Pixel exakt diesen Wert). +## Das ist bewusst NICHT der glTF-Weg (dort wäre baseColorFactor linear): +## solange Editor und Client denselben Wert denselben Weg schreiben, +## rendern sie identisch — und genau das garantiert dieser Helfer. +## +## Semantik des Anwendens: `albedo_color` wird ERSETZT (nicht multipliziert). +## - Ungetexturierte Materialien (Military-Desert-Pack, bewiesenermaßen ohne +## Texturen) nehmen die gewählte Farbe 1:1 an — das ist die intuitive +## Erwartung an „Farbe wählen“. +## - Texturierte Materialien (Alt-Bestand/Waffen) bekommen die Textur × +## Farbe (Godot multipliziert albedo_texture × albedo_color) — Weiß der +## Palette lässt sie unverändert, dunkle Palette tönt sie ab. +## „Zurücksetzen (Pack-Farbe)“ entfernt das Feld; ohne Feld läuft ALLES wie +## vorher (Rückwärtskompatibilität: alte Maps ohne tint laden unverändert). +## +## Anwendung als SURFACE-OVERRIDE-Materialien (Kopien): die Original- +## Materialien am Mesh bleiben unangetastet, das Feld ist damit reversibel, +## und das Auswahl-Orange (material_override) gewinnt weiterhin — Godot +## gibt material_override Vorrang vor surface_override_materials. +## +## DIESE DATEI ist bewusst 1:1 im Client-Repo vorhanden +## (scopedvr-client scripts/world/svmap_tint.gd) — Editor und Client müssen +## denselben Code nutzen, damit gespeicherte Farbe = gespielte Farbe. +## Bei Änderungen: BEIDE Kopien identisch anpassen. + +const FIELD := "tint" + +## Feste Palette (Namen wie auf der Karte vereinbart). Reihenfolge = UI-Reihenfolge. +const PALETTE := [ + ["Wüstensand", Color(0.76, 0.70, 0.50)], + ["Oliv", Color(0.50, 0.52, 0.30)], + ["Tarn-Grün", Color(0.30, 0.40, 0.24)], + ["Stahlgrau", Color(0.45, 0.47, 0.50)], + ["Holzbraun", Color(0.55, 0.40, 0.25)], + ["Dunkelbraun", Color(0.32, 0.23, 0.14)], + ["Weiß", Color(0.92, 0.92, 0.92)], +] + +const EPS := 0.003 # Farb-Gleichheit (4 Nachkommastellen gerundet) + + +## Objekt hat ein gültiges tint-Feld? +static func hat(obj: Dictionary) -> bool: + return gueltig(obj.get(FIELD, null)) + + +## Feld typo-/bereichsgeprüft: Array mit 3 Zahlen 0..1. +static func gueltig(v: Variant) -> bool: + if not (v is Array) or (v as Array).size() != 3: + return false + for c in v: + if not (c is float or c is int): + return false + if float(c) < 0.0 or float(c) > 1.0: + return false + return true + + +## Farbe eines Objekts lesen (ohne Feld → Weiß = neutral, Anzeigewert). +static func von(obj: Dictionary) -> Color: + var v: Variant = obj.get(FIELD, null) + if gueltig(v): + var a: Array = v + return Color(float(a[0]), float(a[1]), float(a[2])) + return Color.WHITE + + +## Farbe speichern (Anzeigewert, 4 Stellen — reicht für Visuelles, klein im JSON). +static func setze(obj: Dictionary, c: Color) -> void: + obj[FIELD] = [ + roundf(c.r * 10000.0) / 10000.0, + roundf(c.g * 10000.0) / 10000.0, + roundf(c.b * 10000.0) / 10000.0, + ] + + +## Feld entfernen („Zurücksetzen (Pack-Farbe)“). +static func entferne(obj: Dictionary) -> void: + obj.erase(FIELD) + + +## Zwei tint-Werte „gleich“ (Anzeige-Toleranz)? +static func gleich(a: Color, b: Color) -> bool: + return absf(a.r - b.r) <= EPS and absf(a.g - b.g) <= EPS and absf(a.b - b.b) <= EPS + + +## Paletten-Namen zu einer Farbe finden („“ = keine Palette-Farbe / gemischt). +static func palette_name(c: Color) -> String: + for eintrag in PALETTE: + if gleich(c, eintrag[1]): + return str(eintrag[0]) + return "" + + +## Tint auf eine Visual-Struktur anwenden: ALLE MeshInstance3D darunter +## bekommen pro Fläche eine KOPIE ihres aktiven Materials mit der neuen +## albedo_color. Vorhandene Surface-Overrides werden ersetzt (idempotent). +## material_override (Auswahl-Orange) bleibt unangetastet und gewinnt. +static func anwenden(visual: Node, c: Color) -> void: + if visual == null or not is_instance_valid(visual): + return + _walk_anwenden(visual, c) + + +static func _walk_anwenden(n: Node, c: Color) -> void: + if n is MeshInstance3D: + var mi := n as MeshInstance3D + if mi.mesh != null: + for s in mi.mesh.get_surface_count(): + var mat := mi.get_active_material(s) + # Ausgewählte Objekte (material_override = Auswahl-Material) + # überspringt der AUFRUFER, indem er tint erst nach dem + # Abwählen anwendet — hier nichts Sonderbehandeln. + if mat is BaseMaterial3D: + var dup: BaseMaterial3D = (mat as BaseMaterial3D).duplicate() + dup.albedo_color = Color(c.r, c.g, c.b, + (mat as BaseMaterial3D).albedo_color.a) + mi.set_surface_override_material(s, dup) + for kind in n.get_children(): + _walk_anwenden(kind, c) + + +## Tint visuell zurücknehmen: alle gesetzten Surface-Override-Materialien +## entfernen → die Original-Materialien am Mesh zeigen wieder die Pack-Farbe +## (Platzhalter fallen auf ihr material_override/_base zurück). +static func zuruecknehmen(visual: Node) -> void: + if visual == null or not is_instance_valid(visual): + return + _walk_zurueck(visual) + + +static func _walk_zurueck(n: Node) -> void: + if n is MeshInstance3D: + var mi := n as MeshInstance3D + if mi.mesh != null: + for s in mi.mesh.get_surface_count(): + mi.set_surface_override_material(s, null) + for kind in n.get_children(): + _walk_zurueck(kind) diff --git a/svmap/svmap_tint.gd.uid b/svmap/svmap_tint.gd.uid new file mode 100644 index 0000000..db1fa23 --- /dev/null +++ b/svmap/svmap_tint.gd.uid @@ -0,0 +1 @@ +uid://cx6cfhyxw42f diff --git a/svmap/terrain_field.gd b/svmap/terrain_field.gd new file mode 100644 index 0000000..dc7c698 --- /dev/null +++ b/svmap/terrain_field.gd @@ -0,0 +1,456 @@ +extends RefCounted +class_name TerrainField +## Höhenfeld-Mathematik des Map-Editors (M2, Karte t_9aba04f1 — Terrain-Werkzeug). +## +## Alles static + rein: arbeitet auf einem dekodierten PackedFloat32Array +## (res×res, row-major, -Z → +Z) statt auf Base64-Strings — das Terrain-Werkzeug +## dekodiert EINMAL pro Stroke und arbeitet flott darauf. Das Doc (svmap-Format) +## bleibt der Besitzer der Daten: vor einem Stroke dekodieren, nach dem Stroke +## mit encode() zurück ins Doc. +## +## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik. + +enum Mode { RAISE, LOWER, SMOOTH, PAINT, FLATTEN, RAMP } + +## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber +## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim +## Zeichnen, Ziel 60+ FPS). Neu-Maps werden direkt mit 129 angelegt. +const EDITOR_MAX_RESOLUTION := 129 + + +## Höhe an Welt-X/Z auf einem bereits dekodierten Feld (bilinear). +## Gleiche Geometrie-Konvention wie SvmapDocument.terrain_height_at, aber ohne +## Base64-Neudekodierung pro Aufruf (macht den Pinsel flott). +static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + x: float, z: float) -> float: + var last := float(res - 1) + var fx := clampf((x / size_x + 0.5) * last, 0.0, last) + var fz := clampf((z / size_z + 0.5) * last, 0.0, last) + var xi := int(floor(fx)) + var zi := int(floor(fz)) + var tx := fx - float(xi) + var tz := fz - float(zi) + var xi1 := mini(xi + 1, res - 1) + var zi1 := mini(zi + 1, res - 1) + var h00 := heights[zi * res + xi] + var h10 := heights[zi * res + xi1] + var h01 := heights[zi1 * res + xi] + var h11 := heights[zi1 * res + xi1] + return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) + + +## Pinsel-Gewicht über einen einzigen Regler (Karte 25.10.): t = 1 − d/radius, +## w = lerp(t, t·t, falloff). falloff 0 = linear (hart, breite Mitte), +## 1 = quadratisch (weich, spitze Mitte). Ausserhalb des Radius 0. +static func falloff_weight(d: float, radius: float, falloff: float) -> float: + if radius <= 0.0 or d >= radius: + return 0.0 + var t := clampf(1.0 - d / radius, 0.0, 1.0) + return lerpf(t, t * t, clampf(falloff, 0.0, 1.0)) + + +## Pinsel-Stärke aus Eingabe: Pen-Druck (>0) gewinnt, sonst Controller-Trigger +## (> 0.02), sonst Maus = 1.0. Immer 0.05…1. +static func pressure_of(pen: float, trigger: float) -> float: + if pen > 0.0: + return clampf(pen, 0.05, 1.0) + if trigger > 0.02: + return clampf(trigger, 0.05, 1.0) + return 1.0 + + +## Flatten: zieht Höhen im Kreis gewichtet zur Zielhöhe. amount = Mischfaktor 0–1. +## (Eigene Funktion, damit apply_brush-Signatur stabil bleibt.) +static func apply_flatten(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + cx: float, cz: float, radius: float, target: float, amount: float, + falloff: float) -> bool: + if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res: + return false + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + var gx := (cx / size_x + 0.5) * float(res - 1) + var gz := (cz / size_z + 0.5) * float(res - 1) + var x0 := maxi(int(floor(gx - radius / cell_x)) - 1, 0) + var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1) + var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0) + var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1) + var changed := false + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + var dx := (float(x) - gx) * cell_x + var dz := (float(z) - gz) * cell_z + var w := falloff_weight(sqrt(dx * dx + dz * dz), radius, falloff) + if w <= 0.0: + continue + var idx := z * res + x + var h := heights[idx] + var nh := lerpf(h, target, clampf(amount * w, 0.0, 1.0)) + if nh != h: + heights[idx] = nh + changed = true + return changed + + +## Rampe: Höhe linear von ha (Punkt a) nach hb (Punkt b), Breite `width` m +## quer zur Achse; Kern (halbe Breite) voll, Falloff am Rand. a/b = Welt-X/Z (Vector2). Rückgabe: geändert. +static func apply_ramp(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + a: Vector2, b: Vector2, ha: float, hb: float, width: float, falloff: float) -> bool: + if res < 2 or width <= 0.0 or heights.size() < res * res: + return false + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + var half := width * 0.5 + var ab := b - a + var len2 := ab.length_squared() + var lo := Vector2(minf(a.x, b.x) - half, minf(a.y, b.y) - half) + var hi := Vector2(maxf(a.x, b.x) + half, maxf(a.y, b.y) + half) + var x0 := maxi(int(floor((lo.x / size_x + 0.5) * float(res - 1))) - 1, 0) + var x1 := mini(int(ceil((hi.x / size_x + 0.5) * float(res - 1))) + 1, res - 1) + var z0 := maxi(int(floor((lo.y / size_z + 0.5) * float(res - 1))) - 1, 0) + var z1 := mini(int(ceil((hi.y / size_z + 0.5) * float(res - 1))) + 1, res - 1) + var changed := false + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + var p := Vector2((float(x) / float(res - 1) - 0.5) * size_x, + (float(z) / float(res - 1) - 0.5) * size_z) + var s := 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0) + var d := p.distance_to(a + ab * s) + # Innere Hälfte der Breite voll (ebene Fahrfläche), äußere Hälfte + # läuft mit dem Falloff-Regler ins Gelände aus. + var core := half * 0.5 + var w := 1.0 if d <= core else falloff_weight(d - core, half - core, falloff) + if d >= half: + w = 0.0 + if w <= 0.0: + continue + var idx := z * res + x + var h := heights[idx] + var nh := lerpf(h, lerpf(ha, hb, s), w) + if nh != h: + heights[idx] = nh + changed = true + return changed + + +## Pinsel anwenden. amount: +## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer), +## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel). +## Gewicht: Kosinus-Falloff (1 in der Mitte, 0 am Rand) — weiche Hügel statt +## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde. +static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + hmin: float, hmax: float, cx: float, cz: float, radius: float, + mode: int, amount: float, falloff: float = -1.0, target: float = 0.0) -> bool: + ## falloff < 0 = alter Kosinus-Falloff (Kompatibilität), sonst Regler-Falloff. + ## target nur für FLATTEN (Zielhöhe). + if mode == Mode.RAMP: + return false # Rampe hat ein eigenes Zwei-Punkt-API (apply_ramp) + if mode == Mode.FLATTEN: + return apply_flatten(heights, res, size_x, size_z, cx, cz, radius, target, + clampf(amount, 0.0, 1.0), maxf(falloff, 0.0)) + if mode == Mode.PAINT: + # PAINT läuft über apply_material_brush (Material-Schicht, Karte + # t_603ec1a8) — Höhe bleibt unangetastet. Hier ohne Material-Argument + # aufrufbar, aber bewusst NEUTRAL: kein Pattern, keine Höhe. + return false + if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res: + return false + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + var r_cells_x := radius / cell_x + var r_cells_z := radius / cell_z + var gx := (cx / size_x + 0.5) * float(res - 1) + var gz := (cz / size_z + 0.5) * float(res - 1) + var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0) + var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1) + var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0) + var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1) + if mode == Mode.SMOOTH: + return _apply_smooth(heights, res, hmin, hmax, gx, gz, r_cells_x, r_cells_z, + x0, x1, z0, z1, clampf(amount, 0.0, 1.0), falloff) + var changed := false + var z := z0 + while z <= z1: + var x := x0 + while x <= x1: + var dx := (float(x) - gx) * cell_x + var dz := (float(z) - gz) * cell_z + var d := sqrt(dx * dx + dz * dz) + if d >= radius: + x += 1 + continue + var w := 0.5 * (1.0 + cos(PI * d / radius)) if falloff < 0.0 \ + else falloff_weight(d, radius, falloff) + var idx := z * res + x + var h := heights[idx] + var nh := h + amount * w if mode == Mode.RAISE else h - amount * w + nh = clampf(nh, hmin, hmax) + if nh != h: + heights[idx] = nh + changed = true + x += 1 + z += 1 + return changed + + +## Material-Pinsel (Karte t_603ec1a8): setzt die Material-ID aller Zellen im +## Kreis (d < radius) auf material_id — Malen statt Formen, Höhe bleibt unangetastet. +## Kante bewusst CRISP (d < radius): Malen fühlt sich wie ein Malprogramm an, +## die Vertex-Interpolation des Meshes glättet die Grenze optisch. +## mats = dekodierte Material-Schicht (PackedByteArray, res² Einträge). +static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, size_z: float, + cx: float, cz: float, radius: float, material_id: int) -> bool: + if res < 2 or radius <= 0.0 or mats.size() < res * res: + return false + var gx := (cx / size_x + 0.5) * float(res - 1) + var gz := (cz / size_z + 0.5) * float(res - 1) + var r_cells_x := radius / (size_x / float(res - 1)) + var r_cells_z := radius / (size_z / float(res - 1)) + var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0) + var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1) + var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0) + var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1) + var changed := false + var z := z0 + while z <= z1: + var x := x0 + while x <= x1: + var dx := (float(x) - gx) * (size_x / float(res - 1)) + var dz := (float(z) - gz) * (size_z / float(res - 1)) + if dx * dx + dz * dz < radius * radius: + var idx := z * res + x + if mats[idx] != material_id: + mats[idx] = material_id + changed = true + x += 1 + z += 1 + return changed + + +## Malen MIT weichem Rand (Karte 25.10., Sly: „weicher Übergang der Texturen +## am Rand des Malwerkzeugs“). Jeder Punkt kann zwei Materialien mischen: +## a = mats (Hauptschicht), b = mix_b, Anteil von b = mix_w/255. Innerhalb +## radius·(1−weichheit) wird voll gemalt, außen fällt der Anteil des neuen +## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie +## abgeschwächt (Nachmalen am Rand macht ihn kräftiger, nicht schwächer). +## Mischung bleibt zweiteilig: das neue Material + das bisher stärkste andere +## (kommt ein drittes Material dazu, wird das schwächere der beiden ersetzt). +## staerke (0–1, Controller-Trigger/Pen-Druck) deckelt den Zielanteil. +static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArray, + mix_w: PackedByteArray, res: int, size_x: float, size_z: float, + cx: float, cz: float, radius: float, material_id: int, weichheit: float, + staerke: float = 1.0) -> bool: + if res < 2 or radius <= 0.0 or mats.size() < res * res \ + or mix_b.size() < res * res or mix_w.size() < res * res: + return false + var soft := clampf(weichheit, 0.0, 1.0) + var inner := radius * (1.0 - soft) + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + var gx := (cx / size_x + 0.5) * float(res - 1) + var gz := (cz / size_z + 0.5) * float(res - 1) + var x0 := maxi(int(floor(gx - radius / cell_x)) - 1, 0) + var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1) + var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0) + var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1) + var changed := false + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + var dx := (float(x) - gx) * cell_x + var dz := (float(z) - gz) * cell_z + var d := sqrt(dx * dx + dz * dz) + if d >= radius: + continue + var f := 1.0 + if d > inner: + var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0) + f = 1.0 - t * t * (3.0 - 2.0 * t) + var fb := int(roundf(f * clampf(staerke, 0.0, 1.0) * 255.0)) + if fb <= 0: + continue + var idx := z * res + x + var a := int(mats[idx]) + var b := int(mix_b[idx]) + var w := int(mix_w[idx]) + var anteil := (255 - w if a == material_id else 0) + (w if b == material_id else 0) + if fb <= anteil: + continue + if fb >= 255: + mats[idx] = material_id + mix_b[idx] = material_id + mix_w[idx] = 0 + else: + # Ziel in keinem Slot → das SCHWÄCHERE Slot-Material wird ersetzt + # (Anteil von b = w/255, a hat 255−w): w ≥ 128 → b bleibt, sonst a. + var andere: int + if a == material_id: + andere = b + elif b == material_id: + andere = a + else: + andere = b if w >= 128 else a + mats[idx] = andere + mix_b[idx] = material_id + mix_w[idx] = fb + changed = true + return changed + + +## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird +## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung). +static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float, + gx: float, gz: float, r_cells_x: float, r_cells_z: float, + x0: int, x1: int, z0: int, z1: int, amount: float, falloff: float = -1.0) -> bool: + # Mittelwerte nur für das Pinsel-Rechteck (statt Puffer in Feldgröße pro Schritt). + var bw := x1 - x0 + 1 + var avg := PackedFloat32Array() + avg.resize(bw * (z1 - z0 + 1)) + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + var sum := 0.0 + var count := 0 + for zi in range(maxi(z - 1, 0), mini(z + 1, res - 1) + 1): + for xi in range(maxi(x - 1, 0), mini(x + 1, res - 1) + 1): + sum += heights[zi * res + xi] + count += 1 + avg[(z - z0) * bw + (x - x0)] = sum / float(maxi(count, 1)) + var changed := false + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): + var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001) + var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001) + var d := sqrt(dx * dx + dz * dz) + if d >= 1.0: + continue + var w := falloff_weight(d, 1.0, falloff) + var idx := z * res + x + var nh := lerpf(heights[idx], avg[(z - z0) * bw + (x - x0)], clampf(amount * w, 0.0, 1.0)) + nh = clampf(nh, hmin, hmax) + if nh != heights[idx]: + heights[idx] = nh + changed = true + return changed + + +## Höhenfeld → Base64-Rohdaten (svmap-Format: uint16 LE, row-major). +## Rundung halbwegs offen (round) — Fehler ≤ ½ Quantisierungsschritt. +static func encode(heights: PackedFloat32Array, hmin: float, hmax: float) -> String: + var span := maxf(hmax - hmin, 0.000001) + var bytes := PackedByteArray() + bytes.resize(heights.size() * 2) + for i in heights.size(): + var v := int(round(clampf((heights[i] - hmin) / span, 0.0, 1.0) * 65535.0)) + bytes.encode_u16(i * 2, v) + return Marshalls.raw_to_base64(bytes) + + +## Cursor-Strahl → erster Treffer auf dem Höhenfeld (ray-march + Bisektion). +## Für den Pinsel: der Kreis muss UNTER dem Mauszeiger auf dem GELÄNDE liegen, +## nicht auf der flachen Basis-Ebene. Rückgabe null = kein Treffer. +static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + origin: Vector3, dir: Vector3, max_dist: float = 0.0) -> Variant: + if res < 2 or heights.size() < res * res: + return null + if max_dist <= 0.0: + max_dist = maxf(size_x, size_z) * 3.0 + var d := dir + if d.length_squared() < 0.0000001: + return null + d = d.normalized() + var step := maxf(size_x, size_z) / float(res - 1) * 0.5 + step = clampf(step, 0.5, 4.0) + # Tangenten-Band (Karte t_234490cf, Teil 2 des Gerätebefunds): Ein Strahl, + # der EXAKT auf eine Hügelkuppe zielt, grast die gewölbte Oberfläche — + # f(t) bleibt minimal knapp ÜBER 0 am Kuppen-Scheitel, DANACH steigt f + # wieder, obwohl weiter hinten (verdeckt hinter dem Hügel) noch ein + # Vorzeichenwechsel an der Basis-Ebene käme. Regel „erster sichtbarer + # Oberflächen-Kontakt gewinnt": kommt f innerhalb des Bandes (½ Schritt) + # an die Oberfläche und entfernt sich der Strahl wieder, gilt die + # Oberfläche als getroffen (lokal verfeinert). Ein echter Treffer + # (f ≤ 0) gewinnt weiterhin immer. + var graze := step * 0.5 + var best_t := -1.0 + var best_f := INF + var t := 0.0 + var prev_t := 0.0 + var prev_f := INF + while t < max_dist: + t = minf(t + step, max_dist) + var f := _ray_error(heights, res, size_x, size_z, origin, d, t) + if f < best_f: + best_f = f + best_t = t + if f <= 0.0: + # Vorzeichenwechsel zwischen prev_t und t → Bisektion. + var lo := prev_t + var hi := t + for i in 18: + var mid := (lo + hi) * 0.5 + if _ray_error(heights, res, size_x, size_z, origin, d, mid) <= 0.0: + hi = mid + else: + lo = mid + return origin + d * hi + # Tangenten-Erkennung (Karte t_234490cf): war der Strahl im Band an + # der Oberfläche und steigt f JETZT wieder, hat er die Oberfläche + # tangential berührt und entfernt sich — der erste sichtbare Kontakt + # war das Graze-Minimum. Ohne das fiele der Strahl über die Kuppe + # hinweg und träfe die (verdeckte) Basis-Ebene weit hinten. + if f > prev_f and best_f <= graze: + return _graze_hit(heights, res, size_x, size_z, origin, d, + best_t, best_f, step, max_dist) + prev_f = f + prev_t = t + # Band-Minimum direkt am March-Ende (kein steigender Sample mehr kam): + if best_f <= graze and best_t > 0.0: + return _graze_hit(heights, res, size_x, size_z, origin, d, + best_t, best_f, step, max_dist) + # Nichts getroffen → Basis-Ebene als Fallback (wie interaction._ground_point). + if absf(d.y) > 0.0001: + var plane_y: float = _min_h(heights) + var t_plane: float = (plane_y - origin.y) / d.y + if t_plane > 0.0: + return origin + d * t_plane + return null + + +## Tangenten-Treffer (Karte t_234490cf): dichteste Annäherung des Strahls an +## die Oberfläche lokal verfeinern (Ternärsuche um den besten March-Sample) +## und den Punkt auf dem Strahl zurückgeben — der x/z-Punkt liegt damit dort, +## wo der Cursor die Oberfläche sichtbar berührt, nicht meterweit dahinter. +static func _graze_hit(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + origin: Vector3, d: Vector3, best_t: float, _best_f: float, + step: float, max_dist: float) -> Vector3: + var glo := maxf(best_t - step, 0.0) + var ghi := minf(best_t + step, max_dist) + for i in 12: + var ga := glo + (ghi - glo) / 3.0 + var gb := ghi - (ghi - glo) / 3.0 + if _ray_error(heights, res, size_x, size_z, origin, d, ga) < _ray_error(heights, res, size_x, size_z, origin, d, gb): + ghi = gb + else: + glo = ga + return origin + d * ((glo + ghi) * 0.5) + + +## Minimum/Maximum des Feldes (PackedFloat32Array hat kein .min()/.max()). +static func _min_h(heights: PackedFloat32Array) -> float: + var out := INF + for v in heights: + if v < out: + out = v + return out + + +static func _max_h(heights: PackedFloat32Array) -> float: + var out := -INF + for v in heights: + if v > out: + out = v + return out + + +## f(t) < 0 → Strahl unter dem Terrain. (prev_f-Nutzung vermieden Godot-Warnung.) +static func _ray_error(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + origin: Vector3, dir: Vector3, t: float) -> float: + var p := origin + dir * t + return p.y - height_at(heights, res, size_x, size_z, p.x, p.z) diff --git a/svmap/terrain_field.gd.uid b/svmap/terrain_field.gd.uid new file mode 100644 index 0000000..f0b3472 --- /dev/null +++ b/svmap/terrain_field.gd.uid @@ -0,0 +1 @@ +uid://bwhwp7otbp0ie