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