diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6634e27 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,5 @@ +[submodule "core"] + path = core + url = ../scopedvr-map-core.git + branch = main + ignore = untracked diff --git a/README.md b/README.md index a72e5fb..7702e58 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ UX-Fundament auf dem App-Skelett (t_5badf371), nach dem Chef-Grundsatz gebaut: Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/ Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)* -- **Undo/Redo (`src/editor/action_history.gd`):** zentrale ActionHistory über +- **Undo/Redo (`core/editor/action_history.gd`):** zentrale ActionHistory über die svmap-Ober-Blöcke (`objects`/`terrain`/`spawn_zones` — tiefe Kopien vor und nach jeder Änderung, merge_tags gegen Mausrad-Tick-Fluten, Max-Stufe 100). Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben. @@ -113,7 +113,7 @@ Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz: Gebäude/Zelte/Türme exakter Trimesh (betretbar), Fahrzeuge Verbund aus Boxen, Bäume nur Stamm, Dekor ohne Kollision — erzeugt vom Match-Server-Repo (`tools/pack_registry.py`). -- **Snap to Ground (`src/editor/object_ops.gd`):** Ein Klick auf +- **Snap to Ground (`core/editor/object_ops.gd`):** Ein Klick auf „⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet. @@ -145,7 +145,7 @@ Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz: 2–30 m per Regler. Zone anklicken → auswählen + ziehen (Terrain-Höhe bleibt), Entf löscht, Strg+Z nimmt JEDE Operation zurück (jede Zone = EIN History-Eintrag, Radius-Slider-Ticks sind merge-gekoppelt). -- **Live-Validierung (`src/editor/spawn_ops.gd`):** Block „Spawn-Check“ +- **Live-Validierung (`core/editor/spawn_ops.gd`):** Block „Spawn-Check“ rechts bleibt STÄNDIG sichtbar und spiegelt exakt die Loader-Regeln (V11: ≥ 4 Spawn-Punkte pro Team · V7: Objekt-IDs in der Bibliothek) — z. B. „✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 (mindestens 4 @@ -244,7 +244,7 @@ Mehrfachauswahl + Gruppieren + Gruppen-Verschieben, auf dem UX-Fundament Auswahl). Loser Klick auf leere Fläche hebt die Auswahl auf. ALLE markierten Objekte färben sich orange, das Auswahl-Panel rechts listet sie namentlich auf (ab 9: „… und N weitere“). Projektions-Mathe liegt - in `src/editor/selection_math.gd` (headless getestet). + in `core/editor/selection_math.gd` (headless getestet). - **Gruppieren:** Buttons „🔗 Gruppieren / ⛓ Gruppe lösen“ im Auswahl-Panel + Kürzel Strg+G / Strg+Umschalt+G. Eine Gruppe braucht ≥ 2 Objekte. Gruppen-Mitglieder ziehen und drehen GEMEINSAM — ein Griff an ein diff --git a/core b/core new file mode 160000 index 0000000..92ee3fb --- /dev/null +++ b/core @@ -0,0 +1 @@ +Subproject commit 92ee3fbdd1102d24bd34ad9302a494973a525fc3 diff --git a/library/svmap_wasser.gd b/library/svmap_wasser.gd deleted file mode 100644 index 755f89b..0000000 --- a/library/svmap_wasser.gd +++ /dev/null @@ -1,240 +0,0 @@ -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 deleted file mode 100644 index 6fa8227..0000000 --- a/library/svmap_wasser.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cppnjm0fhofpa diff --git a/src/editor/action_history.gd b/src/editor/action_history.gd deleted file mode 100644 index 592c9d5..0000000 --- a/src/editor/action_history.gd +++ /dev/null @@ -1,132 +0,0 @@ -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/src/editor/action_history.gd.uid b/src/editor/action_history.gd.uid deleted file mode 100644 index e1c64cc..0000000 --- a/src/editor/action_history.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bj1egufnqcho5 diff --git a/src/editor/group_ops.gd b/src/editor/group_ops.gd deleted file mode 100644 index bb3defc..0000000 --- a/src/editor/group_ops.gd +++ /dev/null @@ -1,197 +0,0 @@ -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/src/editor/group_ops.gd.uid b/src/editor/group_ops.gd.uid deleted file mode 100644 index 1f8ad6e..0000000 --- a/src/editor/group_ops.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d0luucpxtrabo diff --git a/src/editor/move_math.gd b/src/editor/move_math.gd deleted file mode 100644 index e43fb18..0000000 --- a/src/editor/move_math.gd +++ /dev/null @@ -1,61 +0,0 @@ -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/src/editor/move_math.gd.uid b/src/editor/move_math.gd.uid deleted file mode 100644 index 9001904..0000000 --- a/src/editor/move_math.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://behfbtvkyai65 diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd deleted file mode 100644 index f9e0e69..0000000 --- a/src/editor/object_ops.gd +++ /dev/null @@ -1,384 +0,0 @@ -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/src/editor/object_ops.gd.uid b/src/editor/object_ops.gd.uid deleted file mode 100644 index a8fb101..0000000 --- a/src/editor/object_ops.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dnocqoteya1h3 diff --git a/src/editor/playtest/playtest_mode.gd b/src/editor/playtest/playtest_mode.gd index 62053dd..ade88d2 100644 --- a/src/editor/playtest/playtest_mode.gd +++ b/src/editor/playtest/playtest_mode.gd @@ -6,7 +6,7 @@ extends Node ## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen) ## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input ## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden. -## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel +## - Kollider-Bau via KolliderBau (core/svmap/kollider_bau.gd — 1:1-Spiegel ## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider ## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen) ## + Bauteil-Trimeshe (bau_*-Objekte: Treppen/Türen/Wände begehbar @@ -22,7 +22,7 @@ extends Node ## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD. const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd" -const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd" +const KOLLIDER_SCRIPT := "res://core/svmap/kollider_bau.gd" const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd" ## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik) diff --git a/src/editor/selection_math.gd b/src/editor/selection_math.gd deleted file mode 100644 index 56a3a5c..0000000 --- a/src/editor/selection_math.gd +++ /dev/null @@ -1,143 +0,0 @@ -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/src/editor/selection_math.gd.uid b/src/editor/selection_math.gd.uid deleted file mode 100644 index 77dcae2..0000000 --- a/src/editor/selection_math.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://00t31s6wj67c diff --git a/src/editor/spawn_ops.gd b/src/editor/spawn_ops.gd deleted file mode 100644 index 4d48617..0000000 --- a/src/editor/spawn_ops.gd +++ /dev/null @@ -1,325 +0,0 @@ -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/src/editor/spawn_ops.gd.uid b/src/editor/spawn_ops.gd.uid deleted file mode 100644 index 9c86c61..0000000 --- a/src/editor/spawn_ops.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c1yvw71ppjl28 diff --git a/src/editor/symmetry_core.gd b/src/editor/symmetry_core.gd deleted file mode 100644 index 7f975ef..0000000 --- a/src/editor/symmetry_core.gd +++ /dev/null @@ -1,233 +0,0 @@ -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/src/editor/symmetry_core.gd.uid b/src/editor/symmetry_core.gd.uid deleted file mode 100644 index 51ee5f0..0000000 --- a/src/editor/symmetry_core.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://e08hmo3p6ra3 diff --git a/src/editor/updater.gd b/src/editor/updater.gd index 172474f..17a62df 100644 --- a/src/editor/updater.gd +++ b/src/editor/updater.gd @@ -283,6 +283,14 @@ static func pull(git_exe: String, project_dir: String) -> Dictionary: var restored := _restore_stash(git_exe, project_dir) if not bool(restored["ok"]): info += "\n" + STASH_KEPT_TEXT + # Der Map-Kern ist ein Submodul: ein Pull allein holt ihn nicht nach. + var sub := git_run(git_exe, project_dir, PackedStringArray(["submodule", "update", "--init", "--recursive"])) + if not bool(sub["ok"]): + errors.append("git submodule update fehlgeschlagen (Exit-Code %s):" % str(sub["code"])) + errors.append(str(sub["output"]).strip_edges()) + if looks_like_auth_error(str(sub["output"])): + errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).") + return {"ok": false, "output": str(sub["output"]), "errors": errors, "info": info} return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors, "info": info} diff --git a/src/svmap/group_state.gd b/src/svmap/group_state.gd deleted file mode 100644 index e6c2d01..0000000 --- a/src/svmap/group_state.gd +++ /dev/null @@ -1,111 +0,0 @@ -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/src/svmap/group_state.gd.uid b/src/svmap/group_state.gd.uid deleted file mode 100644 index 84d92fe..0000000 --- a/src/svmap/group_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b13m4gw07qdsf diff --git a/src/svmap/kollider_bau.gd b/src/svmap/kollider_bau.gd deleted file mode 100644 index ad62da0..0000000 --- a/src/svmap/kollider_bau.gd +++ /dev/null @@ -1,294 +0,0 @@ -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/src/svmap/kollider_bau.gd.uid b/src/svmap/kollider_bau.gd.uid deleted file mode 100644 index d7b9df3..0000000 --- a/src/svmap/kollider_bau.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cncg002d4e5x7 diff --git a/src/svmap/loadout_katalog.gd b/src/svmap/loadout_katalog.gd deleted file mode 100644 index d106c6d..0000000 --- a/src/svmap/loadout_katalog.gd +++ /dev/null @@ -1,201 +0,0 @@ -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/src/svmap/loadout_katalog.gd.uid b/src/svmap/loadout_katalog.gd.uid deleted file mode 100644 index 1df16fa..0000000 --- a/src/svmap/loadout_katalog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://jm1cubdr25s8 diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd deleted file mode 100644 index f8b427e..0000000 --- a/src/svmap/map_document.gd +++ /dev/null @@ -1,470 +0,0 @@ -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/src/svmap/map_document.gd.uid b/src/svmap/map_document.gd.uid deleted file mode 100644 index 79c3c6e..0000000 --- a/src/svmap/map_document.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmyaqvftm41uf diff --git a/src/svmap/modus_katalog.gd b/src/svmap/modus_katalog.gd deleted file mode 100644 index 89649e2..0000000 --- a/src/svmap/modus_katalog.gd +++ /dev/null @@ -1,264 +0,0 @@ -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/src/svmap/modus_katalog.gd.uid b/src/svmap/modus_katalog.gd.uid deleted file mode 100644 index 3234abe..0000000 --- a/src/svmap/modus_katalog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cprm4ke4dpj58 diff --git a/src/svmap/skybox_katalog.gd b/src/svmap/skybox_katalog.gd deleted file mode 100644 index 2aebaba..0000000 --- a/src/svmap/skybox_katalog.gd +++ /dev/null @@ -1,125 +0,0 @@ -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/src/svmap/skybox_katalog.gd.uid b/src/svmap/skybox_katalog.gd.uid deleted file mode 100644 index b8abc2c..0000000 --- a/src/svmap/skybox_katalog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cydcauar68si5 diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd deleted file mode 100644 index 72c2a49..0000000 --- a/src/svmap/svmap_io.gd +++ /dev/null @@ -1,916 +0,0 @@ -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/src/svmap/svmap_io.gd.uid b/src/svmap/svmap_io.gd.uid deleted file mode 100644 index 3ef96d6..0000000 --- a/src/svmap/svmap_io.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b0n1rh37a72rd diff --git a/src/svmap/svmap_migration.gd b/src/svmap/svmap_migration.gd deleted file mode 100644 index 5908844..0000000 --- a/src/svmap/svmap_migration.gd +++ /dev/null @@ -1,67 +0,0 @@ -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/src/svmap/svmap_migration.gd.uid b/src/svmap/svmap_migration.gd.uid deleted file mode 100644 index c5742e8..0000000 --- a/src/svmap/svmap_migration.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dhfmxbbnonapa diff --git a/src/svmap/svmap_tint.gd b/src/svmap/svmap_tint.gd deleted file mode 100644 index 501bb06..0000000 --- a/src/svmap/svmap_tint.gd +++ /dev/null @@ -1,149 +0,0 @@ -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/src/svmap/svmap_tint.gd.uid b/src/svmap/svmap_tint.gd.uid deleted file mode 100644 index db1fa23..0000000 --- a/src/svmap/svmap_tint.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cx6cfhyxw42f diff --git a/src/svmap/terrain_field.gd b/src/svmap/terrain_field.gd deleted file mode 100644 index dc7c698..0000000 --- a/src/svmap/terrain_field.gd +++ /dev/null @@ -1,456 +0,0 @@ -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/src/svmap/terrain_field.gd.uid b/src/svmap/terrain_field.gd.uid deleted file mode 100644 index f0b3472..0000000 --- a/src/svmap/terrain_field.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwhwp7otbp0ie diff --git a/src/ui/main.gd b/src/ui/main.gd index e790cae..19490fb 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -34,7 +34,7 @@ const APP_TITLE := "ScopedVR Map-Editor" const ShortcutKeys := preload("res://src/editor/shortcut_keys.gd") const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd") # Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets. -const MoveMath := preload("res://src/editor/move_math.gd") +const MoveMath := preload("res://core/editor/move_math.gd") # Kurzhilfe-Overlay Karte 3/3 (t_11b5c25b): Gesamte Tastenliste als BBCode. const HelpText := preload("res://src/ui/help_text.gd") # Zentrale UI-Skalierung (Karte t_297221b5): EIN Faktor für Schrift/Panels/Zeilen. diff --git a/src/ui/panels/loadout_panel.gd b/src/ui/panels/loadout_panel.gd index e9cde80..7ff3e46 100644 --- a/src/ui/panels/loadout_panel.gd +++ b/src/ui/panels/loadout_panel.gd @@ -2,7 +2,7 @@ extends "res://src/ui/panels/editor_panel.gd" ## Loadout-Bereich im Map-Tab (Karte „Loadout pro Map"): pro Map wählbar, ob die ## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout ## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler -## Block doc["loadout"] (Regeln: src/svmap/loadout_katalog.gd, V18). Der Server +## Block doc["loadout"] (Regeln: core/svmap/loadout_katalog.gd, V18). Der Server ## setzt ein festes Loadout durch, der Client zeigt es an. In beiden Modi lässt ## sich zusätzlich der Granaten-/Minen-Vorrat pro Leben festlegen (auch für Bots). ## diff --git a/src/ui/panels/modus_panel.gd b/src/ui/panels/modus_panel.gd index 21f92de..a012a89 100644 --- a/src/ui/panels/modus_panel.gd +++ b/src/ui/panels/modus_panel.gd @@ -3,7 +3,7 @@ extends "res://src/ui/panels/editor_panel.gd" ## Conquest / King of the Hill / Deathmatch), Zonen bzw. Hills setzen und eigene ## Spawns je Modus anlegen. Zonen erscheinen als farbige Kreise im 3D-Bild. ## Gespeichert als optionale Felder doc["game_mode"], doc["conquest_zones"], -## doc["hills"], doc["mode_spawns"] (Regeln: src/svmap/modus_katalog.gd). +## doc["hills"], doc["mode_spawns"] (Regeln: core/svmap/modus_katalog.gd). ## ## Jede Änderung ist ein Undo-Schritt (History-Blöcke; fehlt ein Block, gilt der ## Standard: TDM, keine Zonen, keine eigenen Spawns). diff --git a/src/viewport/gizmo.gd b/src/viewport/gizmo.gd index 5abca93..aff4d5d 100644 --- a/src/viewport/gizmo.gd +++ b/src/viewport/gizmo.gd @@ -22,7 +22,7 @@ extends Node3D ## interaction.gd bleibt dadurch UNVERÄNDERT (Karten-Grenze) und der ## bestehende Klick-/Zieh-Pfad wird 1:1 wiederverwendet. -const SvmapDocument := preload("res://src/svmap/map_document.gd") +const SvmapDocument := preload("res://core/svmap/map_document.gd") const AXIS_LEN := 2.2 # Pfeil-Länge in Metern (Weltmaß) const SHAFT_R := 0.045 diff --git a/src/viewport/orbit_camera.gd b/src/viewport/orbit_camera.gd index ba92c81..1e2ba37 100644 --- a/src/viewport/orbit_camera.gd +++ b/src/viewport/orbit_camera.gd @@ -28,8 +28,8 @@ extends Camera3D ## gerechnet. fit_to_bounds() ersetzt frame_map(): ganze Map ins Bild. const FlyMath := preload("res://src/editor/fly_math.gd") -const TerrainField := preload("res://src/svmap/terrain_field.gd") -const SvmapIO := preload("res://src/svmap/svmap_io.gd") +const TerrainField := preload("res://core/svmap/terrain_field.gd") +const SvmapIO := preload("res://core/svmap/svmap_io.gd") const ORBIT_DEG_PER_PX := 0.3 # Karte K1: Drehgeschwindigkeit gekapselt — kein Nachlaufen const DISTANCE_MIN := 5.0 diff --git a/tests/probe_d4_mapbuilder.gd b/tests/probe_d4_mapbuilder.gd index 984fe54..81e62e9 100644 --- a/tests/probe_d4_mapbuilder.gd +++ b/tests/probe_d4_mapbuilder.gd @@ -7,7 +7,7 @@ extends SceneTree ## ## Ausgabe: /tmp/d4_mapbuilder.png + Prüfzeilen (Modus == GLB, Fußpunkt y=0) -const MapDocument := preload("res://src/svmap/map_document.gd") +const MapDocument := preload("res://core/svmap/map_document.gd") const PLATZ := [ {"id": "barrel_001", "pos": [-2.2, 0.0, 0.0], "rot_y_deg": 0.0}, diff --git a/tests/probe_d6_mapbuilder.gd b/tests/probe_d6_mapbuilder.gd index d762cf5..de6e980 100644 --- a/tests/probe_d6_mapbuilder.gd +++ b/tests/probe_d6_mapbuilder.gd @@ -22,7 +22,7 @@ const PLATZ := [ {"id": "apc_001", "pos": [9.5, 0.0, 0.0], "rot_y_deg": 0.0, "aufsatz": false}, ] -const MapDocument := preload("res://src/svmap/map_document.gd") +const MapDocument := preload("res://core/svmap/map_document.gd") var vp: SubViewport var bauer: Node3D diff --git a/tests/probe_kollision_alle_ident.gd b/tests/probe_kollision_alle_ident.gd index 17a468b..d9b4e83 100644 --- a/tests/probe_kollision_alle_ident.gd +++ b/tests/probe_kollision_alle_ident.gd @@ -49,7 +49,7 @@ func _initialize() -> void: var instanz: Object = null match art: "editor": - skript = load("res://src/svmap/kollider_bau.gd") + skript = load("res://core/svmap/kollider_bau.gd") "client": skript = load("res://scripts/world/svmap_world.gd") instanz = skript.new() diff --git a/tests/probe_platzierung_ident.gd b/tests/probe_platzierung_ident.gd index e065640..cf84044 100644 --- a/tests/probe_platzierung_ident.gd +++ b/tests/probe_platzierung_ident.gd @@ -42,8 +42,8 @@ const COL_TRIMESH_FEHLT := { ## Skript-Pfade je Projekt (config/name → Loader-Skripte). const SKRIPTE := { "ScopedVR Map Editor": { - "rotation": "res://src/svmap/svmap_io.gd", - "hindernis": "res://src/svmap/kollider_bau.gd", + "rotation": "res://core/svmap/svmap_io.gd", + "hindernis": "res://core/svmap/kollider_bau.gd", "rot_funktion": "objekt_rotation", }, "ScopedVR": { diff --git a/tests/test_updater.gd b/tests/test_updater.gd index 77a20fa..66a14c2 100644 --- a/tests/test_updater.gd +++ b/tests/test_updater.gd @@ -33,6 +33,7 @@ func _init() -> void: DirAccess.make_dir_recursive_absolute(base) _run_tests() _test_ls_remote_warnzeile() + _test_submodul_kommt_mit() _force_writable(base) _cleanup_dir(base) # best effort — git-Pack-Dateien können read-only sein if failures == 0: @@ -410,3 +411,43 @@ func _test_ls_remote_warnzeile() -> void: "normale Zeile funktioniert weiterhin") _check(EditorUpdater.sha_aus_ls_remote("%s\trefs/heads/anderer" % sha, "main") == sha, "anderer Branch: SHA bleibt als Notnagel nutzbar") + + +## Map-Kern als Submodul: Pull allein reicht nicht, das Update muss ihn mitholen. +func _test_submodul_kommt_mit() -> void: + print("== Submodul ==") + OS.set_environment("GIT_ALLOW_PROTOCOL", "file") + var core_bare := base.path_join("core.git") + var app_bare := base.path_join("app.git") + var core_work := base.path_join("core_work") + var app_work := base.path_join("app_work") + var alt := base.path_join("app_alt") + for bare in [core_bare, app_bare]: + _git(base, ["init", "--bare", "--initial-branch=main", bare]) + for pair in [[core_work, core_bare, "kern.txt"], [app_work, app_bare, "app.txt"]]: + _git(base, ["init", "--initial-branch=main", pair[0]]) + _git(pair[0], ["remote", "add", "origin", pair[1]]) + _git(pair[0], ["config", "user.email", "t@example.invalid"]) + _git(pair[0], ["config", "user.name", "Update-Test"]) + _write_file(str(pair[0]).path_join(pair[2]), "v1\n") + _git(pair[0], ["add", "-A"]) + _git(pair[0], ["commit", "-m", "v1"]) + _git(pair[0], ["push", "-u", "origin", "main"]) + _git(base, ["clone", app_bare, alt]) + _check(bool(_git(app_work, ["-c", "protocol.file.allow=always", "submodule", "add", core_bare, "core"])["ok"]), + "Submodul: im App-Repo eingetragen") + _git(app_work, ["commit", "-m", "Kern als Submodul"]) + _git(app_work, ["push", "origin", "main"]) + var r := EditorUpdater.pull("git", alt) + _check(bool(r["ok"]), "Submodul: Update läuft durch") + _check(FileAccess.file_exists(alt.path_join("core/kern.txt")), "Submodul: Kern ist nach dem Update da") + _write_file(core_work.path_join("kern.txt"), "v2\n") + _git(core_work, ["commit", "-am", "v2"]) + _git(core_work, ["push", "origin", "main"]) + _git(app_work, ["-c", "protocol.file.allow=always", "submodule", "update", "--remote", "core"]) + _git(app_work, ["add", "core"]) + _git(app_work, ["commit", "-m", "Kern v2"]) + _git(app_work, ["push", "origin", "main"]) + r = EditorUpdater.pull("git", alt) + _check(bool(r["ok"]) and _file_text(alt.path_join("core/kern.txt")) == "v2\n", + "Submodul: neuer Kern-Stand kommt beim nächsten Update mit") diff --git a/tools/parse_check.sh b/tools/parse_check.sh index d9425b3..6af6569 100755 --- a/tools/parse_check.sh +++ b/tools/parse_check.sh @@ -3,7 +3,7 @@ cd "$(dirname "$0")/.." GODOT=/opt/data/home/bin/godot4 fail=0 -for f in src/ui/main.gd src/ui/inspector_panel.gd tests/test_inspector.gd src/editor/test_run.gd src/editor/library_browser.gd src/editor/object_ops.gd \ +for f in src/ui/main.gd src/ui/inspector_panel.gd tests/test_inspector.gd src/editor/test_run.gd src/editor/library_browser.gd core/editor/object_ops.gd \ src/editor/interaction.gd tools/gen_thumbs.gd tests/test_library.gd; do out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) if [ -n "$out" ]; then diff --git a/tools/parse_check_shortcuts.sh b/tools/parse_check_shortcuts.sh index 56e130a..a98a618 100644 --- a/tools/parse_check_shortcuts.sh +++ b/tools/parse_check_shortcuts.sh @@ -5,7 +5,7 @@ GODOT=~/bin/godot4 fail=0 for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \ src/editor/fly_math.gd src/editor/shortcut_policy.gd \ - src/editor/selection_math.gd src/editor/interaction.gd src/editor/object_ops.gd \ + core/editor/selection_math.gd src/editor/interaction.gd core/editor/object_ops.gd \ tests/test_shortcuts.gd tests/test_ux.gd; do out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) if [ -n "$out" ]; then diff --git a/tools/parse_check_shortcuts2.sh b/tools/parse_check_shortcuts2.sh index 2ce7396..e50a9a7 100644 --- a/tools/parse_check_shortcuts2.sh +++ b/tools/parse_check_shortcuts2.sh @@ -4,8 +4,8 @@ cd "$(dirname "$0")/.." GODOT=/opt/data/home/bin/godot4 fail=0 for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \ - src/editor/move_math.gd src/editor/shortcut_policy.gd \ - src/editor/interaction.gd src/editor/object_ops.gd \ + core/editor/move_math.gd src/editor/shortcut_policy.gd \ + src/editor/interaction.gd core/editor/object_ops.gd \ src/editor/build_tool.gd src/editor/terrain_tool.gd \ tests/test_shortcuts.gd tests/test_gui_smoke.gd; do out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true)