Map-Kern ausgelagert: scopedvr-map-core als Submodul unter res://core, Update holt ihn mit; Test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-31 15:37:09 +01:00
parent e8ab954876
commit 949d73dfdd
54 changed files with 76 additions and 4807 deletions

5
.gitmodules vendored Normal file
View file

@ -0,0 +1,5 @@
[submodule "core"]
path = core
url = ../scopedvr-map-core.git
branch = main
ignore = untracked

View file

@ -48,7 +48,7 @@ UX-Fundament auf dem App-Skelett (t_5badf371), nach dem Chef-Grundsatz gebaut:
Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/ Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/
Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)* Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)*
- **Undo/Redo (`src/editor/action_history.gd`):** zentrale ActionHistory über - **Undo/Redo (`core/editor/action_history.gd`):** zentrale ActionHistory über
die svmap-Ober-Blöcke (`objects`/`terrain`/`spawn_zones` — tiefe Kopien vor die svmap-Ober-Blöcke (`objects`/`terrain`/`spawn_zones` — tiefe Kopien vor
und nach jeder Änderung, merge_tags gegen Mausrad-Tick-Fluten, Max-Stufe 100). und nach jeder Änderung, merge_tags gegen Mausrad-Tick-Fluten, Max-Stufe 100).
Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben. Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben.
@ -113,7 +113,7 @@ Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
Gebäude/Zelte/Türme exakter Trimesh (betretbar), Fahrzeuge Verbund aus Gebäude/Zelte/Türme exakter Trimesh (betretbar), Fahrzeuge Verbund aus
Boxen, Bäume nur Stamm, Dekor ohne Kollision — erzeugt vom Match-Server-Repo Boxen, Bäume nur Stamm, Dekor ohne Kollision — erzeugt vom Match-Server-Repo
(`tools/pack_registry.py`). (`tools/pack_registry.py`).
- **Snap to Ground (`src/editor/object_ops.gd`):** Ein Klick auf - **Snap to Ground (`core/editor/object_ops.gd`):** Ein Klick auf
„⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die „⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die
Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die
ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet. ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet.
@ -145,7 +145,7 @@ Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz:
2–30 m per Regler. Zone anklicken → auswählen + ziehen (Terrain-Höhe 2–30 m per Regler. Zone anklicken → auswählen + ziehen (Terrain-Höhe
bleibt), Entf löscht, Strg+Z nimmt JEDE Operation zurück (jede Zone = bleibt), Entf löscht, Strg+Z nimmt JEDE Operation zurück (jede Zone =
EIN History-Eintrag, Radius-Slider-Ticks sind merge-gekoppelt). EIN History-Eintrag, Radius-Slider-Ticks sind merge-gekoppelt).
- **Live-Validierung (`src/editor/spawn_ops.gd`):** Block „Spawn-Check“ - **Live-Validierung (`core/editor/spawn_ops.gd`):** Block „Spawn-Check“
rechts bleibt STÄNDIG sichtbar und spiegelt exakt die Loader-Regeln rechts bleibt STÄNDIG sichtbar und spiegelt exakt die Loader-Regeln
(V11: ≥ 4 Spawn-Punkte pro Team · V7: Objekt-IDs in der Bibliothek) — (V11: ≥ 4 Spawn-Punkte pro Team · V7: Objekt-IDs in der Bibliothek) —
z. B. „✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 (mindestens 4 z. B. „✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 (mindestens 4
@ -244,7 +244,7 @@ Mehrfachauswahl + Gruppieren + Gruppen-Verschieben, auf dem UX-Fundament
Auswahl). Loser Klick auf leere Fläche hebt die Auswahl auf. ALLE Auswahl). Loser Klick auf leere Fläche hebt die Auswahl auf. ALLE
markierten Objekte färben sich orange, das Auswahl-Panel rechts listet markierten Objekte färben sich orange, das Auswahl-Panel rechts listet
sie namentlich auf (ab 9: „… und N weitere“). Projektions-Mathe liegt sie namentlich auf (ab 9: „… und N weitere“). Projektions-Mathe liegt
in `src/editor/selection_math.gd` (headless getestet). in `core/editor/selection_math.gd` (headless getestet).
- **Gruppieren:** Buttons „🔗 Gruppieren / ⛓ Gruppe lösen“ im Auswahl-Panel - **Gruppieren:** Buttons „🔗 Gruppieren / ⛓ Gruppe lösen“ im Auswahl-Panel
+ Kürzel Strg+G / Strg+Umschalt+G. Eine Gruppe braucht ≥ 2 Objekte. + Kürzel Strg+G / Strg+Umschalt+G. Eine Gruppe braucht ≥ 2 Objekte.
Gruppen-Mitglieder ziehen und drehen GEMEINSAM — ein Griff an ein Gruppen-Mitglieder ziehen und drehen GEMEINSAM — ein Griff an ein

1
core Submodule

@ -0,0 +1 @@
Subproject commit 92ee3fbdd1102d24bd34ad9302a494973a525fc3

View file

@ -1,240 +0,0 @@
class_name SvmapWasser
extends RefCounted
## Wasser pro Map (Karte t_28fdef03) — SPIEGELGLEICHES Modul in Client UND
## Map-Editor (Muster: library/terrain_arrays.gd, t_ab408127 — identischer
## Pfad in beiden Repos, identischer Code; Repos bleiben entkoppelt).
##
## Sly-Entscheidungen 20.10.: Wasserlevel-Ansatz (EINE Höhe pro Map — Senken
## unter der Höhe füllen sich automatisch), Wat-Trägheit (knietief, kein
## Schwimmen), Ufer-Automatik (schlammiges Overlay knapp unter Wasserhöhe).
##
## Verantwortung dieses Moduls (Dictionary rein, Node out — wie SvmapTerrain):
## - hat()/level(): optionales svmap-Feld `water_level` (float, y-Höhe in m).
## Abwesenheit oder Fehltyp = KEIN Wasser (Legacy-Maps unverändert).
## - baue(): EINE transparente Fläche auf Wasserhöhe über der ganzen Map
## (1 Draw-Call, keine Geometrie-Wogen — Wellen NUR im Fragment-Shader).
## - ufer_bild(): Tiefe-Feld (R8, depth = wasser − Terrainhöhe, geklemmt auf
## tiefe_max) als Textur — der Shader liest daraus Ufer-Schaum + Flach-
## Wasser-Transparenz, OHNE Screen-Space-Tricks.
## - ufer_faktor()/matsch_anwenden(): Ufer-Automatik — Zellen knapp unter
## Wasserhöhe bekommen den Matsch-Ton. BEWUSST nur eine abgeleitete Tönung
## (kein gespeichertes Feld): die Material-Schicht bleibt malbar, der
## Matsch liegt als Höhen-Regel drüber — Client UND Editor-Vorschau
## rechnen IDENTISCH (dieselbe Datei).
## - make_material(): dasselbe Material wie der Client (Identitäts-Vertrag
## der Vorschau) — Shader-Pfad je Repo über SHADER_KANDIDATEN.
##
## KEINE echte Spiegelung/Brechung/SSR (bewusste Absage, Performance):
## Spiegel-Impression = Skybox-Farbreflex (uniform) + Fresnel-Glanz.
##
## Wasserdistanz-Regel (Perf-Beweis-Plan): ferne Fragmente vereinfachen im
## Shader selbst (kein Wellen-/Schaum-Aufwand) — keine Per-Frame-Logik nötig.
## Shader-Pfade je Repo (erster vorhandener gewinnt — Spiegel-Dateien sind
## byteidentisch, siehe tools/sync_wasser.sh im Map-Editor).
const SHADER_KANDIDATEN: Array[String] = [
"res://shaders/wasser.gdshader", # scopedvr-client
"res://src/viewport/wasser.gdshader", # scopedvr-map-editor (Spiegel)
]
## Plausibilitätsbereich des svmap-Feldes `water_level` (Meter, y-Höhe).
## GLEICHE Werte wie V18 im Match-Server (svmap_loader) und im Editor
## (svmap_io) — nur Dokumentations-Konstante hier.
const LEVEL_MIN := -5.0
const LEVEL_MAX := 500.0
## Ufer-Bild: Tiefe in 0..tiefe_max m → 0..255 (R8). Mehr Tiefe braucht kein
## Detail — Schaum lebt in den ersten Dezimetern, Transparenz in den ersten Metern.
const TIEFE_MAX := 4.0
## Schaum-Band am Ufer (Meter Wassertiefe, in denen Schaum sichtbar ist).
const SCHAUM_BAND := 0.35
## Matsch-Ring (Ufer-Automatik): Zellen, deren Terrainhöhe weniger als
## MATSCH_HOEHE unter dem Wasserlevel liegt (bis MATSCH_UEBER leicht darüber,
## damit die Wasserlinie selbst matschig ist), werden zum Matsch-Ton überblendet.
const MATSCH_HOEHE := 0.55
const MATSCH_UEBER := 0.06
const MATSCH_FARBE := Color(0.34, 0.26, 0.18) # schlammiges Braun-Betonung
## Wetter-Farbkopplung (Entscheidung der Atmosphären-Karte): Wasser-Tint je
## Preset. farbe_flach = Seicht-Wasser, farbe_tief = tiefe Mitte, reflex =
## Skybox-Farbreflex (Himmelsfarbe des Presets), glanz = Glint-Stärke.
## Schlüssel = Client-Wetter-Keys (WeatherPresets). Unbekannt → "mittag".
const WETTER_WASSER := {
"mittag": {
"farbe_flach": Color(0.10, 0.30, 0.34),
"farbe_tief": Color(0.02, 0.10, 0.15),
"reflex": Color(0.72, 0.83, 0.92),
"glanz": 1.0,
},
"abend": {
"farbe_flach": Color(0.18, 0.26, 0.28),
"farbe_tief": Color(0.04, 0.07, 0.11),
"reflex": Color(0.95, 0.62, 0.38),
"glanz": 1.2,
},
"regen": {
"farbe_flach": Color(0.20, 0.24, 0.26),
"farbe_tief": Color(0.06, 0.09, 0.11),
"reflex": Color(0.55, 0.58, 0.62),
"glanz": 0.35,
},
"nacht": {
"farbe_flach": Color(0.02, 0.05, 0.09),
"farbe_tief": Color(0.0, 0.015, 0.03),
"reflex": Color(0.16, 0.22, 0.38),
"glanz": 0.9,
},
}
## Optionales svmap-Feld `water_level` vorhanden und brauchbar? Abwesenheit
## ODER Fehltyp = kein Wasser (Legacy-Maps unverändert, Fehltyp toleriert).
static func hat(data: Dictionary) -> bool:
var v: Variant = data.get("water_level", null)
return v is float or v is int
## Wasserhöhe aus der Map (INF = kein Wasser).
static func level(data: Dictionary) -> float:
var v: Variant = data.get("water_level", null)
if v is float or v is int:
return float(v)
return INF
## Wasserfläche bauen. terrain = der geparste terrain-Block, wasser = Wasser-
## Höhe (Meter), wetter = Preset-Key ("mittag"/"abend"/"regen"/"nacht").
## Rückgabe: Node3D mit EINER transparenten Fläche — null, wenn kein Wasser
## sinnvoll ist (kein Feld, kein Terrain, Wasserhöhe unter dem Tiefpunkt,
## ungültige Maße). Die Senken "füllen sich" von selbst: alles unter der
## Höhe liegt hinterher unter der Fläche.
static func baue(terrain: Dictionary, wasser: float, wetter: String) -> Node3D:
if not is_finite(wasser):
return null
var res := int(terrain.get("resolution", 0))
var size_x := float(terrain.get("size_x_m", 0.0))
var size_z := float(terrain.get("size_z_m", 0.0))
if res < 2 or size_x <= 0.0 or size_z <= 0.0:
return null
var heights := _decode_heights(terrain)
if heights.size() < res * res:
return null
var hmin := heights[0]
for h in heights:
if h < hmin:
hmin = h
if wasser <= hmin:
return null # Wasserhöhe unter dem Tiefpunkt — nichts gefüllt.
var root := Node3D.new()
root.name = "Wasser"
var mi := MeshInstance3D.new()
mi.name = "WasserFlaeche"
var plane := PlaneMesh.new()
plane.size = Vector2(size_x, size_z)
mi.mesh = plane
mi.position = Vector3(0.0, wasser, 0.0)
mi.material_override = make_material(wetter, ufer_bild(heights, res, wasser))
root.add_child(mi)
return root
## Ufer-Tiefe-Bild (R8): Pixel = Terrainhöhe der Zelle, R-Wert = Tiefe unter
## dem Wasserlevel (0 = Wasserlinie/Ufer, 255 = tiefe_max oder tiefer).
## Identische Index-Konvention wie das Höhenfeld: (0,0) = −X/−Z-Ecke, x → +X,
## z → +Z (svmap-Vertrag). Der PlaneMesh des Wassers überdeckt denselben
## Bereich (zentriert, UV 0..1), der Shader sampelt direkt mit UV.
static func ufer_bild(heights: PackedFloat32Array, res: int, wasser: float) -> Image:
var img := Image.create(res, res, false, Image.FORMAT_R8)
for z in res:
for x in res:
var d := wasser - heights[z * res + x] # Tiefe (negativ = Land)
var v := int(round(clampf(d / TIEFE_MAX, 0.0, 1.0) * 255.0))
img.set_pixel(x, z, Color8(v, 0, 0))
return img
## Ufer-Automatik (Matsch-Ring): Faktor 0 (Land/tief) … 1 (Uferstreifen).
## Band = MATSCH_HOEHE unter bis MATSCH_UEBER über dem Wasserlevel, weich
## (smoothstep) an beiden Kanten — kein Harter Pixel-Kantensprung.
static func ufer_faktor(h: float, wasser: float) -> float:
if not is_finite(wasser):
return 0.0
var d := wasser - h # Tiefe der Zelle (negativ = über Wasser)
var oben := clampf((d + MATSCH_UEBER) / MATSCH_UEBER, 0.0, 1.0)
if oben <= 0.0:
return 0.0
var unten := clampf(d / MATSCH_HOEHE, 0.0, 1.0)
var f := minf(oben, 1.0 - unten * unten * (3.0 - 2.0 * unten))
return clampf(f, 0.0, 1.0)
## Matsch-Ton auf eine Terrain-Zellfarbe anwenden (Client UND Editor rufen
## dieselbe Formel — Identität der Vorschau). faktor = ufer_faktor(...).
## BEWUSST nur RGB: die Alpha der Terrain-Zellfarbe trägt die gepackte
## Material-ID (m/N, Karte t_ab408127) — die darf der Matsch nie anfassen,
## sonst landen die Zellen im Splat-Shader auf dem falschen Textur-Layer.
static func matsch_anwenden(farbe: Color, faktor: float) -> Color:
var gemischt := farbe.lerp(MATSCH_FARBE, faktor * 0.75) # 0.75: Ton bleibt sichtbar
gemischt.a = farbe.a
return gemischt
## Wasser-Material (dasselbe im Client UND in der Editor-Vorschau —
## Identitäts-Vertrag): Custom-Shader + Wetter-Tint + Ufer-Tiefen-Textur.
## Fehlt der Shader (Frisch-Klon ohne Spiegel), fällt das Material auf ein
## simples transparentes StandardMaterial zurück — Wasser bleibt sichtbar.
static func make_material(wetter: String, ufer: Image = null) -> Material:
var preset: Dictionary = WETTER_WASSER.get(wetter, WETTER_WASSER["mittag"])
var shader := _shader_laden()
if shader != null:
var mat := ShaderMaterial.new()
mat.shader = shader
mat.set_shader_parameter("farbe_flach", preset["farbe_flach"])
mat.set_shader_parameter("farbe_tief", preset["farbe_tief"])
mat.set_shader_parameter("reflex", preset["reflex"])
mat.set_shader_parameter("glanz", float(preset["glanz"]))
if ufer != null:
mat.set_shader_parameter("ufer_tex", ImageTexture.create_from_image(ufer))
return mat
# Fallback ohne Shader (Robustheit — Wasser bleibt sichtbar, nur ohne Wellen).
var std := StandardMaterial3D.new()
std.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
std.albedo_color = Color(preset["farbe_flach"], 0.6)
std.roughness = 0.15
std.metallic = 0.1
return std
## Shader aus den Repo-Pfaden laden (erster vorhandener gewinnt).
static func _shader_laden() -> Shader:
for pfad in SHADER_KANDIDATEN:
if ResourceLoader.exists(pfad):
var s := load(pfad)
if s is Shader:
return s
return null
## Höhenfeld dekodieren (R16, identische Formel wie SvmapTerrain/SvmapIO/
## match-server — hier NOCHMAL lokal, damit dieses Spiegel-Modul von keinem
## Repo-Skript abhängt; 15 Zeilen Duplikat gegen Repo-Kopplung getauscht).
static func _decode_heights(terrain: Dictionary) -> PackedFloat32Array:
var res := int(terrain.get("resolution", 0))
var out := PackedFloat32Array()
if res < 2:
return out
out.resize(res * res)
var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", "")))
if raw.size() < res * res * 2:
return PackedFloat32Array() # kaputtes/fehlendes Höhenfeld → kein Wasser
var hmin := float(terrain.get("min_height_m", 0.0))
var hmax := float(terrain.get("max_height_m", 1.0))
if hmax <= hmin:
hmax = hmin + 1.0
var n := mini(raw.size() / 2, res * res)
for i in n:
var v: int = raw.decode_u16(i * 2)
out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin)
return out

View file

@ -1 +0,0 @@
uid://cppnjm0fhofpa

View file

@ -1,132 +0,0 @@
extends RefCounted
class_name ActionHistory
## Zentrale Undo/Redo-Historie des Map-Editors (M2, Karte t_40d855d0).
##
## Die Map IST das svmap-Dictionary (1:1-Format, keine Kopie). Jede Aktion
## sichert die betroffenen OBER-Blöcke (z. B. "objects", "terrain",
## "spawn_zones") als tiefe Kopie VOR und NACH der Änderung. Undo setze die
## Blöcke zurück, Redo wendet "after" erneut an.
##
## So melden spätere Werkzeuge ihre Aktionen an (Terrain-Strokes, Spawn-Zonen):
## var before := history.snapshot(["terrain"]) # VOR der Änderung
## doc["terrain"][...] = ... # Änderung machen
## history.commit("Hügel geformt", before, history.snapshot(["terrain"]))
##
## Ober-Blöcke sind klein (Objekt-Liste/Spawn-Punkte) oder Base64-Strings
## (heightmap) — tiefe Kopien sind hier billig und bombenfest gegen
## Alias-Verwechslung, weil Godot-Arrays/Dicts sonst gegenseitig referenzieren.
const MAX_STEPS := 100
var doc: Dictionary = {}
var entries: Array = [] # Eintrag = {desc:String, merge:String, before:{Blk:Variante}, after:{Blk:Variante}}
var index := -1 # zuletzt angewandter Eintrag; -1 = nichts
func begin(document: Dictionary) -> void:
doc = document # Referenz — wir ändern dieselbe Map, keine Kopie
clear()
func clear() -> void:
entries.clear()
index = -1
## Tiefe Kopie der angegebenen Blöcke (VOR oder NACH einer Änderung).
## Skalare Ober-Keys (format_version, weather, …) werden als Wert kopiert —
## so kann ein Snapshot auch den GANZEN doc abdecken (Resize, R3).
func snapshot(keys: Array) -> Dictionary:
var out := {}
for key in keys:
if doc.has(key):
var value: Variant = doc[key]
if value is Array or value is Dictionary:
out[key] = value.duplicate(true)
else:
out[key] = value
return out
func can_undo() -> bool:
return index >= 0
func can_redo() -> bool:
return index + 1 < entries.size()
func undo_desc() -> String:
if not can_undo():
return ""
return str(entries[index].get("desc", ""))
func redo_desc() -> String:
if not can_redo():
return ""
return str(entries[index + 1].get("desc", ""))
## Eine fertige Änderung in die History einhängen.
## merge_tag: ungleich "" → identisch getagte Folge-Aufrufe (z. B. Drehen
## per Mausrad in schnellen Ticks) überschreiben den letzten Eintrag,
## statt die History zu fluten. commit immer direkt NACH der Doc-Änderung aufrufen.
func commit(desc: String, before: Dictionary, after: Dictionary, merge_tag: String = "") -> void:
if merge_tag != "" and index >= 0 and str(entries[index].get("merge", "")) == merge_tag:
entries[index]["after"] = after
return
entries.resize(index + 1) # redo-Zweig verwerfen (Standard-Undo-Semantik)
entries.append({"desc": desc, "merge": merge_tag, "before": before, "after": after})
if entries.size() > MAX_STEPS:
entries.remove_at(0) # älteste Aktion verdrängen
index = entries.size() - 1
## Letzte Aktion zurücknehmen. Rückgabe: Beschreibung ("" = nichts da).
## Restore läuft IN-PLACE: doc["objects"] etc. behalten ihre Identität (Array/
## Dict-Objekt wird geleert und neu befüllt) — bezogene Referenzen (Tests,
## Ansichten) werden nicht zu Stale-Schnappschüssen.
func undo() -> String:
if not can_undo():
return ""
var entry: Dictionary = entries[index]
for key in entry["before"]:
_restore_block(key, entry["before"][key])
index -= 1
return str(entry.get("desc", ""))
## Zurückgenommenes wiederherstellen. Rückgabe: Beschreibung ("" = nichts da).
func redo() -> String:
if not can_redo():
return ""
index += 1
var entry: Dictionary = entries[index]
for key in entry["after"]:
_restore_block(key, entry["after"][key])
return str(entry.get("desc", ""))
func _restore_block(key: String, value: Variant) -> void:
if value == null:
doc.erase(key)
return
# Ganzen Block (Array/Dict) in-place neu befüllen — Identität bleibt erhalten.
# Skalare (format_version, weather, …) werden als Wert gesetzt.
var source: Variant = value
if source is Array or source is Dictionary:
source = value.duplicate(true)
if source is Array:
var arr: Array = doc.get(key, [])
arr.clear()
arr.append_array(source)
doc[key] = arr
elif source is Dictionary:
var dict: Dictionary = doc.get(key, {})
dict.clear()
dict.merge(source)
doc[key] = dict
else:
doc[key] = source

View file

@ -1 +0,0 @@
uid://bj1egufnqcho5

View file

@ -1,197 +0,0 @@
extends RefCounted
class_name GroupOps
## Gruppen-Logik des Map-Editors (Karte t_f6f29d86) — pure, statische Funktionen
## (kein UI, kein Knoten), headless testbar via tests/test_ux.gd.
##
## ENTSCHIEDUNG Gruppen-Persistenz (Karte t_f6f29d86, 15.10.):
## Gruppen-Zugehörigkeit liegt als OPTIONALES Objekt-Feld "group" (int ≥ 0,
## abwesend oder < 0 = ungruppiert) direkt im svmap-Format — NICHT als
## Editor-Sitzungsstatus. Begründung (Loader-Kompatibilität nachgewiesen,
## src/maps/svmap_loader.gd im Match-Server):
## - V4 prüft nur PFLICHT-Felder; unbekannte Zusatz-Keys je Objekt werden
## nicht abgelehnt (Loader liest id/pos/scale/rot_y_deg + optionales rot).
## - Die sha256-Prüfsumme läuft über das GESAMTE kanonisierte Dict — das
## Feld reist automatisch mit, ohne dass Prüfsummen-Code sich ändert.
## - Alte Maps ohne "group"-Feld bleiben 1:1 ladbar (Feld abwesend =
## ungruppiert), neue Maps laufen auf unverändertem Server-Loader.
## - Kein format_version-Bump nötig (additiv + optional, Vorwärts- UND
## Rückwärtskompatibel). Format-Doku-Anpassung (map_format.md) als
## Folge-Karte im Match-Server-Repo.
## Vorteil gegenüber Sitzungsstatus: Gruppen überleben Speichern/Laden
## UND Undo/Redo (ActionHistory kopiert den objects-Block tief — das Feld
## reist mit jedem Snapshot mit).
##
## Konvention: Gruppen-IDs werden bei jeder "Gruppieren"-Aktion neu vergeben
## (max + 1). Lücken (nach Löschen/Lösen) sind erlaubt und harmlos.
const UNGROUPED := -1
## Gruppen-ID eines Objekts (ungültig/abwesend → UNGROUPED).
static func group_id(obj) -> int:
if not (obj is Dictionary):
return UNGROUPED
var g = obj.get("group", UNGROUPED)
if not (g is int or g is float) or not is_finite(float(g)):
return UNGROUPED
var gi := int(float(g))
return gi if gi >= 0 else UNGROUPED
## Objekt-Feld hart auf "ungruppiert" setzen (Feld entfernen — sauberes Format).
static func _clear_group(obj: Dictionary) -> void:
obj.erase("group")
## Aktive Gruppen-IDs (aufsteigend sortiert) — nur IDs mit ≥ 1 Mitglied.
static func active_group_ids(doc: Dictionary) -> Array:
var ids := {}
for obj in (doc.get("objects", []) as Array):
var g := group_id(obj)
if g != UNGROUPED:
ids[g] = true
var out: Array = []
for g in ids.keys():
out.append(int(g))
out.sort()
return out
## Nächste freie Gruppen-ID (max aktiv + 1; leere Map → 0).
static func next_group_id(doc: Dictionary) -> int:
var max_id := UNGROUPED
for obj in (doc.get("objects", []) as Array):
var g := group_id(obj)
if g > max_id:
max_id = g
return max_id + 1
## Indizes aller Mitglieder von Gruppe gid (aufsteigend).
static func members_of(doc: Dictionary, gid: int) -> Array:
var out: Array = []
var objects: Array = doc.get("objects", [])
for i in objects.size():
if group_id(objects[i]) == gid:
out.append(i)
return out
## Die aktiven Gruppen der gegebenen Indizes (für "Gruppe lösen").
static func groups_of(doc: Dictionary, indices: Array) -> Array:
var ids := {}
var objects: Array = doc.get("objects", [])
for i in indices:
if i is int and i >= 0 and i < objects.size():
var g := group_id(objects[i])
if g != UNGROUPED:
ids[g] = true
return ids.keys()
## Ausgewählte Objekte zu EINER Gruppe machen. Gibt die neue Gruppen-ID
## zurück (−1, wenn weniger als 2 gültige Indizes — eine Gruppe braucht ≥ 2).
## Objekte, die schon in anderen Gruppen waren, verlassen diese.
static func group(doc: Dictionary, indices: Array) -> int:
var valid := _valid_indices(doc, indices)
if valid.size() < 2:
return UNGROUPED
var gid := next_group_id(doc)
var objects: Array = doc["objects"]
for i in valid:
objects[i]["group"] = gid
return gid
## Gruppe(n) der gegebenen Indizes KOMPLETT lösen (alle Mitglieder, nicht nur
## die Auswahl — sonst bliebe eine Rumpfgruppe zurück). Rückgabe: Anzahl
## befreiter Objekte.
static func ungroup(doc: Dictionary, indices: Array) -> int:
var objects: Array = doc.get("objects", [])
var freed := 0
for gid in groups_of(doc, indices):
for i in members_of(doc, gid):
_clear_group(objects[i])
freed += 1
return freed
## Mittel-Punkt (XZ) der gegebenen Objekt-Indizes (Dreh-Mitte).
static func centroid(doc: Dictionary, indices: Array) -> Vector3:
var objects: Array = doc.get("objects", [])
var sum := Vector3.ZERO
var n := 0
for i in _valid_indices(doc, indices):
var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
sum += Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
n += 1
return sum / float(maxi(n, 1))
## Mitglieder gemeinsam verschieben (XZ + delta), Terrain-Clamp PRO Objekt:
## X/Z bleiben in der Map, Y wird auf die Terrain-Höhe an der neuen Stelle
## gesetzt. Rückgabe: Anzahl tatsächlich geänderter Objekte.
static func move_members(doc: Dictionary, indices: Array, delta: Vector3) -> int:
var objects: Array = doc.get("objects", [])
var half := _terrain_half(doc)
var moved := 0
for i in _valid_indices(doc, indices):
var obj: Dictionary = objects[i]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var x := clampf(float(pos[0]) + delta.x, -half.x, half.x)
var z := clampf(float(pos[2]) + delta.z, -half.y, half.y)
var y := SvmapDocument.terrain_height_at(doc, x, z)
if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001 \
or absf(y - float(pos[1])) > 0.0001:
moved += 1
obj["pos"] = [x, y, z]
return moved
## Mitglieder gemeinsam DREHEN: Positionen rotieren um den Gruppen-Mittelpunkt
## (XZ, Godot-Y-Konvention wie marker.rotation.y) und rot_y_deg je Objekt
## += delta_deg. Terrain-Clamp PRO Objekt danach (die Gruppe kann über eine
## Kante wandern). Rückgabe: Anzahl geänderter Objekte.
static func rotate_members(doc: Dictionary, indices: Array, delta_deg: float) -> int:
var objects: Array = doc.get("objects", [])
var valid := _valid_indices(doc, indices)
if valid.is_empty():
return 0
var c := centroid(doc, valid)
var rad := deg_to_rad(delta_deg)
var cos_a := cos(rad)
var sin_a := sin(rad)
var half := _terrain_half(doc)
var changed := 0
for i in valid:
var obj: Dictionary = objects[i]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var dx := float(pos[0]) - c.x
var dz := float(pos[2]) - c.z
var x := clampf(c.x + dx * cos_a + dz * sin_a, -half.x, half.x)
var z := clampf(c.z - dx * sin_a + dz * cos_a, -half.y, half.y)
var y := SvmapDocument.terrain_height_at(doc, x, z)
if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001 \
or absf(y - float(pos[1])) > 0.0001:
changed += 1
obj["pos"] = [x, y, z]
# 3 Achsen (Karte t_e166e656): Gruppendrehung dreht die Y-Komponente
# und lässt Kippen (X/Z) unverändert — rot/rot_y_deg bleiben synchron.
var euler := SvmapIO.objekt_rotation(obj)
SvmapIO.rot_setzen(obj, Vector3(
euler.x, fmod(euler.y + delta_deg + 3600.0, 360.0), euler.z))
return changed
static func _valid_indices(doc: Dictionary, indices: Array) -> Array:
var objects: Array = doc.get("objects", [])
var out: Array = []
for i in indices:
if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary):
out.append(i)
return out
static func _terrain_half(doc: Dictionary) -> Vector2:
var terrain: Dictionary = doc.get("terrain", {})
return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5

View file

@ -1 +0,0 @@
uid://d0luucpxtrabo

View file

@ -1,61 +0,0 @@
extends RefCounted
class_name MoveMath
## Shortcuts-Mathe für Karte 2/3 (t_55257792) — bewusst pure Funktionen
## (kein Knoten, keine Kamera): Pfeiltasten-Raster-Schritt + Terrain-
## Pinselgrößen-Zyklus. tests/test_shortcuts.gd prüft headless; main.gd und
## interaction.gd sind nur die dünnen Anwender.
##
## Pfeiltasten-Konvention (Editor-Üblichkeit): „↑" bewegt die Auswahl VON der
## Kamera WEG (in Blickrichtung, horizontal), „↓" auf sie zu, links/rechts
## entsprechend quer. Die Blickrichtung wird auf die NÄCHSTE Weltachse
## gerastet (Godot-Editor-Konvention) — eine Pfeiltaste verschiebt also immer
## exakt entlang X oder Z, nie diagonal.
const NUDGE_STEP_M := 2.0 # eine Rasterzelle pro Tastendruck (Karte 2/3)
## Pfeil-Richtung → Welt-Delta (2 m). yaw_deg = Kamera-Gierwinkel (Orbit ODER
## Flug), fwd = +1 (↑) / −1 (↓), side = +1 (rechts) / −1 (links).
## Die horizontale Blickrichtung (FlyMath.forward_from_euler(yaw, 0)) wird auf
## die nächste Weltachse gerastet: |z| >= |x| → Z-Achse, sonst X-Achse
## (Gleichstand → Z, deterministisch). Rechts = Blickrichtung × oben gekreuzt.
static func nudge_delta(yaw_deg: float, fwd: int, side: int) -> Vector3:
var f := forward_from_euler(yaw_deg, 0.0)
var axis := Vector3.ZERO
if absf(f.z) >= absf(f.x):
axis = Vector3(0.0, 0.0, -1.0) if f.z <= 0.0 else Vector3(0.0, 0.0, 1.0)
else:
axis = Vector3(-1.0, 0.0, 0.0) if f.x <= 0.0 else Vector3(1.0, 0.0, 0.0)
var right := axis.cross(Vector3.UP)
return (axis * float(fwd) + right * float(side)) * NUDGE_STEP_M
## Horizontale Blickrichtung (nur Yaw — identische Formel wie FlyMath, hier
## kopiert statt Abhängigkeit, damit die Datei ohne FlyMath-Load testbar bleibt).
static func forward_from_euler(yaw_deg: float, _pitch_deg: float) -> Vector3:
var yaw := deg_to_rad(yaw_deg)
return Vector3(-sin(yaw), 0.0, -cos(yaw))
# ------------------------------------------------- Terrain-Pinsel-Presets ----
## Pinselgröße-Zyklus durch die 3 Presets (Klein 4 m / Mittel 12 m / Groß 32 m,
## wie die Buttons im Terrain-Panel). up = ] (vergrößern), down = [ (verkleinern).
## Bei einem FREIEN Slider-Wert (nutzergezogen) geht ] zum nächsten Preset
## ÜBER dem Wert, [ zum nächsten DARUNTER — am Rand wird zyklisch gewrappt.
## Rückgabe: der neue Radius in Metern.
static func brush_next_preset(current_m: float, up: bool) -> float:
var presets := [4.0, 12.0, 32.0]
if up:
for r in presets:
if r > current_m + 0.001:
return r
return presets[0] # zyklisch: über dem größten → wieder Klein
# Abwärts: das NÄCHSTE Preset UNTER dem Wert (größtes darunter) —
# rückwärts iterieren, sonst fände die Schleife immer Klein zuerst.
var i := presets.size() - 1
while i >= 0:
if presets[i] < current_m - 0.001:
return presets[i]
i -= 1
return presets[presets.size() - 1] # zyklisch: unter dem kleinsten → Groß

View file

@ -1 +0,0 @@
uid://behfbtvkyai65

View file

@ -1,384 +0,0 @@
extends RefCounted
class_name ObjectOps
## Objekt-Operationen des Editors (Karte t_3a998ae4) — bewusst pure Funktionen
## (kein UI, kein Knoten): Snap-to-Ground und Raster-Snap. main.gd/interaction.gd
## rufen auf, tests/test_library.gd prüft headless.
const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte)
const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend"
const LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f)
const DUPLICATE_OFFSET_M := Vector2(2.0, 0.0) # Kopie +2 m in X (Rasterversatz,
# Karte t_c46d31c2)
## Auswahl per Pfeiltaste im 2-m-Raster verschieben (Karte t_55257792) — pure.
## Genau das gegebene Delta (±2 m pro Tastendruck, Richtung von MoveMath),
## X/Z an der Map geklemmt. Die HÖHE bleibt relativ zum Terrain erhalten
## (t_7567a06f-Semantik wie beim Ziehen): ein angehobenes Objekt schwebt
## weiter, ein bodennahes bleibt bodennah — ein Objekt auf dem 2-m-Raster
## landet wieder exakt auf dem Raster. Rückgabe: Anzahl geänderter Objekte.
static func nudge_objects(doc: Dictionary, indices: Array, delta: Vector3) -> int:
var objects: Array = doc.get("objects", [])
var half := _terrain_half_size(doc)
var changed := 0
for i in indices:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var obj: Dictionary = objects[i]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() != 3:
continue
var x := clampf(float(pos[0]) + delta.x, -half.x, half.x)
var z := clampf(float(pos[2]) + delta.z, -half.y, half.y)
var lift := float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
var y := SvmapDocument.terrain_height_at(doc, x, z) + lift
if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001:
changed += 1
obj["pos"] = [x, y, z]
return changed
## Die AUSWAHL auf den Boden absetzen (Strg+Bild↑, Karte t_55257792 — das
## Godot-"Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell): pos.y wird
## exakt die Terrain-Höhe an (x, z). X/Z bleiben unangetastet. Rückgabe:
## Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden).
static func snap_selection_to_floor(doc: Dictionary, indices: Array) -> int:
var objects: Array = doc.get("objects", [])
var changed := 0
for i in indices:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() != 3:
continue
var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
if absf(float(pos[1]) - h) > FLOAT_EPSILON:
(objects[i] as Dictionary)["pos"] = [float(pos[0]), h, float(pos[2])]
changed += 1
return changed
static func _terrain_half_size(doc: Dictionary) -> Vector2:
var terrain: Dictionary = doc.get("terrain", {})
return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter.
## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden).
static func snap_to_ground(doc: Dictionary) -> int:
var changed := 0
var objects: Array = doc.get("objects", [])
for obj in objects:
if not (obj is Dictionary):
continue
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() != 3:
continue
var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
if absf(float(pos[1]) - h) > FLOAT_EPSILON:
obj["pos"] = [float(pos[0]), h, float(pos[2])]
changed += 1
return changed
## Eine Koordinate aufs Raster legen (Raster-Snap). grid_m <= 0 oder aus = no-op.
static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float:
if not enabled or grid_m <= 0.0:
return value
return roundf(value / grid_m) * grid_m
# ---------------------------------------------------- Objekt-Ops (t_7567a06f) --
# Duplizieren · Anheben/Absenken · Spiegeln. Bewusst pure Funktionen wie der
# Rest der Datei: die UI (interaction.gd/main.gd) snapshotet, ruft auf, commitet.
## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie
## ist um DUPLICATE_OFFSET_M versetzt (+2 m in X, Rasterversatz Karte
## t_c46d31c2; mit Raster-Snap eingerastet, wenn an) und behält Drehung/Größe/
## Höhe — der Füßpunkt (pos.y) bleibt exakt. Rückgabe: neuer Index (-1 =
## ungültig). VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung
## statt Fehler).
static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool,
offset_x := DUPLICATE_OFFSET_M.x) -> int:
var objects: Array = doc.get("objects", [])
if index < 0 or index >= objects.size():
return -1
var source = objects[index]
if not (source is Dictionary):
return -1
var copy: Dictionary = (source as Dictionary).duplicate(true)
var pos: Array = copy.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() == 3:
var nx := float(pos[0]) + offset_x
var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y
copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)]
objects.append(copy)
return objects.size() - 1
## MEHRERE Objekte auf einmal duplizieren (Karte t_c46d31c2, Strg+D bei
## Mehrfachauswahl): jede Kopie über duplicate_object (+2 m in X), Reihenfolge
## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt).
## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet
## EINEN Undo-Schritt für alle Kopien zusammen.
static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool,
offset_x := DUPLICATE_OFFSET_M.x) -> Array:
var new_indices: Array = []
for i in indices:
var ni := duplicate_object(doc, int(i), grid_enabled, offset_x)
if ni >= 0:
new_indices.append(ni)
regroup_copies(doc, new_indices)
return new_indices
## Kopien gruppierter Objekte bilden eine NEUE Gruppe je Original-Gruppe — sonst
## würde die Kopie eines Gebäudes mit dem Original zu EINER Gruppe verschmelzen
## (Gruppen-ID wird von duplicate(true) mitkopiert). Name: "<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

View file

@ -1 +0,0 @@
uid://dnocqoteya1h3

View file

@ -6,7 +6,7 @@ extends Node
## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen) ## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen)
## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input ## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input
## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden. ## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden.
## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel ## - Kollider-Bau via KolliderBau (core/svmap/kollider_bau.gd — 1:1-Spiegel
## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider ## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider
## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen) ## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen)
## + Bauteil-Trimeshe (bau_*-Objekte: Treppen/Türen/Wände begehbar ## + Bauteil-Trimeshe (bau_*-Objekte: Treppen/Türen/Wände begehbar
@ -22,7 +22,7 @@ extends Node
## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD. ## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD.
const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd" const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd"
const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd" const KOLLIDER_SCRIPT := "res://core/svmap/kollider_bau.gd"
const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd" const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd"
## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik) ## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik)

View file

@ -1,143 +0,0 @@
extends RefCounted
class_name SelectionMath
## Rubberband-Auswahl-Mathematik (Karte t_f6f29d86) — pure, statisch,
## headless testbar (kein Viewport nötig — Transform + Projection genügen).
##
## Ein Objekt gilt als "im Rahmen", wenn sein Anker-Punkt (Fußpunkt der
## Objekt-Marker) im Bildschirm-Rechteck liegt. Anker statt Mesh-Exakt:
## schnell und fürs Auge richtig — Marker sind die Klick-Flächen.
## Kern (getestet): Weltpunkte → Bildschirmprojektion mit MITGEBENGER
## Kamera-Transform + Projektionsmatrix. Punkte hinter der Kamera
## (Blickrichtung -Z, view.z >= 0) werden ignoriert.
static func pick_in_rect_projection(cam_xform: Transform3D, proj: Projection,
viewport_size: Vector2, world_positions: Array, rect: Rect2) -> Array:
var out: Array = []
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
return out
var inv := cam_xform.affine_inverse()
for i in world_positions.size():
var p: Variant = world_positions[i]
if not (p is Vector3):
continue
var view: Vector3 = inv * (p as Vector3)
if view.z >= 0.0:
continue # hinter der Kamera (kein Spiegel-Treffer)
var clip: Vector4 = proj * Vector4(view.x, view.y, view.z, 1.0)
if absf(clip.w) < 0.0000001:
continue
var ndc := Vector2(clip.x / clip.w, clip.y / clip.w)
var screen := Vector2((ndc.x * 0.5 + 0.5) * viewport_size.x,
(1.0 - (ndc.y * 0.5 + 0.5)) * viewport_size.y)
if rect.has_point(screen):
out.append(i)
return out
## Runtime-Variante (Editor): nimmt die Viewport-Kamera direkt.
static func pick_in_rect(cam: Camera3D, world_positions: Array, rect: Rect2) -> Array:
if cam == null or not is_instance_valid(cam):
return []
var viewport_size := Vector2(cam.get_viewport().size)
return pick_in_rect_projection(cam.global_transform, cam.get_camera_projection(),
viewport_size, world_positions, rect)
## Normalisiertes Rechteck aus zwei Ecken (auch "rückwärts" aufgezogen).
static func rect_from(a: Vector2, b: Vector2) -> Rect2:
return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)),
Vector2(absf(b.x - a.x), absf(b.y - a.y)))
## Mittelpunkt der Auswahl für "F = Auswahl ins Bild zentrieren"
## (Karte t_c46d31c2): Mittelwert der Marker-Anker (+ halbe Mesh-Höhe — der
## optische Objekt-Mittelpunkt, nicht der Fußpunkt). Ungültige Indizes/Marker
## werden übersprungen; bei GAR NICHTS Gültigem: Ursprung (main meldet dann
## „nichts ausgewählt“ — der Guard sitzt beim Aufrufer).
static func focus_center(markers: Array, indices: Array) -> Vector3:
var sum := Vector3.ZERO
var count := 0
for i in indices:
if not (i is int) or i < 0 or i >= markers.size():
continue
var m: Variant = markers[i]
if m == null or not (m is Node3D) or not is_instance_valid(m):
continue
var node := m as Node3D
sum += node.global_position \
+ Vector3.UP * float(node.get_meta("half_height", 1.0)) * 0.5
count += 1
if count == 0:
return Vector3.ZERO
return sum / float(count)
# ------------------------------------------------- Exakter Klick-Treffer ---
## Faces-Cache pro Mesh (Klick-Pick, Karte Mauer-Klick 24.10.): get_faces()
## kopiert jedes Mal das ganze Dreiecks-Array — einmal pro Mesh reicht.
static var _faces_cache: Dictionary = {}
## Strahl gegen die ECHTEN Dreiecke eines Meshes (Möller–Trumbore über
## ray_tri). xform = globale Transform der MeshInstance (inkl.
## Objekt-Drehung/-Kippung, Platzhalter-Lift und Node-Scale).
## Rückgabe: Welt-Abstand vom Strahl-Ursprung zum nächsten Treffer, -1 = keiner.
## WARUM: Die Kugel-Näherung (pick_radius = halbe AABB-Diagonale) eines großen
## Gebäudes ragt weit über seine Wände hinaus — ein Klick auf die schmale
## Mauer daneben traf zuerst die Gebäude-Kugel (Sly-Befund 22.10.).
static func ray_mesh_distance(xform: Transform3D, faces: PackedVector3Array,
origin: Vector3, dir: Vector3) -> float:
if faces.size() < 3:
return -1.0
var inv := xform.affine_inverse()
var lo: Vector3 = inv * origin
var ld: Vector3 = inv.basis * dir
var best := INF
for i in int(faces.size() / 3):
var t := ray_tri(lo, ld, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2])
if t >= 0.0 and t < best:
best = t
if best == INF:
return -1.0
return origin.distance_to(xform * (lo + ld * best))
## Faces eines Meshes (gecacht). Leeres Array, wenn kein Mesh.
static func mesh_faces(mesh: Mesh) -> PackedVector3Array:
if mesh == null:
return PackedVector3Array()
var key := mesh.get_rid()
if not _faces_cache.has(key):
_faces_cache[key] = mesh.get_faces()
return _faces_cache[key]
## Objekt-Mesh eines Markers: das Modell ist laut
## map_view_builder._build_objects IMMER das erste MeshInstance3D-Kind.
static func marker_mesh(marker: Node3D) -> MeshInstance3D:
for child in marker.get_children():
if child is MeshInstance3D:
return child as MeshInstance3D
return null
## Möller–Trumbore: Abstand t entlang des Strahls zum Dreieck (a, b, c), -1 = kein Treffer.
static func ray_tri(origin: Vector3, dir: Vector3, a: Vector3, b: Vector3, c: Vector3) -> float:
var e1 := b - a
var e2 := c - a
var p := dir.cross(e2)
var det := e1.dot(p)
if absf(det) < 0.000001:
return -1.0
var inv := 1.0 / det
var tv := origin - a
var u := tv.dot(p) * inv
if u < 0.0 or u > 1.0:
return -1.0
var q := tv.cross(e1)
var v := dir.dot(q) * inv
if v < 0.0 or u + v > 1.0:
return -1.0
return e2.dot(q) * inv

View file

@ -1 +0,0 @@
uid://00t31s6wj67c

View file

@ -1,325 +0,0 @@
extends RefCounted
class_name SpawnOps
## Spawn-Zonen-Operationen des Editors (M2, Karte t_c4162c5d) — pure Funktionen
## (kein UI, kein Knoten), wie ObjectOps für Objekte. main.gd/spawn_tool.gd
## rufen auf, tests/test_spawns.gd prüft headless.
##
## Format-Realität (map_format.md): spawn_zones speichert pro Team PUNKTE
## (id + pos) — der Kreis in der Karte ist die Editor-Visualisierung einer
## "Zone" um so einen Punkt. Sein Radius ist KEIN Formatfeld; er überlebt
## Speichern/Laden in design_notes.spawn_radius_m — der Loader prüft
## design_notes nicht, also rundtrip-sicher und trotzdem lesbar in der Datei.
##
## Die Validierung hier spiegelt 1:1 die Loader-Regeln (svmap_io.gd, Tabelle
## docs/map_format.md §4): V11 — beide Teams je ≥ 4 Spawn-Punkte; V7 — jede
## Objekt-ID in der Bibliothek. Live-Anzeige und "Karte prüfen" reden genau
## das, was der Loader beim Export entscheiden wird (keine Zweit-Meinung).
const DEFAULT_ZONE_RADIUS := 6.0 # Meter — geräumig für einen Spieler-Start
const MIN_ZONE_RADIUS := 2.0
const MAX_ZONE_RADIUS := 30.0
const RADIUS_KEY := "spawn_radius_m" # Unter design_notes (Loader-frei, siehe oben)
const YAW_KEY := "yaw_deg" # Am Punkt/an der Zone selbst, Server und Client lesen es
# TDM-Konvention (map_format.md): Team 0 = Blau = Prefix "A" (A1, A2 …),
# Team 1 = Rot = Prefix "B" (B1, B2 …).
const TEAM_LABELS := ["Team Blau", "Team Rot"]
const TEAM_PREFIXES := ["A", "B"]
## Welcher Spawn-Satz gerade bearbeitet wird (reiner Editor-Zustand, nicht im Dokument):
## "base" = doc.spawn_zones (Standard für alle Modi), sonst ein Modus aus
## doc.mode_spawns (eigene Spawns dieses Modus, ModusKatalog). Deathmatch kennt nur
## den Schlüssel "all" — beide Team-Werkzeuge schreiben dorthin.
static var edit_set: String = ModusKatalog.SPAWN_SET_BASE
const SET_ID_PREFIX := {"tdm": "T", "conquest": "Q", "koth": "K", "dm": "D"}
static func _team_key(team: int) -> String:
return "all" if edit_set == ModusKatalog.MODE_DM else str(clampi(team, 0, 1))
## Zonen-Block des bearbeiteten Satzes: {"0":{points}, "1":{points}} bzw. {"all":{points}}.
## create=true legt fehlende Blöcke an (nur für Schreibzugriffe).
static func zones_of(doc: Dictionary, create := false) -> Dictionary:
if edit_set == ModusKatalog.SPAWN_SET_BASE:
var z = doc.get("spawn_zones", {})
if not (z is Dictionary):
z = {}
if create:
doc["spawn_zones"] = z
return z
var ms = doc.get("mode_spawns", {})
if not (ms is Dictionary):
ms = {}
var set_v = (ms as Dictionary).get(edit_set, {})
if not (set_v is Dictionary):
set_v = {}
if create:
doc["mode_spawns"] = ms
(ms as Dictionary)[edit_set] = set_v
return set_v
static func team_label(team: int) -> String:
return TEAM_LABELS[clampi(team, 0, 1)]
static func team_prefix(team: int) -> String:
return TEAM_PREFIXES[clampi(team, 0, 1)]
## Der Zone-Block eines Teams ("0"/"1") — immer ein Dictionary (leer, wenn fehlt).
static func zone_dict(doc: Dictionary, team: int) -> Dictionary:
var zones = zones_of(doc)
var zone = zones.get(_team_key(team), {})
if zone is Dictionary:
return zone
return {}
## Spawn-Punkte eines Teams als Array (leer, wenn Block fehlt/defekt ist).
static func points(doc: Dictionary, team: int) -> Array:
var p = zone_dict(doc, team).get("points", [])
return p if p is Array else []
static func count_points(doc: Dictionary, team: int) -> int:
return points(doc, team).size()
## Nächste freie Spawn-ID fürs Team ("A5") — Lücken werden übersprungen
## (Team-Prefixe trennen die Teams, doppelt vergebene Nummern nie).
static func next_id(doc: Dictionary, team: int) -> String:
var used := {}
var all_sets: Array = [doc.get("spawn_zones", {})]
var ms = doc.get("mode_spawns", {})
if ms is Dictionary:
all_sets.append_array((ms as Dictionary).values())
for zones in all_sets:
if not (zones is Dictionary):
continue
for key in zones.keys():
var zone = zones[key]
if not (zone is Dictionary):
continue
var pts = zone.get("points", [])
if not (pts is Array):
continue
for p in pts:
if p is Dictionary:
used[str(p.get("id", ""))] = true
var prefix := team_prefix(team)
if edit_set != ModusKatalog.SPAWN_SET_BASE:
prefix = str(SET_ID_PREFIX.get(edit_set, "M")) + ("" if edit_set == ModusKatalog.MODE_DM else prefix)
var i := 1
while used.has("%s%d" % [prefix, i]):
i += 1
return "%s%d" % [prefix, i]
## Radius einer Zone aus design_notes (Default, wenn nicht gesetzt) — geclampt.
static func zone_radius(doc: Dictionary, pid: String) -> float:
var notes = doc.get("design_notes", {})
if not (notes is Dictionary):
return DEFAULT_ZONE_RADIUS
var radii = (notes as Dictionary).get(RADIUS_KEY, {})
if not (radii is Dictionary):
return DEFAULT_ZONE_RADIUS
return clampf(float(radii.get(pid, DEFAULT_ZONE_RADIUS)), MIN_ZONE_RADIUS, MAX_ZONE_RADIUS)
## Radius setzen (geklammert auf Editor-Range). Legt design_notes an, wenn fehlt.
static func set_radius(doc: Dictionary, pid: String, m: float) -> void:
var notes = doc.get("design_notes", {})
if not (notes is Dictionary):
notes = {}
doc["design_notes"] = notes
var radii = (notes as Dictionary).get(RADIUS_KEY, {})
if not (radii is Dictionary):
radii = {}
(notes as Dictionary)[RADIUS_KEY] = radii
radii[pid] = clampf(m, MIN_ZONE_RADIUS, MAX_ZONE_RADIUS)
## History-Schnappschuss für JEDE Spawn-Zonen-Änderung: spawn_zones REGULÄR
## über die ActionHistory, design_notes IMMER mit — auch wenn es vorher nicht
## gab (leerer Block) — damit Radius-Änderungen sauber mit-Undo-en und keine
## Alt-Radien nach Undo herumgeistern.
static func snapshot_blocks(hist: ActionHistory, doc: Dictionary) -> Dictionary:
var out := hist.snapshot(["spawn_zones"])
var ms: Variant = doc.get("mode_spawns", null) # fehlend = null → Undo löscht den Block wieder
out["mode_spawns"] = ms.duplicate(true) if ms is Dictionary else null
out["design_notes"] = (doc.get("design_notes", {}) as Dictionary).duplicate(true)
return out
## Blickrichtung beim Spawn (optionales Feld yaw_deg an Spawn-Punkten und Conquest-Zonen).
## Fehlt es, schaut der Spieler zur Kartenmitte — der Pfeil zeigt das grau an.
static func has_yaw(entry: Dictionary) -> bool:
return entry.has(YAW_KEY)
static func yaw_towards(from: Vector3, to: Vector2) -> float:
var dx := to.x - from.x
var dz := to.y - from.z
if Vector2(dx, dz).length() < 0.5:
return 0.0
return fposmod(rad_to_deg(atan2(-dx, -dz)), 360.0)
## Die Richtung, die im Spiel gilt: gesetzte oder Standard (zur Kartenmitte).
static func effective_yaw(doc: Dictionary, entry: Dictionary) -> float:
if entry.has(YAW_KEY):
return fposmod(float(entry[YAW_KEY]), 360.0)
var pos: Array = entry.get("pos", [0.0, 0.0, 0.0])
return yaw_towards(Vector3(float(pos[0]), float(pos[1]), float(pos[2])), SymmetryCore.map_center(doc))
## yaw = Grad setzen, null = zurück auf Standard.
static func write_yaw(entry: Dictionary, yaw: Variant) -> void:
if yaw == null:
entry.erase(YAW_KEY)
else:
entry[YAW_KEY] = fposmod(roundf(float(yaw)), 360.0)
static func set_point_yaw(doc: Dictionary, team: int, pid: String, yaw: Variant) -> bool:
for p in points(doc, team):
if p is Dictionary and str(p.get("id", "")) == pid:
write_yaw(p, yaw)
return true
return false
## Neue Spawn-Zone (Punkt) ans Team hängen. y = Terrain-Höhe (Punkt sitzt auf
## dem Boden; der Loader prüft nur Grenzen, aber schwebende Spawns verwirren).
## Rückgabe: der neue Punkt (id + pos).
static func add_zone(doc: Dictionary, team: int, x: float, z: float) -> Dictionary:
var zones: Dictionary = zones_of(doc, true)
var team_key := _team_key(team)
var zone = zones.get(team_key, {})
if not (zone is Dictionary):
zone = {}
if not zone.has("label"):
zone["label"] = team_label(team)
zones[team_key] = zone
if not (zone.get("points", []) is Array):
zone["points"] = []
var point := {
"id": next_id(doc, team),
"pos": [x, SvmapDocument.terrain_height_at(doc, x, z), z],
}
(zone["points"] as Array).append(point)
return point
## Zone verschieben (x/z wie übergeben, y = Terrain-Höhe). false, wenn fehlt.
## Außerhalb-Kandidaten clampt der Aufrufer (spawn_tool auf die Terrain-Fläche).
static func move_point(doc: Dictionary, team: int, pid: String, x: float, z: float) -> bool:
for p in points(doc, team):
if p is Dictionary and str(p.get("id", "")) == pid:
p["pos"] = [x, SvmapDocument.terrain_height_at(doc, x, z), z]
return true
return false
## Zone löschen (inkl. Radius-Eintrag in design_notes — sonst würde eine
## spätere Zone mit derselben ID einen alten Radius erben). false, wenn fehlt.
static func remove_point(doc: Dictionary, team: int, pid: String) -> bool:
var pts := points(doc, team)
for i in pts.size():
var p = pts[i]
if p is Dictionary and str(p.get("id", "")) == pid:
pts.remove_at(i)
var notes = doc.get("design_notes", {})
if notes is Dictionary:
var radii = (notes as Dictionary).get(RADIUS_KEY, {})
if radii is Dictionary:
(radii as Dictionary).erase(pid)
return true
return false
## Live-Validierungs-Zeilen (Loader-Regeln V11 + V7) — je {ok, text, hint}.
## Genau die Antwort auf "darf diese Map exportiert werden?" — BEVOR man exportiert.
static func validate_lines(doc: Dictionary, manifest: Dictionary = {}) -> Array:
var lines: Array = []
var min_pts := SvmapIO.MIN_SPAWN_POINTS_PER_TEAM
for team in [0, 1]:
var count := count_points(doc, team)
var ok := count >= min_pts
var text := "%s Spawns %s: %d (mindestens %d nötig)" % [
"✅" if ok else "❌", team_label(team), count, min_pts]
var hint := ""
if not ok:
hint = "Im Werkzeug »Spawns« %d Zone(n) für %s auf den Boden malen" % [
min_pts - count, team_label(team)]
lines.append({"ok": ok, "text": text, "hint": hint})
# V7: Objekt-IDs gegen die Bibliothek (keine Objekte = ok — Objects sind optional).
var lib: Dictionary = manifest.get("objects", {}) if manifest is Dictionary else {}
var total := 0
var unknown := 0
for obj in (doc.get("objects", []) as Array):
total += 1
if not (obj is Dictionary) or not lib.has(str(obj.get("id", ""))):
unknown += 1
var ids_ok := unknown == 0
var ids_text := "%s Objekt-IDs: %s" % ["✅" if ids_ok else "❌",
"alle %d in der Bibliothek" % total if unknown == 0 else
"%d von %d unbekannt — auswendige Namen kann der Server nicht laden" % [unknown, total]]
lines.append({"ok": ids_ok, "text": ids_text, "hint": "Bibliothek bietet nur bekannte Objekte" if not ids_ok else ""})
return lines
## Einzeiliger Live-Status für die rechte Spalte (ständig sichtbar, aktualisiert
## sich bei jeder Änderung): "✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 …"
static func live_status(doc: Dictionary, manifest: Dictionary = {}) -> String:
var parts: Array = []
for line in validate_lines(doc, manifest):
parts.append(str(line["text"]))
return " · ".join(PackedStringArray(parts))
## "Karte prüfen" (Map-Fertig-Check): Klartext-Liste ✅/❌ + ok-Flag.
## Der harte Gate bleibt das SPEICHERN (SvmapIO.save_to_file validiert und
## signiert mit demselben Kern) — der Check hier verspricht dasselbe Ergebnis,
## nur freundlich und VOR dem Export. ok = der echte Loader-Kern (_validate_core)
## wäre grün; die Prüfsumme bewusst NICHT — die macht erst das Speichern.
static func ready_report(doc: Dictionary, manifest: Dictionary = {}) -> Dictionary:
var lines: Array = []
var info: Dictionary = doc.get("info", {})
var name_ok := str(info.get("name", "")).strip_edges() != ""
lines.append({"ok": name_ok, "text": "%s Map-Name ist gesetzt%s" % [
"✅" if name_ok else "❌",
"" if name_ok else " — unter »Map-Eigenschaften« eintragen"]})
var author_ok := str(info.get("author", "")).strip_edges() != ""
lines.append({"ok": author_ok, "text": "%s Autor ist gesetzt%s" % [
"✅" if author_ok else "❌",
"" if author_ok else " — unter »Map-Eigenschaften« eintragen"]})
for line in validate_lines(doc, manifest):
lines.append(line)
var used := SvmapDocument.poly_usage(doc, manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY)
var poly_frac := used / budget if budget > 0.0 else 0.0
var poly_ok := poly_frac <= 1.0
lines.append({"ok": poly_ok, "text": "%s Poly-Budget: %.0f %% belegt (%s von %s)" % [
"✅" if poly_ok else "❌", poly_frac * 100.0,
_fmt_thousand(int(used)), _fmt_thousand(int(budget))]})
# Harte Wahrheit: der Loader-Kern entscheidet (dieselbe Funktion, die das
# Speichern vor dem Schreiben noch einmal aufruft — EINE Implementierung,
# kein Drift zwischen Anzeige und Export).
var core: PackedStringArray = SvmapIO._validate_core(doc, manifest)
var ok := core.is_empty()
if not ok:
for e in core:
lines.append({"ok": false, "text": "❌ %s" % str(e), "hint": ""})
return {"ok": ok, "lines": lines}
static func _fmt_thousand(value: int) -> String:
var s := str(value)
var out := ""
while s.length() > 3:
out = "." + s.substr(s.length() - 3) + out
s = s.substr(0, s.length() - 3)
return s + out

View file

@ -1 +0,0 @@
uid://c1yvw71ppjl28

View file

@ -1,233 +0,0 @@
extends RefCounted
class_name SymmetryCore
## Symmetrie-Modus im Map Builder (Karte t_f5935023, Spec
## /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Entscheidung Sly 20.10.):
## gespiegeltes Bauen — Änderungen an einer Kartenseite landen automatisch
## auf der anderen. DIESE Datei ist M1 (Fundament): reine Mathe + Format-
## Helfer, KEIN Spiegeln von Aktionen — das kommt mit M2 (Terrain),
## M3 (Objekte) und M4 (Bau-Teile + Spawns).
##
## Spiegel-Mathematik (Spec vc27):
## Zentrum = Kartenmitte. Editor-Maps liegen zentriert (bounds
## −size/2 … +size/2), die Mitte ist also in Weltkoordinaten (0, 0) —
## map_center() rechnet sie trotzdem generell aus den bounds aus
## ((min + max) / 2), damit die Helfer auch bei asymmetrischen bounds
## korrekt bleiben.
## | Modus | Position | rot_y |
## |-------------|-----------------------------|--------------|
## | Punkt (180°)| x' = 2cx − x, z' = 2cz − z | rot + 180° |
## | Achse X | x' = 2cx − x, z' = z | −rot |
## | Achse Z | x' = x, z' = 2cz − z | −rot |
## y bleibt in ALLEN Modi unverändert (Höhe ist ein Skalar).
## Standard = Punktspiegelung (nur sie macht beide Hälften wirklich
## deckungsgleich — Achsenspiegelung dreht den Zwilling um 180°, siehe Spec).
##
## Terrain-Zell-Abbildung (Zelle (i,j), i läuft entlang x, j entlang z —
## siehe SvmapDocument.terrain_height_at): Punkt (res−1−i, res−1−j),
## Achse X (res−1−i, j), Achse Z (i, res−1−j).
##
## Partner-Link: ein Paar trägt denselben `sym`-Schlüssel (optionales Feld
## an Objekten UND Bau-Teilen). Validator-Regel (svmap_io.gd, V16):
## String, nicht leer, ≤ 64 Zeichen, höchstens 2 Objekte pro Kennung.
## Alte Karten ohne `sym` laden unverändert (Rückwärtskompatibilität).
##
## Alles static + reine Funktionen, ohne Node-Abhängigkeit → headless testbar.
enum Mode {
OFF = 0, # Symmetrie aus
POINT = 1, # Punktspiegelung 180° um die Kartenmitte (Standard)
AXIS_X = 2, # Achsenspiegelung X (links ↔ rechts)
AXIS_Z = 3, # Achsenspiegelung Z (vorn ↔ hinten)
}
## Toolbar-/Statuszeilen-Texte, Index = Mode-Wert.
const MODE_LABELS: Array[String] = ["Aus", "Punkt", "Achse X", "Achse Z"]
## Toleranz in Metern für „liegt auf der Achse/im Zentrum“ — solche Elemente
## werden beim gespiegelten Bauen NICHT gedoppelt (Spec vc27).
const ON_AXIS_TOLERANCE_M := 0.05
# ------------------------------------------------------------ Modus/Helfer --
static func mode_label(mode: int) -> String:
return MODE_LABELS[clampi(mode, 0, MODE_LABELS.size() - 1)]
## Kartenmitte aus den bounds ((min + max) / 2 in X und Z). Für zentrierte
## Editor-Maps = (0, 0), unabhängig von der Kartengröße — die Größe steckt
## in den bounds. Fehlen die bounds (defektes doc) → (0, 0).
static func map_center(doc: Dictionary) -> Vector2:
var bounds: Dictionary = doc.get("bounds", {})
var mn: Array = bounds.get("min", [])
var mx: Array = bounds.get("max", [])
if (mn as Array).size() == 3 and (mx as Array).size() == 3:
return Vector2(
(float(mn[0]) + float(mx[0])) * 0.5,
(float(mn[2]) + float(mx[2])) * 0.5)
return Vector2.ZERO
# ----------------------------------------------------- Transform-Helfer -----
## Spiegelt die XZ-Position eines Objekts/Bau-Teils; Y (Höhe) bleibt gleich.
static func mirror_point(mode: int, p: Vector3, center: Vector2) -> Vector3:
match mode:
Mode.POINT:
return Vector3(2.0 * center.x - p.x, p.y, 2.0 * center.y - p.z)
Mode.AXIS_X:
return Vector3(2.0 * center.x - p.x, p.y, p.z)
Mode.AXIS_Z:
return Vector3(p.x, p.y, 2.0 * center.y - p.z)
_:
return p
## Spiegelt die Y-Drehung in Grad. Normalisiert auf [0, 360): zwei Anwendungen
## ergeben wieder die Ausgangsdrehung (modulo 360 — gleiche Orientierung).
## Punkt: +180°, Achsen: −180°-Spiegel = Vorzeichenwechsel.
static func mirror_rot_y(mode: int, rot_y_deg: float) -> float:
match mode:
Mode.POINT:
return fposmod(rot_y_deg + 180.0, 360.0)
Mode.AXIS_X, Mode.AXIS_Z:
return fposmod(-rot_y_deg, 360.0)
_:
return rot_y_deg
## Spiegelt eine Blickrichtung (Grad, Blick = (-sin, 0, -cos)) exakt mit: anders als
## mirror_rot_y zeigt der Pfeil danach wirklich in die gespiegelte Richtung.
static func mirror_facing(mode: int, yaw_deg: float) -> float:
match mode:
Mode.POINT:
return fposmod(yaw_deg + 180.0, 360.0)
Mode.AXIS_X:
return fposmod(-yaw_deg, 360.0)
Mode.AXIS_Z:
return fposmod(180.0 - yaw_deg, 360.0)
_:
return yaw_deg
## true, wenn der Punkt unter diesem Modus auf sich selbst abgebildet wird
## (Zentrum bei Punktspiegelung, Spiegelachse bei Achsenspiegelung, ± Toleranz).
## Solche Elemente werden beim gespiegelten Bauen NICHT gedoppelt.
static func maps_to_self(mode: int, p: Vector3, center: Vector2,
threshold_m: float = ON_AXIS_TOLERANCE_M) -> bool:
match mode:
Mode.POINT:
return Vector2(p.x - center.x, p.z - center.y).length() < threshold_m
Mode.AXIS_X:
return absf(p.x - center.x) < threshold_m
Mode.AXIS_Z:
return absf(p.z - center.y) < threshold_m
_:
return true
# ------------------------------------------------ Zell-Abbildung (Terrain) --
## Rasterzelle (i, j) → gespiegelte Zelle. res = terrain.resolution.
## i läuft entlang x, j entlang z (wie terrain_height_at). OFF → unverändert.
static func mirror_cell(mode: int, i: int, j: int, res: int) -> Vector2i:
if res < 2:
return Vector2i(i, j)
var last := res - 1
match mode:
Mode.POINT:
return Vector2i(last - i, last - j)
Mode.AXIS_X:
return Vector2i(last - i, j)
Mode.AXIS_Z:
return Vector2i(i, last - j)
_:
return Vector2i(i, j)
# ------------------------------------------------------------ Partner-Link --
## sym-Kennung eines Elements lesen (leer = kein Partner). Typsicher: nur
## nicht-leere Strings gelten; defekte Felder werden still als „kein Partner“
## behandelt (Rückwärtskompatibilität mit alten Karten).
static func sym_of(obj: Dictionary) -> String:
var s = obj.get("sym", "")
return s if s is String else ""
## sym-Kennung setzen (mutiert das Element und gibt es zurück zum Verketten).
static func set_sym(obj: Dictionary, key: String) -> Dictionary:
obj["sym"] = key
return obj
## sym-Kennung entfernen (Feld komplett löschen — fehlt es, wie in alten Karten).
static func clear_sym(obj: Dictionary) -> Dictionary:
obj.erase("sym")
return obj
## Index des Partner-Elements in einer Liste (objects ODER gebaeude):
## das Element mit derselben sym-Kennung (index selbst ausgeschlossen).
## −1 = kein Partner gefunden.
static func find_partner_index(list: Array, key: String, exclude_index: int = -1) -> int:
if key.is_empty():
return -1
for i in list.size():
if i == exclude_index or not (list[i] is Dictionary):
continue
if sym_of(list[i] as Dictionary) == key:
return i
return -1
## Nächste freie sym-Kennung im Dokument ("sym_1", "sym_2", …): scannt
## Objekte (inkl. Bauteile), deterministic (kein Zufall, keine Uhr).
static func next_sym_key(doc: Dictionary) -> String:
var used: Dictionary = {}
var block: Array = doc.get("objects", [])
for item in block:
if not (item is Dictionary):
continue
var s := sym_of(item as Dictionary)
if s.begins_with("sym_"):
var tail := s.substr(4)
if tail.is_valid_int():
used[int(tail)] = true
var n := 1
while used.has(n):
n += 1
return "sym_%d" % n
# ---------------------------------- Spiegel-Mathe 3 Achsen (t_e166e656) ------
## Spiegelt eine VOLLE Euler-Orientierung (Karte t_e166e656, Platzierungs-
## freiheit). Definition (dokumentiert in docs/map_format.md §objects.rot):
## - Achsenspiegelung (AXIS_X / AXIS_Z) = Konjugation der Basis mit der
## Spiegelmatrix: B' = M·B·M — für reines Y-Drehen exakt die bisherige
## Regel mirror_rot_y (−rot).
## - Punktspiegelung (POINT) = 180°-Drehung um Y: B' = Ry(180°)·B — für
## reines Y-Drehen exakt mirror_rot_y (+180°).
## Godot-Euler-Reihenfolge YXZ (Node3D-Rotation), Rückgabe in Grad.
static func mirror_rot_euler(mode: int, rot_deg: Vector3) -> Vector3:
var b := Basis.from_euler(Vector3(
deg_to_rad(rot_deg.x), deg_to_rad(rot_deg.y), deg_to_rad(rot_deg.z)))
var gespiegelt: Basis
match mode:
Mode.POINT:
gespiegelt = Basis.from_euler(Vector3(0.0, deg_to_rad(180.0), 0.0)) * b
Mode.AXIS_X:
var m := Basis.from_scale(Vector3(-1.0, 1.0, 1.0))
gespiegelt = m * b * m
Mode.AXIS_Z:
var m := Basis.from_scale(Vector3(1.0, 1.0, -1.0))
gespiegelt = m * b * m
_:
return rot_deg
# Normalisierung [0, 360) je Achse — gleiche Darstellung wie rot_y_deg
# bisher (tests/test_object_mirror.gd erwartet z. B. 330 statt −30).
return Vector3(
fposmod(gespiegelt.get_euler().x * (180.0 / PI), 360.0),
fposmod(gespiegelt.get_euler().y * (180.0 / PI), 360.0),
fposmod(gespiegelt.get_euler().z * (180.0 / PI), 360.0))

View file

@ -1 +0,0 @@
uid://e08hmo3p6ra3

View file

@ -283,6 +283,14 @@ static func pull(git_exe: String, project_dir: String) -> Dictionary:
var restored := _restore_stash(git_exe, project_dir) var restored := _restore_stash(git_exe, project_dir)
if not bool(restored["ok"]): if not bool(restored["ok"]):
info += "\n" + STASH_KEPT_TEXT info += "\n" + STASH_KEPT_TEXT
# Der Map-Kern ist ein Submodul: ein Pull allein holt ihn nicht nach.
var sub := git_run(git_exe, project_dir, PackedStringArray(["submodule", "update", "--init", "--recursive"]))
if not bool(sub["ok"]):
errors.append("git submodule update fehlgeschlagen (Exit-Code %s):" % str(sub["code"]))
errors.append(str(sub["output"]).strip_edges())
if looks_like_auth_error(str(sub["output"])):
errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).")
return {"ok": false, "output": str(sub["output"]), "errors": errors, "info": info}
return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors, "info": info} return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors, "info": info}

View file

@ -1,111 +0,0 @@
extends RefCounted
class_name GroupState
## Gruppen-ZUSTAND über das Objekt-Feld "group" hinaus (Karte t_a4bf9b8c, U3 —
## Gruppen im Szenen-Baum). GroupOps (src/editor/group_ops.gd) besitzt die
## Zugehörigkeit (Optionales Objekt-Feld "group", int ≥ 0) — diese Datei ergänzt
## NUR ADDITIV, was der Baum additionally braucht:
##
## 1. GRUPPENNAMEN: doc["group_names"] = { "<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

View file

@ -1 +0,0 @@
uid://b13m4gw07qdsf

View file

@ -1,294 +0,0 @@
extends RefCounted
class_name KolliderBau
## Kollider-Bau 1:1 aus dem Quest-3-Client gespiegelt (Karte t_1da96275 —
## „Editor-Playtest-Physik = Quest 3 identisch").
##
## Dieser Modul ist der SPIEGEL von client/scripts/world/svmap_world.gd
## (_collider/_primitiv_form/_trimesh_aus_datei/_half_height + Terrain-
## Trimesh). Die Playtest-Physik im Editor benutzt EXAKT dieselben Formen
## wie der Quest-Client: box/kugel/zylinder/verbund, Trimesh (inline oder
## aus library/kollisions/<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 []

View file

@ -1 +0,0 @@
uid://cncg002d4e5x7

View file

@ -1,201 +0,0 @@
class_name LoadoutKatalog
extends RefCounted
## Map-Loadout (Karte „Loadout pro Map"): Waffen-/Aufsatz-Katalog des Editors
## und die Regeln für den optionalen `loadout`-Block der .svmap (V18, 1:1 wie
## MapLoadout im Match-Server, docs/map_format.md).
##
## doc["loadout"] fehlt oder mode "player" = Spieler-Loadout aus der Lobby.
## mode "fixed" = {primary, secondary ("" = keine), attachments:{waffe: "slot=teil;…"}}
## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client).
## In beiden Modi optional: grenades:{id: anzahl} und mines:anzahl = Vorrat pro
## Leben für Spieler und Bots; fehlen sie, gilt der Standard des Servers.
## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py).
const PATH := "res://library/loadout_katalog.json"
const MODE_FIXED := "fixed"
const MODE_PLAYER := "player"
## Deutsche Namen der Visiere mit eigener Funktion; alles andere zeigt die Teil-ID.
const TEIL_NAMEN := {
"sight_collimator": "Rotpunktvisier",
"sight_holographic": "Holo-Visier (Ring mit Punkt)",
"sight_offset": "Schrägvisier (seitlich, Punkt)",
}
## Granatenarten des Servers (library/grenades.json); die ersten drei stellt das Panel zur Wahl.
const GRANATEN := {
"he1": "Splittergranate",
"blend1": "Blendgranate",
"smoke1": "Rauchgranate",
"he2": "Splittergranate leicht",
"blend2": "Blendgranate kurz",
"smoke2": "Nebelgranate",
"bomb": "Bombe",
}
const GRANATEN_PANEL: Array[String] = ["he1", "blend1", "smoke1"]
const MAX_GRANATEN_JE_ART := 5
const MAX_MINEN := 3 # Spiegel zu MineSystem.MAX_ACTIVE_PER_OWNER
const STANDARD_MINEN := 2 # Spiegel zu MineSystem.START_STOCK
static var _data: Dictionary = {}
static var _loaded := false
static func _ensure() -> void:
if _loaded:
return
_loaded = true
var f := FileAccess.open(PATH, FileAccess.READ)
if f == null:
push_warning("LoadoutKatalog: %s fehlt — Loadout-Auswahl bleibt leer" % PATH)
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
if parsed is Dictionary:
_data = (parsed as Dictionary).get("waffen", {})
## Alle Waffen-IDs, nach Anzeigename sortiert.
static func waffen_ids() -> Array[String]:
_ensure()
var ids: Array[String] = []
for k in _data.keys():
ids.append(str(k))
ids.sort_custom(func(a: String, b: String) -> bool:
return anzeigename(a).naturalnocasecmp_to(anzeigename(b)) < 0)
return ids
static func hat_waffe(id: String) -> bool:
_ensure()
return _data.has(id)
static func anzeigename(id: String) -> String:
_ensure()
return str((_data.get(id, {}) as Dictionary).get("name", id))
static func teil_name(part: String) -> String:
return "— nichts —" if part.is_empty() else str(TEIL_NAMEN.get(part, part))
## Aufsatz-Slots einer Waffe: {slot: {default, optional, options}} (leer = keine).
static func slots(id: String) -> Dictionary:
_ensure()
return (_data.get(id, {}) as Dictionary).get("slots", {})
static func decode(text: String) -> Dictionary:
var out := {}
for chunk in text.split(";", false):
var kv := chunk.split("=", true, 1)
if kv.size() == 2 and not kv[0].is_empty():
out[kv[0]] = kv[1]
return out
## Auswahl {slot: teil} → Draht-Text, nur Abweichungen vom Standard, Slots sortiert.
static func encode(id: String, sel: Dictionary) -> String:
var sl := slots(id)
var keys: Array = sel.keys()
keys.sort()
var parts: PackedStringArray = []
for s in keys:
if sl.has(s) and str(sel[s]) != str((sl[s] as Dictionary).get("default", "")):
parts.append("%s=%s" % [s, sel[s]])
return ";".join(parts)
## Aktuelle Teil-Wahl je Slot (Standard, überlagert vom gespeicherten Text).
static func auswahl(id: String, wire: String) -> Dictionary:
var out := {}
var sl := slots(id)
var dec := decode(wire)
for s in sl.keys():
out[s] = str(dec.get(s, (sl[s] as Dictionary).get("default", "")))
return out
## Grund, warum der Aufsatz-Text für die Waffe ungültig ist ("" = ok).
static func pruefe_aufsaetze(id: String, wire: String) -> String:
if wire.is_empty():
return ""
var sl := slots(id)
if sl.is_empty():
return "Waffe '%s' hat keine Aufsätze" % id
var dec := decode(wire)
if dec.is_empty():
return "Aufsatz-Text unlesbar"
for s in dec.keys():
if not sl.has(s):
return "Slot '%s' gibt es an '%s' nicht" % [s, id]
var p := str(dec[s])
var sd: Dictionary = sl[s]
if p.is_empty():
if not bool(sd.get("optional", false)):
return "Slot '%s' darf nicht leer sein" % s
elif not (sd.get("options", []) as Array).has(p):
return "Teil '%s' passt nicht an Slot '%s' von '%s'" % [p, s, id]
return ""
## V18-Fehlerliste (leer = gültig oder nicht vorhanden).
static func validate(lo: Variant) -> PackedStringArray:
var errors := PackedStringArray()
if lo == null:
return errors
if not (lo is Dictionary):
errors.append("V18: loadout muss ein Objekt sein.")
return errors
var d: Dictionary = lo
var mode := str(d.get("mode", MODE_PLAYER))
if mode != MODE_FIXED and mode != MODE_PLAYER:
errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode)
return errors
errors.append_array(_pruefe_vorrat(d))
if mode == MODE_PLAYER:
return errors
var prim := str(d.get("primary", "")).strip_edges().to_lower()
var sec := str(d.get("secondary", "")).strip_edges().to_lower()
if prim.is_empty():
errors.append("V18: loadout.primary fehlt (Modus 'fixed').")
elif not hat_waffe(prim):
errors.append("V18: loadout.primary '%s' ist keine bekannte Waffe." % prim)
if not sec.is_empty() and not hat_waffe(sec):
errors.append("V18: loadout.secondary '%s' ist keine bekannte Waffe." % sec)
var att: Variant = d.get("attachments", {})
if not (att is Dictionary):
errors.append("V18: loadout.attachments muss ein Objekt sein.")
return errors
for wid in (att as Dictionary).keys():
var w := str(wid).strip_edges().to_lower()
if w != prim and w != sec:
errors.append("V18: loadout.attachments['%s'] gehört zu keiner Loadout-Waffe." % w)
continue
var why := pruefe_aufsaetze(w, str(att[wid]))
if why != "":
errors.append("V18: loadout.attachments['%s']: %s." % [w, why])
return errors
static func _pruefe_vorrat(d: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
if d.has("grenades"):
var g: Variant = d["grenades"]
if not (g is Dictionary):
errors.append("V18: loadout.grenades muss ein Objekt sein.")
else:
for gid in (g as Dictionary).keys():
if not GRANATEN.has(str(gid)):
errors.append("V18: loadout.grenades['%s'] ist keine bekannte Granate." % str(gid))
elif not _ist_anzahl(g[gid], MAX_GRANATEN_JE_ART):
errors.append("V18: loadout.grenades['%s'] muss eine ganze Zahl von 0 bis %d sein." % [str(gid), MAX_GRANATEN_JE_ART])
if d.has("mines") and not _ist_anzahl(d["mines"], MAX_MINEN):
errors.append("V18: loadout.mines muss eine ganze Zahl von 0 bis %d sein." % MAX_MINEN)
return errors
static func _ist_anzahl(v: Variant, max_n: int) -> bool:
if not (v is int or v is float):
return false
return float(v) == floorf(float(v)) and float(v) >= 0.0 and float(v) <= float(max_n)

View file

@ -1 +0,0 @@
uid://jm1cubdr25s8

View file

@ -1,470 +0,0 @@
extends RefCounted
class_name SvmapDocument
## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format (v2,
## docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat →
## kein Konverter, Speichern/Laden direkt auf .svmap (README Architektur).
##
## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd.
const DEFAULT_RESOLUTION := 129 # Editor-MVP-Perf-Limit (Karte t_9aba04f1);
# das Format erlaubt 17–513, der Editor formt im MVP nur bis 129 —
# neue Maps erfüllen das Limit dadurch automatisch.
const DEFAULT_MIN_HEIGHT := 0.0
const DEFAULT_MAX_HEIGHT := 50.0
const DEFAULT_TARGET_GAME_VERSION := "0.1.0"
const DEFAULT_AUTHOR := "ScopedVR Creator"
## Neue, leere Map erzeugen. Bedingung: Sie muss SOFORT speicherbar sein
## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte
## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen-
## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern.
static func create_new(map_name: String, size_m: float, weather: String, game_mode := "tdm") -> Dictionary:
var size := maxf(MIN_SIZE_M, minf(size_m, MAX_SIZE_M))
var res := DEFAULT_RESOLUTION
var zero_heights := PackedByteArray()
zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height
# Feines Material-Raster (Karte 24.10.): ~1 m pro Punkt statt Höhenraster.
var mres := SvmapIO.material_resolution_for(res, size, size)
var zero_materials := PackedByteArray()
zero_materials.resize(mres * mres) # alles 0 → komplette Map Gras
var half := size / 2.0
var data: Dictionary = {
"format_version": SvmapIO.FORMAT_VERSION,
"info": {
"name": map_name,
"author": DEFAULT_AUTHOR,
"description": "",
"version": 1,
"created_utc": Time.get_datetime_string_from_system(true) + "Z",
"target_game_version": DEFAULT_TARGET_GAME_VERSION,
},
"terrain": {
"type": "heightmap",
"size_x_m": size,
"size_z_m": size,
"min_height_m": DEFAULT_MIN_HEIGHT,
"max_height_m": DEFAULT_MAX_HEIGHT,
"resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights),
# v2 (t_603ec1a8): Material-Schicht — neue Maps starten komplett Gras.
"material_resolution": mres,
"materials_r8_b64": Marshalls.raw_to_base64(zero_materials),
},
"objects": [],
"spawn_zones": _default_spawn_zones(size),
"bounds": {
"min": [-half, -10.0, -half],
"max": [half, 80.0, half],
},
"weather": weather,
}
# Spielmodus (28.10.): nur ein abweichender Modus wird geschrieben; Conquest-
# und KotH-Maps starten mit drei Zonen/Hills zwischen den Basen.
game_mode = ModusKatalog.normalize(game_mode)
if game_mode != ModusKatalog.MODE_TDM and ModusKatalog.MODES.has(game_mode):
data["game_mode"] = game_mode
if ModusKatalog.has_zones(game_mode):
data[ModusKatalog.ZONE_KEYS[game_mode]] = ModusKatalog.default_zones(data, game_mode)
return data
const MIN_SIZE_M := 256.0
const MAX_SIZE_M := 4096.0
## Default-Spawns: 4 Punkte je Team als Streck vor den gegenüberliegenden
## Kanten (Team Rot = Osten, Team Blau = Westen — Anzeige-Sache, MVP-Skelett).
static func _default_spawn_zones(size: float) -> Dictionary:
var half := size / 2.0
var x_blue := -half * 0.8
var x_red := half * 0.8
var zs := [-0.3, -0.1, 0.1, 0.3]
var team0_points: Array = []
var team1_points: Array = []
for i in zs.size():
var z: float = zs[i] * size
team0_points.append({"id": "A%d" % (i + 1), "pos": [x_blue, 0.2, z]})
team1_points.append({"id": "B%d" % (i + 1), "pos": [x_red, 0.2, z]})
return {
"0": {"label": "Team Blau", "points": team0_points},
"1": {"label": "Team Rot", "points": team1_points},
}
## Objekt-Anzahl (für Statusleiste/Budget-Anzeige).
static func object_count(data: Dictionary) -> int:
return (data.get("objects", []) as Array).size()
## Verbrauchtes Poly-Budget (Σ poly × scale² — wie V8b im Loader).
static func poly_usage(data: Dictionary, manifest: Dictionary) -> float:
var lib: Dictionary = manifest.get("objects", {})
var total := 0.0
for obj in (data.get("objects", []) as Array):
if obj is Dictionary and lib.has(str(obj.get("id", ""))):
var poly := float(lib[str(obj["id"])].get("poly", 0))
var scale := float(obj.get("scale", 1.0))
total += poly * scale * scale
return total
## Höhe des Terrains an Welt-X/Z (Meter). Flache neue Maps → min_height.
## Heightmap R16/LE/Base64 (map_format.md): res×res Punkte über size_x/size_z,
## bilinear interpoliert. Objekt-Platzierung und spätere Terrain-Werkzeuge
## fragen hier nach, statt selbst über Bytes zu stolpern.
static func terrain_height_at(data: Dictionary, x: float, z: float) -> float:
var terrain: Dictionary = data.get("terrain", {})
var res := int(terrain.get("resolution", 0))
var size_x := float(terrain.get("size_x_m", 0.0))
var size_z := float(terrain.get("size_z_m", 0.0))
var hmin := float(terrain.get("min_height_m", 0.0))
var hmax := float(terrain.get("max_height_m", 0.0))
if res < 2 or size_x <= 0.0 or size_z <= 0.0 or hmax <= hmin:
return hmin
var bytes := Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", "")))
if bytes.size() < res * res * 2:
return hmin
var last := float(res - 1)
var fx := clampf((x / size_x + 0.5) * last, 0.0, last)
var fz := clampf((z / size_z + 0.5) * last, 0.0, last)
var xi := int(floor(fx))
var zi := int(floor(fz))
var tx := fx - float(xi)
var tz := fz - float(zi)
var xi1 := mini(xi + 1, res - 1)
var zi1 := mini(zi + 1, res - 1)
var h00 := _height_sample(bytes, res, xi, zi, hmin, hmax)
var h10 := _height_sample(bytes, res, xi1, zi, hmin, hmax)
var h01 := _height_sample(bytes, res, xi, zi1, hmin, hmax)
var h11 := _height_sample(bytes, res, xi1, zi1, hmin, hmax)
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
static func _height_sample(bytes: PackedByteArray, res: int, xi: int, zi: int,
hmin: float, hmax: float) -> float:
var i := (zi * res + xi) * 2
var raw := float(bytes[i]) + float(bytes[i + 1]) * 256.0
return hmin + (raw / 65535.0) * (hmax - hmin)
# ----------------------------------------------------- Kartengröße ändern ----
# Karte t_0d436687 / PLAN.md „Map-Größe ändern" (17.10., Sly-geankert).
# Semantik „Anker Mitte, Rand flach, Resample": die Karte bleibt am Ursprung
# zentriert, bestehendes Terrain behält seine WELTPOSITION (bilinear
# resampeln — kein Stretch), die neue Randzone wird flach (min_height) +
# Material Gras. Reine Funktion (altes Dict → NEUES Dict), damit R3 sie
# atomar hinter ein Undo-Snapshot hängen kann.
## Dichte-Ziel: nach einem Resize nicht grober als ~2 m/Punkt sampeln.
const RESIZE_DENSITY_TARGET_M := 2.0
## Auflösungs-Leiter beim Hochdrehen (Format erlaubt 17–513, V6).
const RESIZE_RESOLUTION_LADDER: Array[int] = [129, 257, 513]
## Auflösung nach einem Resize: kleinstmögliche Leiter-Sprosse ≥ alter res,
## die die ~2 m/Punkt-Dichte auf der GRÖSSTEN neuen Kante hält; über 513
## (Format-Maximum) nicht mehr hochdrehen → ab ~1026 m grobt es (Warnband,
## Anzeige kommt in R3).
static func _resize_resolution(old_res: int, new_sx: float, new_sz: float) -> int:
var res := old_res
for step in RESIZE_RESOLUTION_LADDER:
if step < res:
continue
var m_per_point := maxf(new_sx, new_sz) / float(step - 1)
if m_per_point <= RESIZE_DENSITY_TARGET_M:
return step
return SvmapIO.MAX_TERRAIN_RESOLUTION
## m/Punkt einer res/size-Kombination — R3-Dialog zeigt das Warnband ab
## ~1024 m (Dichte grobt, Quest-Perf: Objektzahl/Draw-Calls).
static func resize_m_per_point(res: int, size_m: float) -> float:
return size_m / float(maxi(res - 1, 1))
## Terrain auf neue Kartenmaße resampeln (Vergrößern UND Verkleinern —
## beides geht über dieselbe Anker-Mitte-Resample-Schleife: beim Shrink liegt
## jeder neue Gridpunkt innerhalb der alten Fläche, die Rand-flach-Regel
## greift dann nie). R1/R2-Schnitt (PLAN.md Kette): Spawn-Zonen, objects und
## gebaeude werden unverändert ÜBERNOMMEN — auch wenn etwas außerhalb des
## neuen Geländes liegt (Filter nur mit drop_outside via resize_apply,
## Out-of-Bounds-Dialog-Liste via resize_preview). Kein Mutationsspaß:
## der Eingabe-doc bleibt unangetastet, das Ergebnis ist ein frisches Dict
## ohne Prüfsumme (die alte wäre nach dem Resize ungültig — save_to_file
## signiert beim nächsten Speichern neu).
static func resize_terrain(doc: Dictionary, new_size_x_m: float,
new_size_z_m: float) -> Dictionary:
var old_terrain: Dictionary = doc.get("terrain", {})
var old_res := int(old_terrain.get("resolution", DEFAULT_RESOLUTION))
var old_sx := float(old_terrain.get("size_x_m", 0.0))
var old_sz := float(old_terrain.get("size_z_m", 0.0))
var hmin := float(old_terrain.get("min_height_m", DEFAULT_MIN_HEIGHT))
var hmax := float(old_terrain.get("max_height_m", DEFAULT_MAX_HEIGHT))
var new_sx := clampf(new_size_x_m, MIN_SIZE_M, MAX_SIZE_M)
var new_sz := clampf(new_size_z_m, MIN_SIZE_M, MAX_SIZE_M)
var new_res := _resize_resolution(old_res, new_sx, new_sz)
var heights_old := SvmapIO.decode_heights(old_terrain)
var mats_old := SvmapIO.decode_materials(old_terrain)
var old_mres := SvmapIO.material_resolution(old_terrain)
var mix_old := SvmapIO.decode_mix(old_terrain) # weiche Ränder (Karte 25.10.)
var mb_old: PackedByteArray = mix_old[0]
var mw_old: PackedByteArray = mix_old[1]
var new_mres := SvmapIO.material_resolution_for(new_res, new_sx, new_sz)
var heights_new := PackedFloat32Array()
heights_new.resize(new_res * new_res)
var mats_new := PackedByteArray()
mats_new.resize(new_mres * new_mres)
var mb_new := PackedByteArray()
mb_new.resize(new_mres * new_mres)
var mw_new := PackedByteArray()
mw_new.resize(new_mres * new_mres)
var last_new := float(new_res - 1)
var half_x_old := old_sx * 0.5
var half_z_old := old_sz * 0.5
var eps := 0.0001
for j in new_res:
var z := (float(j) / last_new - 0.5) * new_sz
for i in new_res:
var x := (float(i) / last_new - 0.5) * new_sx
if x < -half_x_old - eps or x > half_x_old + eps \
or z < -half_z_old - eps or z > half_z_old + eps:
# Neue Randzone: flach auf min_height (wie create_new) + Gras.
heights_new[j * new_res + i] = hmin
continue
# Bestehendes Terrain: Weltposition halten, bilinear sampeln.
heights_new[j * new_res + i] = TerrainField.height_at(
heights_old, old_res, old_sx, old_sz, x, z)
# Material-Schicht auf ihrem eigenen (feinen) Raster, gleiche Regel:
# Weltposition halten, nächster alter Punkt; neue Randzone = Gras (0).
var last_mnew := float(new_mres - 1)
var last_mold := float(old_mres - 1)
for j in new_mres:
var z := (float(j) / last_mnew - 0.5) * new_sz
for i in new_mres:
var x := (float(i) / last_mnew - 0.5) * new_sx
if x < -half_x_old - eps or x > half_x_old + eps \
or z < -half_z_old - eps or z > half_z_old + eps:
continue
var fx := clampf(roundf((x / old_sx + 0.5) * last_mold), 0.0, last_mold)
var fz := clampf(roundf((z / old_sz + 0.5) * last_mold), 0.0, last_mold)
var src := int(fz) * old_mres + int(fx)
mats_new[j * new_mres + i] = mats_old[src]
mb_new[j * new_mres + i] = mb_old[src]
mw_new[j * new_mres + i] = mw_old[src]
var terrain := {
"type": "heightmap",
"size_x_m": new_sx,
"size_z_m": new_sz,
"min_height_m": hmin,
"max_height_m": hmax,
"resolution": new_res,
"heightmap_r16_b64": TerrainField.encode(heights_new, hmin, hmax),
"material_resolution": new_mres,
"materials_r8_b64": SvmapIO.encode_materials(mats_new),
}
SvmapIO.encode_mix(terrain, mb_new, mw_new)
# bounds wie create_new auf die neue Größe — Y-Grenzen vom alten doc
# behalten (Resize ändert nur die Grundfläche, nicht die Luft).
var half_x := new_sx / 2.0
var half_z := new_sz / 2.0
var old_bounds: Dictionary = doc.get("bounds", {})
var old_min: Array = old_bounds.get("min", [-half_x, -10.0, -half_z])
var old_max: Array = old_bounds.get("max", [half_x, 80.0, half_z])
var out := {
"format_version": SvmapIO.FORMAT_VERSION,
"info": (doc.get("info", {}) as Dictionary).duplicate(),
"terrain": terrain,
# R1: Objekte/Bauteile/Spawns unverändert übernehmen (Filter in R2).
"objects": (doc.get("objects", []) as Array).duplicate(),
"spawn_zones": (doc.get("spawn_zones", {}) as Dictionary).duplicate(true),
"bounds": {
"min": [-half_x, float(old_min[1]), -half_z],
"max": [half_x, float(old_max[1]), half_z],
},
"weather": doc.get("weather", ""),
}
# Optionale Zusatz-Blöcke (group_names = Gebäude-/Gruppen-Namen, design_notes,
# Himmel …) gehören nicht dem Resize und wandern unverändert mit — nur die
# Prüfsumme wird verworfen.
for key in doc.keys():
if not out.has(key) and key != "checksum":
var v: Variant = doc[key]
out[key] = (v as Array).duplicate(true) if v is Array \
else ((v as Dictionary).duplicate(true) if v is Dictionary else v)
return out
# ------------------------------------------------- R2: Out-of-Bounds-Liste ----
# Karte t_27ba6c37 (PLAN.md „Map-Größe ändern", Punkt 3): beim Verkleinern
# bleiben Objekte/Bauteile/Spawns-Kategorien erhalten, ABER der Dialog listet
# vorher, was komplett außerhalb des neuen Geländes läge. Semantik:
# - Komplett außerhalb → gelistet (resize_apply(drop_outside=true) entfernt).
# - Teils am Rand überlappend → gelten als DRIN, werden unangetastet
# übernommen (kein Clampen-Magie — sie dürfen über die Kante ragen).
# Fußabdruck-Quellen je Kategorie (doc-lokale Fakten, kein Manifest nötig):
# - Objekte: Anker-Punkt pos (svmap speichert keinen Objekt-Fußabdruck; die
# Bibliothek lebt außerhalb der Map-Datei → die Anker-Regel ist die
# dokumentierte Wahrheit, R3 zeigt im Dialog was passiert).
# - Bauteile: das Teil trägt seinen eigenen Fußabdruck ("punkte", lokal,
# mit "rot" gedreht) — EIN Eckpunkt innerhalb der neuen Grenzen = drin.
# - Spawn-Punkte: Punkte (der Zonen-Kreis ist Editor-Visualisierung, kein
# Formatfeld — SpawnOps-Kopf) → Punkt-Regel.
# Grenzfall eps wie resize_terrain: exakt auf der Kante zählt als drin.
static func _xz_in_bounds(x: float, z: float, half_x: float, half_z: float,
eps: float) -> bool:
return x >= -half_x - eps and x <= half_x + eps \
and z >= -half_z - eps and z <= half_z + eps
## Out-of-Bounds-Vorschau für den R3-Dialog (keine Mutation, keine Terrain-
## Arbeit). Rückgabe:
## objects_out: Indizes in doc["objects"] (Reihenfolge aufsteigend)
## parts_out: Indizes in doc["gebaeude"]
## spawns_out: Liste {"team": 0|1, "index": int, "id": String} je Punkt
## Die Größen werden wie in resize_terrain geclampt (256–4096 m), damit
## Preview und Apply garantiert dasselbe Gebiet meinen.
static func resize_preview(doc: Dictionary, new_size_x_m: float,
new_size_z_m: float) -> Dictionary:
var half_x := clampf(new_size_x_m, MIN_SIZE_M, MAX_SIZE_M) * 0.5
var half_z := clampf(new_size_z_m, MIN_SIZE_M, MAX_SIZE_M) * 0.5
var eps := 0.0001
var objects_out: Array = []
var objects: Array = doc.get("objects", [])
for i in objects.size():
var obj = objects[i]
if not (obj is Dictionary):
continue
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() < 3:
continue
if not _xz_in_bounds(float(pos[0]), float(pos[2]), half_x, half_z, eps):
objects_out.append(i)
var parts_out: Array = []
var pieces: Array = doc.get("gebaeude", [])
for i in pieces.size():
var teil = pieces[i]
if not (teil is Dictionary):
continue
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() < 3:
continue
var punkte = teil.get("punkte", [])
var inside := false
if not (punkte is Array) or (punkte as Array).is_empty():
# Kein Fußabdruck bekannt → Anker-Punkt entscheidet.
inside = _xz_in_bounds(float(pos[0]), float(pos[2]),
half_x, half_z, eps)
else:
var rot: float = deg_to_rad(float(teil.get("rot", 0.0)))
var c: float = cos(rot)
var s: float = sin(rot)
for p in punkte:
if not (p is Array) or (p as Array).size() < 3:
continue
# Lokaler Fußabdruck-Punkt → Welt (Rotation um +Y, Godot-Basis):
# x' = x·cos + z·sin, z' = −x·sin + z·cos.
var wx: float = float(pos[0]) + float(p[0]) * c + float(p[2]) * s
var wz: float = float(pos[2]) - float(p[0]) * s + float(p[2]) * c
if _xz_in_bounds(wx, wz, half_x, half_z, eps):
inside = true
break
if not inside:
parts_out.append(i)
var spawns_out: Array = []
var zones = doc.get("spawn_zones", {})
if zones is Dictionary:
for team_key in (zones as Dictionary).keys():
var zone = zones[team_key]
if not (zone is Dictionary):
continue
var points = (zone as Dictionary).get("points", [])
if not (points is Array):
continue
for pi in (points as Array).size():
var pt = points[pi]
if not (pt is Dictionary):
continue
var pos: Array = pt.get("pos", [])
if (pos as Array).size() < 3:
continue
if not _xz_in_bounds(float(pos[0]), float(pos[2]),
half_x, half_z, eps):
spawns_out.append({
"team": int(team_key),
"index": pi,
"id": str(pt.get("id", "")),
})
return {
"objects_out": objects_out,
"parts_out": parts_out,
"spawns_out": spawns_out,
}
## Resize anwenden (Terrain resampeln via resize_terrain) und optional die
## Out-of-Bounds-Einträge ENTFERNEN. drop_outside=false → Ergebnis identisch
## zu resize_terrain (reine Preview-Nutzung im Dialog). drop_outside=true →
## die in resize_preview gelisteten Objekte/Bauteile/Spawn-Punkte werden aus
## dem ERGEBNIS entfernt (der Eingabe-doc bleibt auch dann unangetastet —
## Undo-Snapshot-Sicherheit wie in R1).
static func resize_apply(doc: Dictionary, new_size_x_m: float,
new_size_z_m: float, drop_outside: bool) -> Dictionary:
var result := resize_terrain(doc, new_size_x_m, new_size_z_m)
if not drop_outside:
return result
var preview := resize_preview(doc, new_size_x_m, new_size_z_m)
# Absteigend löschen, damit die Indizes der Preview stabil bleiben.
var out_idx: Array = (preview["objects_out"] as Array).duplicate()
out_idx.sort()
out_idx.reverse()
var objects: Array = result.get("objects", [])
for i in out_idx:
if int(i) < objects.size():
objects.remove_at(int(i))
var parts_idx: Array = (preview["parts_out"] as Array).duplicate()
parts_idx.sort()
parts_idx.reverse()
var gebaeude: Array = result.get("gebaeude", [])
for i in parts_idx:
if int(i) < gebaeude.size():
gebaeude.remove_at(int(i))
# Spawn-Punkte je Team rausfiltern (Struktur bleibt: Teams ohne Punkte
# bleiben als Zone-Block erhalten — der Spawn-Editor füllt sie wieder).
var spawns_out: Array = preview["spawns_out"]
var per_team := {0: {}, 1: {}} # team → {punkt-index: true}
for entry in spawns_out:
if entry is Dictionary:
per_team[int(entry.get("team", -1))][int(entry.get("index", -1))] = true
var zones = result.get("spawn_zones", {})
if zones is Dictionary:
for team_key in (zones as Dictionary).keys():
var zone = zones[team_key]
if not (zone is Dictionary):
continue
var points = (zone as Dictionary).get("points", [])
if not (points is Array):
continue
var drop: Dictionary = per_team.get(int(team_key), {})
if drop.is_empty():
continue
var kept: Array = []
for pi in (points as Array).size():
if not drop.has(pi):
kept.append(points[pi])
zone["points"] = kept
return result

View file

@ -1 +0,0 @@
uid://cmyaqvftm41uf

View file

@ -1,264 +0,0 @@
class_name ModusKatalog
extends RefCounted
## Spielmodus, Zonen/Hills und Modus-Spawns der Map (28.10.).
## doc["game_mode"] ("tdm" | "conquest" | "koth" | "dm", fehlt = tdm),
## doc["conquest_zones"] und doc["hills"] [{id, label, pos:[x,y,z], radius_m}],
## doc["mode_spawns"] {modus: {"0"/"1"/"all": {points:[{id,pos}]}}} — Regeln 1:1
## wie SvmapLoader im Match-Server (V21–V24, docs/map_format.md).
## Alt-Namen: game_mode "checkpoints" und Feld "checkpoints" (= Conquest) werden
## beim Laden umbenannt (migrate).
const MODE_TDM := "tdm"
const MODE_CONQUEST := "conquest"
const MODE_KOTH := "koth"
const MODE_DM := "dm"
const MODES: Array[String] = [MODE_TDM, MODE_CONQUEST, MODE_KOTH, MODE_DM]
const MODE_LABELS := {
MODE_TDM: "Team-Deathmatch",
MODE_CONQUEST: "Conquest (mehrere Zonen einnehmen)",
MODE_KOTH: "King of the Hill (ein wandernder Hill)",
MODE_DM: "Deathmatch (alle gegen alle)",
}
const MODE_SHORT := {
MODE_TDM: "Team-Deathmatch",
MODE_CONQUEST: "Conquest",
MODE_KOTH: "King of the Hill",
MODE_DM: "Deathmatch",
}
const MODE_ALIASES := {"checkpoints": MODE_CONQUEST}
## Feld mit den Zonen je Modus (nur Conquest und KotH haben welche).
const ZONE_KEYS := {MODE_CONQUEST: "conquest_zones", MODE_KOTH: "hills"}
const ZONE_NAMES := {MODE_CONQUEST: "Zone", MODE_KOTH: "Hill"}
const ZONE_PREFIX := {MODE_CONQUEST: "C", MODE_KOTH: "H"}
const DEFAULT_RADIUS := 15.0
const MIN_RADIUS := 3.0
const MAX_RADIUS := 60.0
const SPAWN_SET_BASE := "base"
static func normalize(m: String) -> String:
return str(MODE_ALIASES.get(m, m))
static func mode_of(doc: Dictionary) -> String:
var m := normalize(str(doc.get("game_mode", MODE_TDM)))
return m if MODES.has(m) else MODE_TDM
static func has_zones(mode: String) -> bool:
return ZONE_KEYS.has(mode)
## Zonen/Hills des Modus (leer für Modi ohne Zonen).
static func zones(doc: Dictionary, mode: String) -> Array:
if not ZONE_KEYS.has(mode):
return []
var c: Variant = doc.get(ZONE_KEYS[mode], [])
return c if c is Array else []
## Nächste freie ID "C1", "C2", … (Conquest) bzw. "H1", … (Hills).
static func next_id(doc: Dictionary, mode: String) -> String:
var used := {}
for c in zones(doc, mode):
if c is Dictionary:
used[str(c.get("id", ""))] = true
var prefix: String = ZONE_PREFIX.get(mode, "Z")
var i := 1
while used.has("%s%d" % [prefix, i]):
i += 1
return "%s%d" % [prefix, i]
## Neue Zone auf dem Boden (y = Terrain-Höhe).
static func make_zone(doc: Dictionary, mode: String, x: float, z: float, label := "") -> Dictionary:
var id := next_id(doc, mode)
return {
"id": id,
"label": label if label != "" else "%s %s" % [ZONE_NAMES.get(mode, "Zone"), id.substr(1)],
"pos": [x, SvmapDocument.terrain_height_at(doc, x, z), z],
"radius_m": DEFAULT_RADIUS,
}
## Drei Start-Zonen zwischen den Basen (West–Ost-Linie) für neue Maps des Modus.
static func default_zones(doc: Dictionary, mode: String) -> Array:
var size := float(doc.get("terrain", {}).get("size_x_m", 1024.0))
var out: Array = []
var names := ["Westposten", "Mitte", "Ostposten"] if mode == MODE_CONQUEST else ["Westhang", "Talmitte", "Osthang"]
var xs := [-0.25, 0.0, 0.25]
for i in 3:
var probe := {ZONE_KEYS[mode]: out, "terrain": doc.get("terrain", {})}
out.append(make_zone(probe, mode, xs[i] * size, 0.0, names[i]))
return out
## Alte Namen auf die heutigen umstellen (nach erfolgreichem Laden).
static func migrate(doc: Dictionary) -> void:
if str(doc.get("game_mode", "")) == "checkpoints":
doc["game_mode"] = MODE_CONQUEST
if doc.has("checkpoints"):
if not doc.has("conquest_zones"):
doc["conquest_zones"] = doc["checkpoints"]
doc.erase("checkpoints")
# ----------------------------------------------------------- Modus-Spawns --
## Team-Schlüssel eines Spawn-Satzes: Deathmatch hat nur "all".
static func spawn_keys(mode: String) -> Array:
return ["all"] if mode == MODE_DM else ["0", "1"]
static func spawn_set(doc: Dictionary, mode: String) -> Dictionary:
var ms: Variant = doc.get("mode_spawns", {})
if ms is Dictionary and (ms as Dictionary).get(mode, null) is Dictionary:
return (ms as Dictionary)[mode]
return {}
static func has_own_spawns(doc: Dictionary, mode: String) -> bool:
return not spawn_set(doc, mode).is_empty()
## Eigene Spawns für den Modus anlegen: Kopie der Basis-Spawns (Deathmatch: beide
## Teams in einem Topf). IDs bekommen den Modus-Präfix, damit sie map-weit eindeutig sind.
static func create_spawn_set(doc: Dictionary, mode: String) -> void:
var ms: Dictionary = doc.get("mode_spawns", {}) if doc.get("mode_spawns", {}) is Dictionary else {}
var base: Dictionary = doc.get("spawn_zones", {})
var out := {}
var tag := mode.to_upper().substr(0, 1) if mode != MODE_DM else "D"
var n := 0
for key in spawn_keys(mode):
var pts: Array = []
var src_teams: Array = ["0", "1"] if mode == MODE_DM else [key]
for t in src_teams:
var z: Variant = base.get(t, {})
if not (z is Dictionary):
continue
for p in (z as Dictionary).get("points", []):
if p is Dictionary:
n += 1
pts.append({"id": "%s%s%d" % [tag, "" if mode == MODE_DM else ("A" if t == "0" else "B"), n],
"pos": (p.get("pos", [0, 0, 0]) as Array).duplicate()})
out[key] = {"points": pts}
ms[mode] = out
doc["mode_spawns"] = ms
static func remove_spawn_set(doc: Dictionary, mode: String) -> void:
var ms: Variant = doc.get("mode_spawns", null)
if not (ms is Dictionary):
return
(ms as Dictionary).erase(mode)
if (ms as Dictionary).is_empty():
doc.erase("mode_spawns")
# ------------------------------------------------------------- Validierung --
static func validate(data: Dictionary, bounds: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
var raw = data.get("game_mode", "")
var mode := normalize(str(raw)) if raw != null else ""
if mode != "" and not MODES.has(mode):
errors.append("V21: game_mode '%s' unbekannt (%s)." % [mode, ", ".join(MODES)])
var cz = data.get("conquest_zones", data.get("checkpoints", []))
errors.append_array(_validate_zone_list(cz, "V22", "Conquest-Zone", bounds))
if mode == MODE_CONQUEST and cz is Array and (cz as Array).is_empty():
errors.append("V21: game_mode 'conquest' braucht mindestens eine Zone.")
var hl = data.get("hills", [])
errors.append_array(_validate_zone_list(hl, "V23", "Hill", bounds))
if mode == MODE_KOTH and hl is Array and (hl as Array).is_empty():
errors.append("V21: game_mode 'koth' braucht mindestens einen Hill.")
errors.append_array(_validate_mode_spawns(data.get("mode_spawns", null), bounds))
return errors
static func _validate_zone_list(cps, code: String, what: String, bounds: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
if not (cps is Array):
errors.append("%s: %s-Liste muss ein Array sein." % [code, what])
return errors
var seen := {}
for i in (cps as Array).size():
var c = cps[i]
if not (c is Dictionary):
errors.append("%s: %s %d ist kein Objekt." % [code, what, i])
continue
var cid = c.get("id", "")
if not (cid is String) or (cid as String).strip_edges().is_empty():
errors.append("%s: %s %d: id fehlt/leer." % [code, what, i])
elif seen.has(cid):
errors.append("%s: %s-ID '%s' ist doppelt." % [code, what, cid])
else:
seen[cid] = true
var pos = c.get("pos", null)
if not (pos is Array) or (pos as Array).size() != 3 \
or not _num(pos[0]) or not _num(pos[1]) or not _num(pos[2]):
errors.append("%s: %s '%s': pos muss [x, y, z] mit endlichen Werten sein." % [code, what, str(cid)])
elif not _inside(pos, bounds):
errors.append("%s: %s '%s' liegt außerhalb der Match-Grenzen." % [code, what, str(cid)])
var r = c.get("radius_m", DEFAULT_RADIUS)
if not _num(r) or float(r) < MIN_RADIUS or float(r) > MAX_RADIUS:
errors.append("%s: %s '%s': radius_m muss 3–60 sein." % [code, what, str(cid)])
if c.has("yaw_deg") and not _num(c["yaw_deg"]):
errors.append("%s: %s '%s': yaw_deg muss endlich sein." % [code, what, str(cid)])
return errors
static func _validate_mode_spawns(ms, bounds: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
if ms == null:
return errors
if not (ms is Dictionary):
return PackedStringArray(["V24: mode_spawns muss ein Objekt sein."])
for mode in (ms as Dictionary).keys():
if not MODES.has(str(mode)):
errors.append("V24: mode_spawns: Modus '%s' unbekannt." % str(mode))
continue
var m = ms[mode]
if not (m is Dictionary):
errors.append("V24: mode_spawns.%s muss ein Objekt sein." % str(mode))
continue
var seen := {}
for key in (m as Dictionary).keys():
var allowed := spawn_keys(str(mode))
if not allowed.has(str(key)):
errors.append("V24: mode_spawns.%s: Schlüssel '%s' nicht erlaubt (%s)." % [str(mode), str(key), ", ".join(allowed)])
continue
var pts = m[key].get("points", []) if m[key] is Dictionary else null
if not (pts is Array) or (pts as Array).is_empty():
errors.append("V24: mode_spawns.%s.%s braucht mindestens einen Punkt." % [str(mode), str(key)])
continue
for i in (pts as Array).size():
var p = pts[i]
var pid = p.get("id", "") if p is Dictionary else ""
if not (pid is String) or (pid as String).strip_edges().is_empty():
errors.append("V24: mode_spawns.%s.%s Punkt %d: id fehlt/leer." % [str(mode), str(key), i])
continue
if seen.has(pid):
errors.append("V24: Spawn-ID '%s' ist doppelt (Modus %s)." % [pid, str(mode)])
seen[pid] = true
var pos = p.get("pos", null)
if not (pos is Array) or (pos as Array).size() != 3 \
or not _num(pos[0]) or not _num(pos[1]) or not _num(pos[2]):
errors.append("V24: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % pid)
elif not _inside(pos, bounds):
errors.append("V24: Spawn '%s' liegt außerhalb der Match-Grenzen." % pid)
if p.has("yaw_deg") and not _num(p["yaw_deg"]):
errors.append("V24: Spawn '%s': yaw_deg muss endlich sein." % pid)
return errors
static func _num(v: Variant) -> bool:
return (v is int or v is float) and is_finite(float(v))
static func _inside(pos: Array, bounds: Dictionary) -> bool:
var mn: Array = bounds.get("min", [0, 0, 0])
var mx: Array = bounds.get("max", [0, 0, 0])
for i in 3:
if float(pos[i]) < float(mn[i]) - 1.0 or float(pos[i]) > float(mx[i]) + 1.0:
return false
return true

View file

@ -1 +0,0 @@
uid://cprm4ke4dpj58

View file

@ -1,125 +0,0 @@
class_name SkyboxKatalog
## Skybox-Katalog (Karte Skybox-Auswahl): welche Himmels-Panoramas eine Map
## über das optionale svmap-Feld `weather_sky` wählen kann. 1:1-Spiegel von
## client/scripts/world/skybox_katalog.gd — beide Seiten pflegen dieselben IDs. Der Match-Server kennt nur das ID-Muster (V17), keine Texturen.
##
## IDs: "" = Preset entscheidet (alle Presets: ProceduralSky), "prozedural" =
## ProceduralSky erzwingen, "sky_panorama" = Pack-Wüste (Skybox_3, Altbestand),
## "sky_NN" = Panorama aus assets/textures/skybox/ (2048×1024, VRAM-komprimiert,
## ~1,4 MB je Textur; es wird immer nur EINE geladen). Unbekannte ID → Fallback
## ProceduralSky (alte Clients/neuere Maps brechen nie).
##
## `fog`: Farbe am Horizont des Panoramas (aerial perspective), gemessen am
## Texturband direkt über dem Horizont.
const ID_PACK_WUESTE := "sky_panorama"
const ID_PROZEDURAL := "prozedural"
## Relativ zum Client-Klon (Nachbar-Ordner, keine Duplikate im Editor).
const TEXTUR_DIR_REL := "assets/textures/skybox/"
const PACK_WUESTE_REL := "assets/textures/military_desert/Skybox_3.png"
## Reihenfolge = Reihenfolge im Editor-Auswahlfeld.
const EINTRAEGE := [
{"id": "sky_panorama", "name": "Wüste (Pack)", "fog": Color(0.93, 0.80, 0.66)},
{"id": "sky_03", "name": "Goldene Wolken (Sonnenuntergang)", "fog": Color(0.57, 0.63, 0.47)},
{"id": "sky_06", "name": "Glutroter Abend", "fog": Color(0.66, 0.40, 0.45)},
{"id": "sky_12", "name": "Roter Horizont (Abendrot)", "fog": Color(0.87, 0.44, 0.40)},
{"id": "sky_14", "name": "Wolkenfront (Tag)", "fog": Color(0.48, 0.58, 0.71)},
{"id": "sky_17", "name": "Violette Nacht (Milchstraße)", "fog": Color(0.58, 0.45, 0.65)},
{"id": "sky_82", "name": "Dämmerung mit Milchstraße", "fog": Color(0.54, 0.48, 0.53)},
{"id": "sky_84", "name": "Sternenhimmel mit Wolkenbändern", "fog": Color(0.58, 0.66, 0.65)},
{"id": "sky_90", "name": "Nachtwolken", "fog": Color(0.22, 0.36, 0.53)},
{"id": "sky_94", "name": "Sonne hinter Wolken", "fog": Color(0.47, 0.51, 0.61)},
{"id": "sky_98", "name": "Sternschnuppen (Nacht)", "fog": Color(0.33, 0.54, 0.71)},
{"id": "sky_103", "name": "Sonnenaufgang (Strahlen)", "fog": Color(0.50, 0.69, 0.75)},
{"id": "sky_107", "name": "Goldene Stunde", "fog": Color(0.79, 0.54, 0.33)},
{"id": "sky_115", "name": "Abendglühen (Wolkentürme)", "fog": Color(0.59, 0.44, 0.43)},
]
static func eintrag(id: String) -> Dictionary:
for e in EINTRAEGE:
if e["id"] == id:
return e
return {}
static func ist_bekannt(id: String) -> bool:
return id == ID_PROZEDURAL or not eintrag(id).is_empty()
## V17: gültig = leer/fehlend, "prozedural" oder bekannte ID.
static func ist_gueltig(id: Variant) -> bool:
if id == null:
return true
if not (id is String):
return false
return id == "" or ist_bekannt(id)
## Pfad relativ zum Client-Klon ("" = ProceduralSky).
static func textur_rel(id: String) -> String:
if id == ID_PACK_WUESTE:
return PACK_WUESTE_REL
if eintrag(id).is_empty():
return ""
return TEXTUR_DIR_REL + id + ".png"
## Absoluter Pfad im Nachbar-Klon ("" = ID unbekannt oder kein Client-Klon).
static func textur_pfad(id: String) -> String:
var rel := textur_rel(id)
if rel.is_empty():
return ""
var root := TestRun.find_client(ProjectSettings.globalize_path("res://"),
TestRun.load_cfg())
return "" if root.is_empty() else root.path_join(rel)
static func fog_farbe(id: String, fallback: Color, energie: float = 1.0) -> Color:
if id == ID_PACK_WUESTE:
return fallback
var e := eintrag(id)
return (e["fog"] * minf(energie, 1.0)) if not e.is_empty() else fallback
## Geladene Panorama-Texturen (volle Größe, mit Mipmaps) — ein Eintrag je ID,
## damit jeder Rebuild nicht erneut 2k-PNGs dekodiert. null = nicht ladbar.
static var _tex_cache: Dictionary = {}
static var _thumb_cache: Dictionary = {}
static func panorama_textur(id: String) -> Texture2D:
if _tex_cache.has(id):
return _tex_cache[id]
var tex: Texture2D = null
var pfad := textur_pfad(id)
if not pfad.is_empty() and FileAccess.file_exists(pfad):
var img := Image.load_from_file(pfad)
if img != null:
if img.is_compressed():
img.decompress()
img.generate_mipmaps()
tex = ImageTexture.create_from_image(img)
if tex != null:
_tex_cache[id] = tex # Fehlschläge NICHT cachen (Klon kann später da sein)
return tex
## Vorschau-Bild für das Auswahlfeld: obere Panorama-Hälfte (Himmel), klein.
static func vorschau(id: String) -> Texture2D:
if _thumb_cache.has(id):
return _thumb_cache[id]
var pfad := textur_pfad(id)
if pfad.is_empty() or not FileAccess.file_exists(pfad):
return null
var img := Image.load_from_file(pfad)
if img == null:
return null
if img.is_compressed():
img.decompress()
var halb := img.get_region(Rect2i(0, 0, img.get_width(), img.get_height() / 2))
halb.resize(320, 80, Image.INTERPOLATE_LANCZOS)
var tex := ImageTexture.create_from_image(halb)
_thumb_cache[id] = tex
return tex

View file

@ -1 +0,0 @@
uid://cydcauar68si5

View file

@ -1,916 +0,0 @@
extends RefCounted
class_name SvmapIO
## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v2).
##
## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f
## + v2-Erweiterung t_603ec1a8: Material-Schicht am Höhenfeld).
## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku
## ist der Vertrag. Validierungs-IDs V1..V15 und Kanonisierung/Prüfsumme sind
## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom
## Match-Server akzeptiert wird (und umgekehrt).
##
## Format v2: terrain.materials_r8_b64 (OPTIONAL, Base64 uint8 je Sample,
## 0=Gras/1=Sand/2=Fels). Fehlt es (Legacy-v1), gilt alles Gras. Geladen
## werden v1 UND v2; gespeichert wird immer v2 (mit Schicht).
##
## API (alles static, testbar ohne UI):
## parse_and_validate(text, manifest = {}) -> {ok, errors, data}
## load_from_file(path) -> {ok, errors, data}
## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben)
## validate_map(data, manifest) -> PackedStringArray
## sign_map(data) -> void (setzt/erneuert Prüfsumme)
## decode_materials(terrain) / encode_materials(mats)
##
const FORMAT_VERSION := 2
## Akzeptierte Format-Versionen (V3): 1 = Legacy ohne Material-Schicht,
## 2 = aktuell. Neu gespeicherte Maps tragen immer FORMAT_VERSION.
const SUPPORTED_FORMAT_VERSIONS: Array[int] = [1, 2]
## Material-IDs (Index = ID). 0 = Gras = Default für Maps ohne Schicht.
## Karte t_ab408127 (Sly-Vielfalt): Palette von 3 auf 20 erweitert — NEUE IDs
## NUR ANGEHÄNGT, Legacy-Zuordnung (0=Gras, 1=Sand, 2=Fels) bleibt unverändert,
## damit alte Maps byte-identisch laden. Format v2 bleibt (Feldgröße = res²,
## Werte = uint8 je Sample — der Bereich wächst nur implizit mit der Länge).
const MATERIAL_IDS: Array[String] = [
"gras_steppe", # 0 = Legacy-Gras (Default/Standard-Boden)
"sand_rippen", # 1 = Legacy-Sand (Dünen/offene Flächen)
"fels_granit", # 2 = Legacy-Fels (Berge/Klippen)
"gras_satt", # 3 = gesundes, sattes Grün
"gras_vertrocknet", # 4 = ausgedörrt, gelblich
"gras_moosig", # 5 = weiches Moosgrün
"erde_braun", # 6 = feuchte braune Erde
"erde_getreten", # 7 = festgetretener Pfad
"erde_umgeackert", # 8 = umgegrabene/zerwühlte Erde
"schotter", # 9 = Kies/Schotter
"lehm_rot", # 10 = rotbrauner Lehm
"sand_hell", # 11 = heller Flugsand
"sand_rost", # 12 = rostroter Sand
"fels_basalt", # 13 = dunkler Basalt
"fels_geroell", # 14 = Geröllhalde
"schlamm", # 15 = Matsch/Schlamm
"schnee_frisch", # 16 = Neuschnee
"schnee_matschig", # 17 = angeschmolzen/angesaut
"asche", # 18 = Asche/verkohlt
"stoppeln", # 19 = abgeerntetes Feld
]
const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"]
## Skybox-Option (Karte t_f72913d4): Werte des optionalen Feldes `weather_sky`
## — identische Semantik wie der Client (Weather.apply_preset sky_override):
## "sky_panorama" = Pack-Panorama erzwingen, "prozedural" = ProceduralSky
## erzwingen, leer/fehlend = Preset entscheidet (in allen Presets aus).
const WEATHER_SKY_OPTIONS: Array[String] = ["sky_panorama", "prozedural"] # ALT; gültig = SkyboxKatalog.ist_gueltig
# Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4).
const MAX_FILE_BYTES := 50 * 1024 * 1024 # V1
const MAX_OBJECTS := 5000 # V8a
const MAX_TOTAL_POLY := 1_500_000 # V8b (× scale² gewichtet)
const MAX_TERRAIN_RESOLUTION := 513 # V6
const MIN_TERRAIN_RESOLUTION := 17
const MAX_TERRAIN_SIZE_M := 8192.0 # V6/V14
const MIN_TERRAIN_SIZE_M := 16.0
const MAX_HEIGHT_RANGE_M := 500.0 # V6
const MAX_BOUNDS_EXTENT_M := 8192.0 # V14
const BOUNDS_TOLERANCE_M := 1.0 # V9
const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11)
const SYM_KEY_MAX_LEN := 64 # V16 (Symmetrie-Partner-Kennung)
## Feines Material-Raster (Karte 24.10., Sly: „feiner malen“): optionales
## terrain.material_resolution — eigenes Raster für materials_r8_b64, feiner als
## das Höhenfeld. Fehlt es, gilt resolution (alte Maps unverändert). Muss
## ≥ resolution sein und auf dem Höhenraster liegen ((m−1) Vielfaches von
## (res−1)), damit jeder Höhenpunkt genau einen Materialpunkt hat.
const MAX_MATERIAL_RESOLUTION := 1025 # V15 (1025² = 1 MB)
const MATERIAL_TARGET_M := 1.0 # Ziel: 1 m pro Materialpunkt
const MANIFEST_PATH := "res://library/objects.json"
const JSON_INDENT := " "
static func _add(result: Dictionary, msg: String) -> void:
(result["errors"] as Array).append(msg)
# --------------------------------------------------------------- Öffentlich --
## Komplette Pipeline: JSON-Text → Validierung (inkl. Prüfsumme) → Map-Daten.
## errors bewusst als Array: Godot 4 kopiert PackedArrays beim Dict-Zugriff,
## Mutationen würden sonst verloren gehen.
static func parse_and_validate(text: String, manifest: Dictionary = {}) -> Dictionary:
var result := {"ok": false, "errors": [], "data": {}}
if text.length() > MAX_FILE_BYTES:
_add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [text.length(), MAX_FILE_BYTES])
return result
var parsed: Variant = JSON.parse_string(text)
if parsed == null or not (parsed is Dictionary):
_add(result, "V2: Kein gültiges JSON-Objekt (Datei beschädigt oder keine .svmap?).")
return result
var data: Dictionary = parsed
var errors := validate_map(data, manifest)
if not errors.is_empty():
result["errors"] = Array(errors)
return result
ModusKatalog.migrate(data) # Alt-Namen (checkpoints → conquest) NACH der Prüfsumme umstellen
result["ok"] = true
result["data"] = data
return result
static func load_from_file(path: String) -> Dictionary:
var result := {"ok": false, "errors": [], "data": {}}
if not FileAccess.file_exists(path):
_add(result, "Datei nicht gefunden: %s" % path)
return result
var fa := FileAccess.open(path, FileAccess.READ)
if fa == null:
_add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()])
return result
if fa.get_length() > MAX_FILE_BYTES:
_add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES])
return result
return parse_and_validate(fa.get_as_text())
## Kern-Validierung → Kanonisieren → Signieren → Schreiben. Nur gültige Maps
## landen auf Disk. Kanonisieren VOR dem Schreiben (alle Zahlen → float):
## macht Dateien deterministisch — speichern→laden→speichern bleibt
## byte-identisch, weil Godots JSON-Parser ints ohnehin als float zurückgibt.
## Kompatibel zum Server: dessen Prüfsumme läuft über die kanonisierte Payload,
## int- und float-Form kanonisieren zum selben Hash.
## Schema v2 (Karte t_9814f18f): die Payload wird VOR dem Signieren quantisiert
## (12 signifikante Stellen) — die Datei enthält keine Rausch-Floats mehr, und
## der v2-Hash bleibt unter allen Godot-Versionen stabil (FirstFight-Fall:
## ein 15-stelliger Python-Float-Text war unter Legacy-Hash nicht verifizierbar).
static func save_to_file(path: String, data: Dictionary) -> Dictionary:
var signed := to_signed_text(data)
if not bool(signed["ok"]):
return {"ok": false, "errors": signed["errors"]}
var fa := FileAccess.open(path, FileAccess.WRITE)
if fa == null:
var out := {"ok": false, "errors": []}
_add(out, "Datei nicht schreibbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()])
return out
fa.store_string(str(signed["text"]))
return {"ok": true, "errors": PackedStringArray()}
## Wie save_to_file, aber ohne Datei: liefert den fertigen, kanonisierten und
## signierten .svmap-Text (für den Server-Upload, Etappe 3). Das Eingabe-doc
## bleibt unangetastet. {ok, errors, text}
static func to_signed_text(data: Dictionary) -> Dictionary:
var errors := _validate_core(data)
if not errors.is_empty():
return {"ok": false, "errors": errors, "text": ""}
var canonical: Dictionary = _canonicalize(data)
_quantisieren_deep(canonical)
sign_map(canonical)
return {"ok": true, "errors": PackedStringArray(), "text": JSON.stringify(canonical, JSON_INDENT)}
## Validierung OHNE Prüfsumme (V3–V12 + V14); validate_map = Kern + Prüfsumme.
## manifest = Bibliotheks-Registry (leer → Default aus res://library/objects.json).
static func validate_map(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray:
var errors := _validate_core(data, manifest)
if not errors.is_empty():
return errors
return _validate_checksum(data)
## Setzt/erneuert die Prüfsumme (save = neue Map-Version).
## Schema v2: Hash über die quantisierte Payload (12 signifikante Stellen).
static func sign_map(data: Dictionary) -> void:
data["checksum"] = {"algorithm": "sha256", "schema": "v2", "value": _payload_sha256_v2(data)}
# ------------------------------------------------------------- Validierung --
static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray:
var errors := PackedStringArray()
# V3: Versions-Stempel (v1 = Legacy ohne Material-Schicht, v2 = aktuell)
var fv = data.get("format_version", null)
if not (fv is int or fv is float) or not SUPPORTED_FORMAT_VERSIONS.has(int(fv)):
var wanted := ""
for v in SUPPORTED_FORMAT_VERSIONS:
wanted += (" oder " if wanted != "" else "") + str(v)
errors.append("V3: format_version muss %s sein (ist %s). Diese Datei ist keine .svmap-Map." % [wanted, str(fv)])
return errors # Ohne passende Version ist alles Weitere sinnlos.
# V4: Pflichtblöcke + Typen (checksum prüft validate_map separat).
for key in ["info", "terrain", "spawn_zones", "bounds"]:
if not data.has(key) or not (data[key] is Dictionary):
errors.append("V4: Pflichtblock '%s' fehlt oder ist kein Objekt." % key)
if not data.has("objects") or not (data["objects"] is Array):
errors.append("V4: Pflichtblock 'objects' fehlt oder ist kein Array.")
if not errors.is_empty():
return errors
errors.append_array(_validate_info(data["info"]))
errors.append_array(_validate_terrain(data["terrain"]))
errors.append_array(_validate_bounds(data["bounds"]))
var terrain: Dictionary = data["terrain"]
var bounds: Dictionary = data["bounds"]
errors.append_array(_validate_objects(data["objects"], bounds, manifest))
errors.append_array(_validate_spawns(data["spawn_zones"], bounds))
# V12: Wetter (optional, aber dann gültig)
var weather = data.get("weather", "")
if weather != null and str(weather) != "":
if not (weather is String) or not WEATHER_PRESETS.has(weather):
errors.append("V12: weather '%s' ist kein gültiges Preset (%s)." % [
str(weather), ", ".join(WEATHER_PRESETS)])
# V17: weather_sky (optional, Karte t_f72913d4) — leer/fehlend = Standard.
# Checksum-neutral wie tint/gebaeude: der Server kennt das Feld nicht, die
# Prüfsumme läuft über die ganze Payload — unbekannte Zusatzfelder stören
# die Server-Validierung nicht, nur WERT-Fehler fangen wir hier ab.
var weather_sky = data.get("weather_sky", "")
if weather_sky != null and str(weather_sky) != "":
if not SkyboxKatalog.ist_gueltig(weather_sky):
var ids: PackedStringArray = ["prozedural"]
for e in SkyboxKatalog.EINTRAEGE:
ids.append(e["id"])
errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [
str(weather_sky), ", ".join(ids)])
# V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog.
errors.append_array(LoadoutKatalog.validate(data.get("loadout", null)))
# V21–V24: Spielmodus, Zonen, Hills, Modus-Spawns (optional), siehe ModusKatalog.
errors.append_array(ModusKatalog.validate(data, bounds))
return errors
static func _validate_info(info: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
var name_v = info.get("name", "")
if not (name_v is String) or (name_v as String).strip_edges().is_empty() \
or (name_v as String).length() > 64:
errors.append("V4: info.name muss 1–64 Zeichen lang sein.")
var author = info.get("author", "")
if not (author is String) or (author as String).strip_edges().is_empty():
errors.append("V4: info.author darf nicht leer sein.")
var version = info.get("version", 0)
if not (version is int or version is float) or int(version) < 1:
errors.append("V4: info.version muss eine Zahl ≥ 1 sein.")
var tgv = info.get("target_game_version", "")
if tgv != null and str(tgv) != "" and not (tgv is String):
errors.append("V4: info.target_game_version muss ein String sein.")
return errors
static func _validate_terrain(terrain: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
if terrain.get("type", "") != "heightmap":
errors.append("V4: terrain.type muss 'heightmap' sein.")
return errors
var res = terrain.get("resolution", 0)
if not (res is int or res is float) or int(res) < MIN_TERRAIN_RESOLUTION \
or int(res) > MAX_TERRAIN_RESOLUTION:
errors.append("V6: terrain.resolution muss %d–%d sein (ist %s)." % [
MIN_TERRAIN_RESOLUTION, MAX_TERRAIN_RESOLUTION, str(res)])
return errors
var sx = terrain.get("size_x_m", 0.0)
var sz = terrain.get("size_z_m", 0.0)
for pair in [["size_x_m", sx], ["size_z_m", sz]]:
if not _is_finite_num(pair[1]) or float(pair[1]) < MIN_TERRAIN_SIZE_M \
or float(pair[1]) > MAX_TERRAIN_SIZE_M:
errors.append("V6: terrain.%s muss %.0f–%.0f m sein (ist %s)." % [
pair[0], MIN_TERRAIN_SIZE_M, MAX_TERRAIN_SIZE_M, str(pair[1])])
var hmin = terrain.get("min_height_m", 0.0)
var hmax = terrain.get("max_height_m", 0.0)
if not _is_finite_num(hmin) or not _is_finite_num(hmax) or float(hmin) >= float(hmax) \
or (float(hmax) - float(hmin)) > MAX_HEIGHT_RANGE_M:
errors.append("V6: terrain.min_height_m < max_height_m (Spannweite ≤ %.0f m) verletzt." % MAX_HEIGHT_RANGE_M)
# V5: Höhenfeld-Länge
var res_i := int(res)
var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", "")))
if raw.size() != res_i * res_i * 2:
errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [
raw.size(), res_i * res_i * 2])
# V15: Material-Schicht (v2, optional — fehlt sie, gilt alles Gras).
var mres_v = terrain.get("material_resolution", res_i)
var mres_i := res_i
if not (mres_v is int or mres_v is float) or float(mres_v) != floorf(float(mres_v)) \
or int(mres_v) < res_i or int(mres_v) > MAX_MATERIAL_RESOLUTION \
or (res_i > 1 and (int(mres_v) - 1) % (res_i - 1) != 0):
errors.append("V15: terrain.material_resolution muss eine ganze Zahl %d–%d sein, deren (Wert−1) ein Vielfaches von (resolution−1) ist (ist %s)." % [
res_i, MAX_MATERIAL_RESOLUTION, str(mres_v)])
else:
mres_i = int(mres_v)
var mats = terrain.get("materials_r8_b64", "")
if mats is String and (mats as String) != "":
var mraw := Marshalls.base64_to_raw(mats as String)
if mraw.size() != mres_i * mres_i:
errors.append("V15: materials_r8_b64 dekodiert zu %d Bytes, erwartet %d (= material_resolution²)." % [
mraw.size(), mres_i * mres_i])
else:
for v in mraw:
if int(v) >= MATERIAL_IDS.size():
errors.append("V15: materials_r8_b64 enthält unbekannte Material-ID %d (erlaubt: 0–%d)." % [
int(v), MATERIAL_IDS.size() - 1])
break
elif not (mats is String):
errors.append("V15: materials_r8_b64 muss ein Base64-String sein (ist %s)." % str(mats))
errors.append_array(_validate_mix(terrain, mres_i))
# V20 (Karte t_28fdef03): Wasser-Level (optional).
errors.append_array(_validate_wasser(terrain))
return errors
static func _validate_bounds(bounds: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
var mn = bounds.get("min", null)
var mx = bounds.get("max", null)
if not (mn is Array) or not (mx is Array) or (mn as Array).size() != 3 \
or (mx as Array).size() != 3:
errors.append("V4: bounds.min/max müssen je [x, y, z] sein.")
return errors
for i in 3:
if not _is_finite_num(mn[i]) or not _is_finite_num(mx[i]):
errors.append("V9: bounds enthalten nicht-endliche Werte.")
return errors
if float(mx[i]) <= float(mn[i]):
errors.append("V14: bounds.max[%d] muss > bounds.min[%d] sein." % [i, i])
var extent := Vector3(float(mx[0]) - float(mn[0]), float(mx[1]) - float(mn[1]), float(mx[2]) - float(mn[2]))
if extent.length() > MAX_BOUNDS_EXTENT_M:
errors.append("V14: bounds-Diagonale %.0f m > %.0f m." % [extent.length(), MAX_BOUNDS_EXTENT_M])
return errors
static func _validate_objects(objects: Array, bounds: Dictionary, manifest: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
if objects.size() > MAX_OBJECTS:
errors.append("V8: %d Objekte > Limit %d." % [objects.size(), MAX_OBJECTS])
return errors
var lib: Dictionary = _manifest_or(manifest).get("objects", {})
var total_poly := 0.0
for i in objects.size():
var obj = objects[i]
if not (obj is Dictionary):
errors.append("V4: objects[%d] ist kein Objekt." % i)
continue
var oid = obj.get("id", "")
if not (oid is String) or not lib.has(oid):
errors.append("V7: objects[%d]: unbekannte Objekt-ID '%s' (nicht in der Bibliothek)." % [i, str(oid)])
continue
var pos = obj.get("pos", null)
if not (pos is Array) or (pos as Array).size() != 3 \
or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]):
errors.append("V9: objects[%d]: pos muss [x, y, z] mit endlichen Werten sein." % i)
elif not _inside_bounds(pos, bounds):
errors.append("V9: objects[%d] (%s) liegt außerhalb der Match-Grenzen." % [i, str(oid)])
var scale = obj.get("scale", 1.0)
if not _is_finite_num(scale) or float(scale) < 0.05 or float(scale) > 50.0:
errors.append("V10: objects[%d]: scale muss 0.05–50 sein (ist %s)." % [i, str(scale)])
continue
var rot = obj.get("rot_y_deg", 0.0)
if not _is_finite_num(rot):
errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i)
# V17 (Karte t_e166e656, Platzierungsfreiheit): optionales `rot` —
# Euler x/y/z in Grad (Godot-Konvention YXZ). Vorhanden → er ersetzt
# rot_y_deg KOMPLETT (3-Achsen-Kippen); fehlt er → Fallback rot_y_deg.
# Identischer Check wie im Match-Server (svmap_loader._validate_objects).
var rot_v: Variant = obj.get("rot", null)
if rot_v != null:
if not (rot_v is Array) or (rot_v as Array).size() != 3 \
or not _is_finite_num(rot_v[0]) \
or not _is_finite_num(rot_v[1]) \
or not _is_finite_num(rot_v[2]):
errors.append("V17: objects[%d]: rot muss [x, y, z] mit endlichen Zahlen sein (ist %s)." % [i, str(rot_v)])
# V16 (Karte t_f5935023): optionales `sym` (Symmetrie-Partner-Kennung).
# Editor-Format-Erweiterung: der Match-Server ignoriert unbekannte
# Felder, die Prüfsumme läuft über die ganze Payload → kompatibel.
# Fehlt das Feld (alte Karten) → keine Prüfung, alles unverändert.
var sym = obj.get("sym", null)
if sym != null:
if not (sym is String):
errors.append("V16: objects[%d]: sym muss ein String sein (ist %s)." % [i, str(sym)])
elif (sym as String).strip_edges().is_empty():
errors.append("V16: objects[%d]: sym darf nicht leer sein." % i)
elif (sym as String).length() > SYM_KEY_MAX_LEN:
errors.append("V16: objects[%d]: sym länger als %d Zeichen." % [i, SYM_KEY_MAX_LEN])
# V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes)
var poly = float(lib[oid].get("poly", 0))
total_poly += poly * float(scale) * float(scale)
# V16: eine sym-Kennung verbindet GENAU EIN Paar — mehr als 2 Objekte
# mit derselben Kennung sind ein Verkettungsfehler.
var sym_counts: Dictionary = {}
for obj2 in objects:
if not (obj2 is Dictionary):
continue
var s2 = (obj2 as Dictionary).get("sym", null)
if s2 is String and (s2 as String) != "":
var k := str(s2)
sym_counts[k] = int(sym_counts.get(k, 0)) + 1
for k2 in sym_counts.keys():
if int(sym_counts[k2]) > 2:
errors.append("V16: sym-Kennung '%s' verbindet %d Objekte — ein Paar hat genau 2." % [
k2, int(sym_counts[k2])])
if total_poly > MAX_TOTAL_POLY:
errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY])
return errors
static func _validate_spawns(zones: Dictionary, bounds: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
var seen_ids := {}
for team in [0, 1]:
var key := str(team)
if not zones.has(key) or not (zones[key] is Dictionary):
errors.append("V11: spawn_zones für Team %d fehlt." % team)
continue
var points = zones[key].get("points", [])
if not (points is Array) or points.size() < MIN_SPAWN_POINTS_PER_TEAM:
errors.append("V11: Team %d hat %s Spawn-Punkte, mindestens %d nötig." % [
team, str(points.size()), MIN_SPAWN_POINTS_PER_TEAM])
continue
for i in points.size():
var p = points[i]
if not (p is Dictionary):
errors.append("V11: Team %d Punkt %d ist kein Objekt." % [team, i])
continue
var pid = p.get("id", "")
if not (pid is String) or (pid as String).strip_edges().is_empty():
errors.append("V11: Team %d Punkt %d: id fehlt/leer." % [team, i])
elif seen_ids.has(pid):
errors.append("V11: Spawn-ID '%s' ist doppelt (Team %d, Punkt %d)." % [pid, team, i])
else:
seen_ids[pid] = true
var pos = p.get("pos", null)
if not (pos is Array) or (pos as Array).size() != 3 \
or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]):
errors.append("V9: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(pid))
elif not _inside_bounds(pos, bounds):
errors.append("V9: Spawn '%s' liegt außerhalb der Match-Grenzen." % str(pid))
if p.has("yaw_deg") and not _is_finite_num(p["yaw_deg"]):
errors.append("V9: Spawn '%s': yaw_deg muss endlich sein." % str(pid))
return errors
static func _validate_checksum(data: Dictionary) -> PackedStringArray:
if not data.has("checksum") or not (data["checksum"] is Dictionary):
return PackedStringArray(["V4: checksum-Block fehlt oder ist kein Objekt."])
var cs: Dictionary = data["checksum"]
var algo = cs.get("algorithm", "")
var value = cs.get("value", "")
if algo != "sha256":
return PackedStringArray(["V13: checksum.algorithm muss 'sha256' sein (ist %s)." % str(algo)])
if not (value is String) or not (value as String).is_valid_hex_number() \
or (value as String).length() != 64:
return PackedStringArray(["V13: checksum.value muss ein 64-Zeichen-Hex-String sein."])
# Prüfsummen-Schema v2 (Karte t_9814f18f, FirstFight-Vorfall 20.10.): der
# Hash läuft über die quantisierte Payload (12 signifikante Stellen) —
# formatunabhängig. Ein fremder Writer (z. B. Python-repr-Floats mit 15+
# Stellen) erzeugt denselben quantisierten Text wie Godot selbst.
# Legacy (schema-Feld fehlt): Hash über die VOLLE Textform — beim Laden
# als Fallback akzeptiert, sign_map schreibt beim nächsten Save v2.
var schema := str(cs.get("schema", "legacy"))
var expected := _payload_sha256_v2(data) if schema == "v2" else _payload_sha256(data)
if (value as String).to_lower() != expected:
return PackedStringArray(["V13: Prüfsumme stimmt nicht — die Map wurde nach dem Speichern verändert oder ist beschädigt."])
return PackedStringArray()
# ---------------------------------------------------------------- Prüfung --
## SHA-256 über die kanonisierte Payload (alles außer 'checksum').
## Kanonisierung: alle Zahlen rekursiv → float (sonst unterscheiden sich
## save- und load-seitiges JSON.stringify bei int/float — docs/map_format.md §5).
## EXAKT dieselbe Prozedur wie der Server-Loader → Prüsummen sind kompatibel.
static func _payload_sha256(data: Dictionary) -> String:
var payload: Dictionary = _canonicalize(data)
payload.erase("checksum")
var text := JSON.stringify(payload, JSON_INDENT)
return text.sha256_text()
## SHA-256 über die quantisierte Payload (Schema v2, Karte t_9814f18f):
## Kanonisieren → alle Floats auf 12 signifikante Stellen quantisieren →
## stringify → sha256. Quantisierung macht den Hash formatunabhängig:
## 14-stelliger Godot-Text und Python-repr desselben quantisierten Werts
## erzeugen denselben Hash (beide laufen durch denselben Godot-Parser +
## denselben stringify-Snap). Details: docs/map_format.md §5.
static func _payload_sha256_v2(data: Dictionary) -> String:
var payload: Dictionary = _canonicalize(data)
payload.erase("checksum")
_quantisieren_deep(payload)
var text := JSON.stringify(payload, JSON_INDENT)
return text.sha256_text()
## Float-Quantisierung auf 12 signifikante Stellen (Schema v2).
##
## Zwei Stufen:
## 1. Mathematisch: round(a / 10^(lg-11)) · 10^(lg-11) mit lg = Exponent der
## führenden Zehnerpotenz (log10). Werte unter 1e-12 → 0.0 (unter der
## 12-sig-Auflösung; Positionen sind Meter — Sub-Pikometer ist gameplay-egal).
## 2. SNAP über die Godot-Textform (JSON.stringify): das Runden auf 12 sig
## kann mathematisch einen "Rausch-Double" hinterlassen (z. B.
## 983194046021 · 1e-10 → 98.31940460210001). Der stringify-Text (max
## 15 signifikante Stellen, Godot-kanonisch) zurückgeparst liefert den
## DOUBLE, den jeder Godot-Leser aus diesem Text bekommt — der Snap
## macht quant(v) idempotent und damit den Hash reproduzierbar.
## Geparst wird IMMER über JSON.parse_string/stringify — so erzeugen
## Produzenten mit anderem Float-Textformat (Python-repr & Co.) denselben
## quantisierten double, sofern die Texte denselben Wert darstellen.
static func _quantisieren(v: float) -> float:
if not is_finite(v):
return v # NaN/INF — V9 fängt sie vor dem Hash ab; unverändert lassen.
if v == 0.0:
return 0.0
var a := absf(v)
var lg: float = floor(log(a) / LOG10)
if lg >= 12.0:
var m: float = pow(10.0, lg - 11.0)
return JSON.parse_string(JSON.stringify(signf(v) * roundf(a / m) * m))
if lg <= -12.0:
return 0.0
var mant: float = a / pow(10.0, lg)
var r: float = roundf(mant * 100000000000.0) / 100000000000.0
if r >= 1000000000000.0:
# Aufrunden in die nächste Dekade (z. B. 999.9999999995 → 1000)
lg += 1.0
r /= 10.0
return JSON.parse_string(JSON.stringify(r * pow(10.0, lg) * signf(v)))
const LOG10 := 2.302585092994046
static func _quantisieren_deep(value: Variant) -> void:
if value is Array:
for i in value.size():
var item: Variant = value[i]
if item is float:
value[i] = _quantisieren(item)
elif item is Array or item is Dictionary:
_quantisieren_deep(item)
elif value is Dictionary:
for k in value.keys():
var child: Variant = value[k]
if child is float:
value[k] = _quantisieren(child)
elif child is Array or child is Dictionary:
_quantisieren_deep(child)
static func _canonicalize(value: Variant) -> Variant:
if value is Array:
var arr := []
for v in value:
arr.append(_canonicalize(v))
return arr
if value is Dictionary:
var dict := {}
for k in value.keys():
dict[str(k)] = _canonicalize(value[k])
return dict
if value is int:
return float(value)
if value is bool or value is String or value is float or value == null:
return value
# PackedByteArray & Co. dürfen im svmap-Dict nicht vorkommen — als String.
return str(value)
static func _is_finite_num(v: Variant) -> bool:
return (v is int or v is float) and is_finite(float(v))
static func _inside_bounds(pos: Array, bounds: Dictionary) -> bool:
var mn: Array = bounds["min"]
var mx: Array = bounds["max"]
for i in 3:
if float(pos[i]) < float(mn[i]) - BOUNDS_TOLERANCE_M \
or float(pos[i]) > float(mx[i]) + BOUNDS_TOLERANCE_M:
return false
return true
# ------------------------------------------------------------------ Utils --
static func _manifest_or(manifest: Dictionary) -> Dictionary:
if not manifest.is_empty():
return manifest
if FileAccess.file_exists(MANIFEST_PATH):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH))
if parsed is Dictionary:
return parsed
return {}
## Höhenfeld dekodieren: uint16 little-endian, row-major, absolut in Metern.
## h = min_height + (v / 65535) * (max_height - min_height)
static func decode_heights(terrain: Dictionary) -> PackedFloat32Array:
var res := int(terrain["resolution"])
var raw := Marshalls.base64_to_raw(str(terrain["heightmap_r16_b64"]))
var hmin := float(terrain["min_height_m"])
var hmax := float(terrain["max_height_m"])
var out := PackedFloat32Array()
out.resize(res * res)
for i in res * res:
var v: int = raw.decode_u16(i * 2)
out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin)
return out
## Material-Schicht dekodieren (v2): uint8 je Sample, 0=Gras/1=Sand/2=Fels.
## Fehlt das Feld (Legacy-v1) → alles Gras (Default). Identische Indexierung
## wie das Höhenfeld — TerrainField.apply_material_brush arbeitet darauf.
static func decode_materials(terrain: Dictionary) -> PackedByteArray:
var res := material_resolution(terrain)
var out := PackedByteArray()
out.resize(maxi(res * res, 0)) # PackedByteArray füllt mit 0 = Gras
var mats = terrain.get("materials_r8_b64", "")
if mats is String and (mats as String) != "":
var mraw := Marshalls.base64_to_raw(mats as String)
var n := res * res
for i in n:
if i < mraw.size() and int(mraw[i]) < MATERIAL_IDS.size():
out[i] = mraw[i]
return out
## V15b (Karte 25.10., weiche Übergänge): optionale Misch-Schicht —
## materials_b_r8_b64 (zweites Material je Punkt) + materials_w_r8_b64 (Anteil
## des zweiten Materials, 0–255). Nur BEIDE oder KEINE; Länge material_resolution².
static func _validate_mix(terrain: Dictionary, mres: int) -> PackedStringArray:
var errors := PackedStringArray()
var hb := terrain.has("materials_b_r8_b64")
var hw := terrain.has("materials_w_r8_b64")
if not hb and not hw:
return errors
if hb != hw:
errors.append("V15: materials_b_r8_b64 und materials_w_r8_b64 gibt es nur zusammen.")
return errors
var b = terrain["materials_b_r8_b64"]
var w = terrain["materials_w_r8_b64"]
if not (b is String) or not (w is String):
errors.append("V15: Misch-Schicht muss aus Base64-Strings bestehen.")
return errors
var braw := Marshalls.base64_to_raw(b as String)
var wraw := Marshalls.base64_to_raw(w as String)
if braw.size() != mres * mres or wraw.size() != mres * mres:
errors.append("V15: Misch-Schicht hat %d/%d Bytes, erwartet je %d (= material_resolution²)." % [
braw.size(), wraw.size(), mres * mres])
return errors
for v in braw:
if int(v) >= MATERIAL_IDS.size():
errors.append("V15: materials_b_r8_b64 enthält unbekannte Material-ID %d." % int(v))
break
return errors
## Misch-Schicht dekodieren: [b, w] (je material_resolution², fehlt sie → Nullen).
static func decode_mix(terrain: Dictionary) -> Array:
var n := material_resolution(terrain)
var b := PackedByteArray()
var w := PackedByteArray()
b.resize(maxi(n * n, 0))
w.resize(maxi(n * n, 0))
var bs = terrain.get("materials_b_r8_b64", "")
var ws = terrain.get("materials_w_r8_b64", "")
if bs is String and ws is String and (bs as String) != "" and (ws as String) != "":
var braw := Marshalls.base64_to_raw(bs as String)
var wraw := Marshalls.base64_to_raw(ws as String)
if braw.size() == b.size() and wraw.size() == w.size():
for i in braw.size():
if int(braw[i]) < MATERIAL_IDS.size():
b[i] = braw[i]
w = wraw
return [b, w]
## Misch-Schicht schreiben — ganz ohne Mischung (alle w = 0) fallen die Felder
## weg (Datei bleibt so klein wie vorher, alte Leser unberührt).
static func encode_mix(terrain: Dictionary, b: PackedByteArray, w: PackedByteArray) -> void:
var any := false
for v in w:
if v != 0:
any = true
break
if not any:
terrain.erase("materials_b_r8_b64")
terrain.erase("materials_w_r8_b64")
return
terrain["materials_b_r8_b64"] = encode_materials(b)
terrain["materials_w_r8_b64"] = Marshalls.raw_to_base64(w)
## Raster der Material-Schicht (feines Raster, Karte 24.10.) — ohne Feld
## gilt das Höhenraster (alte Maps).
static func material_resolution(terrain: Dictionary) -> int:
var v = terrain.get("material_resolution", null)
if v is int or v is float:
return int(v)
return int(terrain.get("resolution", 0))
## Ziel-Raster für eine Karte: ~MATERIAL_TARGET_M pro Punkt, auf dem
## Höhenraster ausgerichtet, gedeckelt auf MAX_MATERIAL_RESOLUTION.
## 1024 m / res 129 → 1025 (1 m); 256 m / 129 → 257 (1 m); 2048 m → 1025 (2 m).
static func material_resolution_for(res: int, size_x: float, size_z: float) -> int:
if res < 2:
return res
var cell := maxf(size_x, size_z) / float(res - 1)
var k := maxi(1, int(ceilf(cell / MATERIAL_TARGET_M - 0.001)))
while k > 1 and (res - 1) * k + 1 > MAX_MATERIAL_RESOLUTION:
k -= 1
return (res - 1) * k + 1
## Material-Schicht auf ein anderes Raster umrechnen (nächster Punkt über die
## normierte Position — beide Raster spannen dieselbe Kartenfläche auf).
static func resample_materials(mats: PackedByteArray, from_res: int, to_res: int) -> PackedByteArray:
var out := PackedByteArray()
out.resize(maxi(to_res * to_res, 0))
if from_res < 2 or to_res < 2 or mats.size() < from_res * from_res:
return out
var f := float(from_res - 1) / float(to_res - 1)
for j in to_res:
var sj := mini(int(roundf(float(j) * f)), from_res - 1) * from_res
var row := j * to_res
for i in to_res:
out[row + i] = mats[sj + mini(int(roundf(float(i) * f)), from_res - 1)]
return out
## Alte Karte (Material-Raster = Höhenraster) auf das feine Raster heben.
## true = geändert. Optisch identisch (nächster Punkt), nur ab jetzt feiner malbar.
static func upgrade_material_layer(terrain: Dictionary) -> bool:
var res := int(terrain.get("resolution", 0))
if res < 2:
return false
var target := material_resolution_for(res, float(terrain.get("size_x_m", 0.0)),
float(terrain.get("size_z_m", 0.0)))
var cur := material_resolution(terrain)
if cur >= target:
return false
var mats := resample_materials(decode_materials(terrain), cur, target)
var mix := decode_mix(terrain)
var mb := resample_materials(mix[0], cur, target)
var mw := resample_materials(mix[1], cur, target)
terrain["material_resolution"] = target
terrain["materials_r8_b64"] = encode_materials(mats)
encode_mix(terrain, mb, mw)
return true
## Material-ID am Höhenpunkt (x, z) des Höhenrasters — für Vertex-Farben.
static func material_at_height_point(mats: PackedByteArray, mres: int, res: int,
x: int, z: int) -> int:
if mres < 2 or res < 2 or mats.size() < mres * mres:
return 0
var f := float(mres - 1) / float(res - 1)
return int(mats[mini(int(roundf(float(z) * f)), mres - 1) * mres + mini(int(roundf(float(x) * f)), mres - 1)])
## Material-Schicht kodieren: uint8 je Sample (Werte 0..MATERIAL_IDS.size()-1).
static func encode_materials(mats: PackedByteArray) -> String:
var bytes := PackedByteArray()
bytes.resize(mats.size())
for i in mats.size():
bytes[i] = clampi(mats[i], 0, MATERIAL_IDS.size() - 1)
return Marshalls.raw_to_base64(bytes)
# ------------------------------------- Fremdformat-Dialog (t_9814f18f) ------
## true, wenn ein Load NUR an der Pruefsumme haengt (V13) — die Kern-Validierung
## (V3–V16) ist gruen. Kriterium fuer den „Trotzdem laden (Pruefsumme erneuern)“-
## Dialog im Editor: die Datei kommt aus einem fremden Writer (FirstFight-Fall
## 20.10.: Python-repr-Floats), der Inhalt selbst ist intakt.
static func checksum_only_failure(errors: Array) -> bool:
if errors.is_empty():
return false
for e in errors:
if not str(e).begins_with("V13:"):
return false
return true
## true, wenn ein Load NUR an unbekannten Objekt-IDs (V7) und/oder der Prüfsumme
## (V13) hängt — Kriterium für die Migration beim Öffnen (SvmapMigration).
static func migrierbar(errors: Array) -> bool:
if errors.is_empty():
return false
var v7 := false
for e in errors:
if str(e).begins_with("V7:"):
v7 = true
elif not str(e).begins_with("V13:"):
return false
return v7
## Laden mit Migration veralteter Objekt-IDs: JSON parsen, unbekannte IDs per
## SvmapMigration ersetzen/überspringen, Kern-Validierung, v2-Prüfsumme erneuern.
## Rückgabe wie load_from_file plus "migration" (Bericht von migriere_objekte).
static func load_from_file_migriert(path: String) -> Dictionary:
var result := {"ok": false, "errors": [], "data": {}, "migration": {}}
if not FileAccess.file_exists(path):
_add(result, "Datei nicht gefunden: %s" % path)
return result
var fa := FileAccess.open(path, FileAccess.READ)
if fa == null:
_add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()])
return result
if fa.get_length() > MAX_FILE_BYTES:
_add(result, "V1: Map-Datei zu gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES])
return result
var parsed: Variant = JSON.parse_string(fa.get_as_text())
if parsed == null or not (parsed is Dictionary):
_add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).")
return result
var data: Dictionary = parsed
result["migration"] = SvmapMigration.migriere_objekte(data, _manifest_or({}))
var errors := _validate_core(data)
if not errors.is_empty():
result["errors"] = Array(errors)
return result
sign_map(data)
result["ok"] = true
result["data"] = data
return result
## Laden mit Pruefsummen-Erneuerung (t_9814f18f, „Trotzdem laden“-Weg):
## Datei lesen + JSON parsen, dann NUR die Kern-Validierung (V3–V16 ohne
## Pruefsumme). Ist die gruen, traegt sign_map() die v2-Pruefsumme ins Dokument
## ein — beim naechsten Speichern landet sie in der Datei. Schlaegt die
## Kern-Validierung durch, bleibt der harte Abbruch (echter Schaden).
## Rueckgabe wie load_from_file: {ok, errors, data}.
static func load_from_file_resign(path: String) -> Dictionary:
var result := {"ok": false, "errors": [], "data": {}}
if not FileAccess.file_exists(path):
_add(result, "Datei nicht gefunden: %s" % path)
return result
var fa := FileAccess.open(path, FileAccess.READ)
if fa == null:
_add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()])
return result
if fa.get_length() > MAX_FILE_BYTES:
_add(result, "V1: Map-Datei zu gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES])
return result
var parsed: Variant = JSON.parse_string(fa.get_as_text())
if parsed == null or not (parsed is Dictionary):
_add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).")
return result
var data: Dictionary = parsed
var errors := _validate_core(data)
if not errors.is_empty():
result["errors"] = Array(errors)
return result
sign_map(data)
result["ok"] = true
result["data"] = data
return result
# ------------------------------------- Platzierungsfreiheit (Karte t_e166e656) --
## Objekt-Rotation als Euler-Grad (Karte t_e166e656): `rot` (Euler x/y/z in
## Grad, Godot-Konvention YXZ) vorhanden → verwenden; fehlt er → Fallback
## `rot_y_deg` (nur Y — alte Karten unverändert). Identische Regel im
## Quest-Client (svmap_world._objekt_rotation) und Match-Server
## (svmap_loader._objekt_rotation) — Identitäts-Vertrag über alle drei Repos.
static func objekt_rotation(obj: Dictionary) -> Vector3:
var rot_v: Variant = obj.get("rot", null)
if rot_v is Array and (rot_v as Array).size() == 3:
var a: Array = rot_v
return Vector3(float(a[0]), float(a[1]), float(a[2]))
return Vector3(0.0, float(obj.get("rot_y_deg", 0.0)), 0.0)
## rot-Feld KORREKT ins Objekt schreiben (Editor-Seite der Regel): nur wenn
## tatsächlich um X oder Z gekippt ist, wird `rot` geschrieben (zusätzlich
## bleibt rot_y_deg immer aktuell = die Y-Komponente, damit alte Builds die
## Drehung wenigstens um Y richtig lesen). x = z = 0 → Feld wieder entfernen
## (Datei bleibt legacy-sauber).
static func rot_setzen(obj: Dictionary, euler: Vector3) -> void:
obj["rot_y_deg"] = euler.y
if absf(euler.x) < 0.0001 and absf(euler.z) < 0.0001:
obj.erase("rot")
else:
obj["rot"] = [euler.x, euler.y, euler.z]
# ------------------------------------------------- Wasser (Karte t_28fdef03) --
## V20 (Karte t_28fdef03): optionales svmap-Feld terrain.water_level (float y).
## Abwesenheit = kein Wasser (Legacy-Maps unverändert). Gleiche Regel wie im
## Match-Server (svmap_loader._validate_wasser): endliche Zahl im/am Terrain-
## Höhenbereich ±10 m — sonst wäre das Level sinnlos (unendlich tief unter
## Terrain oder meilen über dem Gelände schwebend).
static func _validate_wasser(terrain: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
var wl = terrain.get("water_level", null)
if wl == null:
return errors
if not _is_finite_num(wl):
errors.append("V20: terrain.water_level muss eine endliche Zahl sein (ist %s)." % str(wl))
return errors
var hmin = terrain.get("min_height_m", 0.0)
var hmax = terrain.get("max_height_m", 0.0)
if not _is_finite_num(hmin) or not _is_finite_num(hmax):
return errors # V6 meldet das bereits; hier nichts doppelt.
if float(wl) < float(hmin) - 10.0 or float(wl) > float(hmax) + 10.0:
errors.append("V20: terrain.water_level (%.2f m) liegt außerhalb des Terrain-Höhenbereichs [%.2f, %.2f] ±10 m." % [
float(wl), float(hmin), float(hmax)])
return errors

View file

@ -1 +0,0 @@
uid://b0n1rh37a72rd

View file

@ -1,67 +0,0 @@
extends RefCounted
class_name SvmapMigration
## Migration veralteter Objekt-IDs beim Laden (Karte 25.10.: test.svmap mit
## gelöschten Pilz-Objekten ließ sich nicht mehr öffnen, V7).
##
## Regel: Eine ID, die nicht (mehr) in der Bibliothek steht, wird per Ersatz-Tabelle
## durch ein passendes Pack-Objekt ersetzt (Position, Drehung, Skalierung, Gruppe
## bleiben). Gibt es keinen Ersatz, wird das Objekt übersprungen und gemeldet —
## nie ein Absturz, nie stilles Verschwinden. Die Datei selbst ändert sich erst
## beim Speichern (Undo/Neu-Laden bleibt möglich).
## Präfix → Ersatz-ID (Pilze waren kleine kollisionslose Bodendeko → Gras 003).
const ERSATZ_PRAEFIX := {
"pilz_": "grass_003",
}
## Ersatz-ID für eine unbekannte ID ("" = keiner).
static func ersatz_fuer(oid: String, lib: Dictionary) -> String:
for praefix in ERSATZ_PRAEFIX:
if oid.begins_with(praefix):
var neu: String = ERSATZ_PRAEFIX[praefix]
return neu if lib.has(neu) else ""
return ""
## Migriert data["objects"] in place. Rückgabe:
## {"ersetzt": {alt_id: {"neu": id, "anzahl": n}}, "entfernt": {alt_id: n}, "gesamt": n}
static func migriere_objekte(data: Dictionary, manifest: Dictionary) -> Dictionary:
var bericht := {"ersetzt": {}, "entfernt": {}, "gesamt": 0}
var objects: Variant = data.get("objects", null)
if not (objects is Array):
return bericht
var lib: Dictionary = manifest.get("objects", {})
var neu_liste: Array = []
for obj in (objects as Array):
if not (obj is Dictionary) or not (obj.get("id", null) is String) \
or lib.has(obj["id"]):
neu_liste.append(obj)
continue
var alt: String = obj["id"]
var ersatz := ersatz_fuer(alt, lib)
bericht["gesamt"] += 1
if ersatz.is_empty():
bericht["entfernt"][alt] = int(bericht["entfernt"].get(alt, 0)) + 1
continue
var kopie: Dictionary = (obj as Dictionary).duplicate(true)
kopie["id"] = ersatz
neu_liste.append(kopie)
var eintrag: Dictionary = bericht["ersetzt"].get(alt, {"neu": ersatz, "anzahl": 0})
eintrag["anzahl"] = int(eintrag["anzahl"]) + 1
bericht["ersetzt"][alt] = eintrag
if int(bericht["gesamt"]) > 0:
data["objects"] = neu_liste
return bericht
## Menschenlesbare Zusammenfassung (deutsch) für die Statuszeile.
static func zusammenfassung(bericht: Dictionary, lib: Dictionary) -> String:
var teile: PackedStringArray = []
for alt in bericht.get("ersetzt", {}):
var e: Dictionary = bericht["ersetzt"][alt]
var neu_name := str(lib.get(e["neu"], {}).get("name_de", e["neu"]))
teile.append("%d× „%s“ durch „%s“ ersetzt" % [e["anzahl"], alt, neu_name])
for alt in bericht.get("entfernt", {}):
teile.append("%d× „%s“ übersprungen (kein Ersatz)" % [bericht["entfernt"][alt], alt])
return ", ".join(teile)

View file

@ -1 +0,0 @@
uid://dhfmxbbnonapa

View file

@ -1,149 +0,0 @@
extends RefCounted
class_name SvmapTint
## Per-Objekt-Farbe (Tint) für den Map Builder — Karte t_b0edb807.
##
## Format (svmap, OPTIONALES Feld je Objekt):
## "tint": [r, g, b] — Display-Farben (sRGB, 0..1, auf 4 Stellen gerundet)
##
## FARBRAUM-REGEL (Lektion D9, 19.10.): gespeichert wird der ANZEIGE-Wert
## (sRGB, wie der Godot-Farbpicker ihn zeigt). Beim Anwenden wird der Wert
## DIREKT in `albedo_color` geschrieben — Godot interpretiert albedo_color
## als Anzeige-Farbe (flach beleuchtet zeigt der Pixel exakt diesen Wert).
## Das ist bewusst NICHT der glTF-Weg (dort wäre baseColorFactor linear):
## solange Editor und Client denselben Wert denselben Weg schreiben,
## rendern sie identisch — und genau das garantiert dieser Helfer.
##
## Semantik des Anwendens: `albedo_color` wird ERSETZT (nicht multipliziert).
## - Ungetexturierte Materialien (Military-Desert-Pack, bewiesenermaßen ohne
## Texturen) nehmen die gewählte Farbe 1:1 an — das ist die intuitive
## Erwartung an „Farbe wählen“.
## - Texturierte Materialien (Alt-Bestand/Waffen) bekommen die Textur ×
## Farbe (Godot multipliziert albedo_texture × albedo_color) — Weiß der
## Palette lässt sie unverändert, dunkle Palette tönt sie ab.
## „Zurücksetzen (Pack-Farbe)“ entfernt das Feld; ohne Feld läuft ALLES wie
## vorher (Rückwärtskompatibilität: alte Maps ohne tint laden unverändert).
##
## Anwendung als SURFACE-OVERRIDE-Materialien (Kopien): die Original-
## Materialien am Mesh bleiben unangetastet, das Feld ist damit reversibel,
## und das Auswahl-Orange (material_override) gewinnt weiterhin — Godot
## gibt material_override Vorrang vor surface_override_materials.
##
## DIESE DATEI ist bewusst 1:1 im Client-Repo vorhanden
## (scopedvr-client scripts/world/svmap_tint.gd) — Editor und Client müssen
## denselben Code nutzen, damit gespeicherte Farbe = gespielte Farbe.
## Bei Änderungen: BEIDE Kopien identisch anpassen.
const FIELD := "tint"
## Feste Palette (Namen wie auf der Karte vereinbart). Reihenfolge = UI-Reihenfolge.
const PALETTE := [
["Wüstensand", Color(0.76, 0.70, 0.50)],
["Oliv", Color(0.50, 0.52, 0.30)],
["Tarn-Grün", Color(0.30, 0.40, 0.24)],
["Stahlgrau", Color(0.45, 0.47, 0.50)],
["Holzbraun", Color(0.55, 0.40, 0.25)],
["Dunkelbraun", Color(0.32, 0.23, 0.14)],
["Weiß", Color(0.92, 0.92, 0.92)],
]
const EPS := 0.003 # Farb-Gleichheit (4 Nachkommastellen gerundet)
## Objekt hat ein gültiges tint-Feld?
static func hat(obj: Dictionary) -> bool:
return gueltig(obj.get(FIELD, null))
## Feld typo-/bereichsgeprüft: Array mit 3 Zahlen 0..1.
static func gueltig(v: Variant) -> bool:
if not (v is Array) or (v as Array).size() != 3:
return false
for c in v:
if not (c is float or c is int):
return false
if float(c) < 0.0 or float(c) > 1.0:
return false
return true
## Farbe eines Objekts lesen (ohne Feld → Weiß = neutral, Anzeigewert).
static func von(obj: Dictionary) -> Color:
var v: Variant = obj.get(FIELD, null)
if gueltig(v):
var a: Array = v
return Color(float(a[0]), float(a[1]), float(a[2]))
return Color.WHITE
## Farbe speichern (Anzeigewert, 4 Stellen — reicht für Visuelles, klein im JSON).
static func setze(obj: Dictionary, c: Color) -> void:
obj[FIELD] = [
roundf(c.r * 10000.0) / 10000.0,
roundf(c.g * 10000.0) / 10000.0,
roundf(c.b * 10000.0) / 10000.0,
]
## Feld entfernen („Zurücksetzen (Pack-Farbe)“).
static func entferne(obj: Dictionary) -> void:
obj.erase(FIELD)
## Zwei tint-Werte „gleich“ (Anzeige-Toleranz)?
static func gleich(a: Color, b: Color) -> bool:
return absf(a.r - b.r) <= EPS and absf(a.g - b.g) <= EPS and absf(a.b - b.b) <= EPS
## Paletten-Namen zu einer Farbe finden („“ = keine Palette-Farbe / gemischt).
static func palette_name(c: Color) -> String:
for eintrag in PALETTE:
if gleich(c, eintrag[1]):
return str(eintrag[0])
return ""
## Tint auf eine Visual-Struktur anwenden: ALLE MeshInstance3D darunter
## bekommen pro Fläche eine KOPIE ihres aktiven Materials mit der neuen
## albedo_color. Vorhandene Surface-Overrides werden ersetzt (idempotent).
## material_override (Auswahl-Orange) bleibt unangetastet und gewinnt.
static func anwenden(visual: Node, c: Color) -> void:
if visual == null or not is_instance_valid(visual):
return
_walk_anwenden(visual, c)
static func _walk_anwenden(n: Node, c: Color) -> void:
if n is MeshInstance3D:
var mi := n as MeshInstance3D
if mi.mesh != null:
for s in mi.mesh.get_surface_count():
var mat := mi.get_active_material(s)
# Ausgewählte Objekte (material_override = Auswahl-Material)
# überspringt der AUFRUFER, indem er tint erst nach dem
# Abwählen anwendet — hier nichts Sonderbehandeln.
if mat is BaseMaterial3D:
var dup: BaseMaterial3D = (mat as BaseMaterial3D).duplicate()
dup.albedo_color = Color(c.r, c.g, c.b,
(mat as BaseMaterial3D).albedo_color.a)
mi.set_surface_override_material(s, dup)
for kind in n.get_children():
_walk_anwenden(kind, c)
## Tint visuell zurücknehmen: alle gesetzten Surface-Override-Materialien
## entfernen → die Original-Materialien am Mesh zeigen wieder die Pack-Farbe
## (Platzhalter fallen auf ihr material_override/_base zurück).
static func zuruecknehmen(visual: Node) -> void:
if visual == null or not is_instance_valid(visual):
return
_walk_zurueck(visual)
static func _walk_zurueck(n: Node) -> void:
if n is MeshInstance3D:
var mi := n as MeshInstance3D
if mi.mesh != null:
for s in mi.mesh.get_surface_count():
mi.set_surface_override_material(s, null)
for kind in n.get_children():
_walk_zurueck(kind)

View file

@ -1 +0,0 @@
uid://cx6cfhyxw42f

View file

@ -1,456 +0,0 @@
extends RefCounted
class_name TerrainField
## Höhenfeld-Mathematik des Map-Editors (M2, Karte t_9aba04f1 — Terrain-Werkzeug).
##
## Alles static + rein: arbeitet auf einem dekodierten PackedFloat32Array
## (res×res, row-major, -Z → +Z) statt auf Base64-Strings — das Terrain-Werkzeug
## dekodiert EINMAL pro Stroke und arbeitet flott darauf. Das Doc (svmap-Format)
## bleibt der Besitzer der Daten: vor einem Stroke dekodieren, nach dem Stroke
## mit encode() zurück ins Doc.
##
## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik.
enum Mode { RAISE, LOWER, SMOOTH, PAINT, FLATTEN, RAMP }
## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber
## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim
## Zeichnen, Ziel 60+ FPS). Neu-Maps werden direkt mit 129 angelegt.
const EDITOR_MAX_RESOLUTION := 129
## Höhe an Welt-X/Z auf einem bereits dekodierten Feld (bilinear).
## Gleiche Geometrie-Konvention wie SvmapDocument.terrain_height_at, aber ohne
## Base64-Neudekodierung pro Aufruf (macht den Pinsel flott).
static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
x: float, z: float) -> float:
var last := float(res - 1)
var fx := clampf((x / size_x + 0.5) * last, 0.0, last)
var fz := clampf((z / size_z + 0.5) * last, 0.0, last)
var xi := int(floor(fx))
var zi := int(floor(fz))
var tx := fx - float(xi)
var tz := fz - float(zi)
var xi1 := mini(xi + 1, res - 1)
var zi1 := mini(zi + 1, res - 1)
var h00 := heights[zi * res + xi]
var h10 := heights[zi * res + xi1]
var h01 := heights[zi1 * res + xi]
var h11 := heights[zi1 * res + xi1]
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
## Pinsel-Gewicht über einen einzigen Regler (Karte 25.10.): t = 1 − d/radius,
## w = lerp(t, t·t, falloff). falloff 0 = linear (hart, breite Mitte),
## 1 = quadratisch (weich, spitze Mitte). Ausserhalb des Radius 0.
static func falloff_weight(d: float, radius: float, falloff: float) -> float:
if radius <= 0.0 or d >= radius:
return 0.0
var t := clampf(1.0 - d / radius, 0.0, 1.0)
return lerpf(t, t * t, clampf(falloff, 0.0, 1.0))
## Pinsel-Stärke aus Eingabe: Pen-Druck (>0) gewinnt, sonst Controller-Trigger
## (> 0.02), sonst Maus = 1.0. Immer 0.05…1.
static func pressure_of(pen: float, trigger: float) -> float:
if pen > 0.0:
return clampf(pen, 0.05, 1.0)
if trigger > 0.02:
return clampf(trigger, 0.05, 1.0)
return 1.0
## Flatten: zieht Höhen im Kreis gewichtet zur Zielhöhe. amount = Mischfaktor 0–1.
## (Eigene Funktion, damit apply_brush-Signatur stabil bleibt.)
static func apply_flatten(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
cx: float, cz: float, radius: float, target: float, amount: float,
falloff: float) -> bool:
if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res:
return false
var cell_x := size_x / float(res - 1)
var cell_z := size_z / float(res - 1)
var gx := (cx / size_x + 0.5) * float(res - 1)
var gz := (cz / size_z + 0.5) * float(res - 1)
var x0 := maxi(int(floor(gx - radius / cell_x)) - 1, 0)
var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0)
var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1)
var changed := false
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var dx := (float(x) - gx) * cell_x
var dz := (float(z) - gz) * cell_z
var w := falloff_weight(sqrt(dx * dx + dz * dz), radius, falloff)
if w <= 0.0:
continue
var idx := z * res + x
var h := heights[idx]
var nh := lerpf(h, target, clampf(amount * w, 0.0, 1.0))
if nh != h:
heights[idx] = nh
changed = true
return changed
## Rampe: Höhe linear von ha (Punkt a) nach hb (Punkt b), Breite `width` m
## quer zur Achse; Kern (halbe Breite) voll, Falloff am Rand. a/b = Welt-X/Z (Vector2). Rückgabe: geändert.
static func apply_ramp(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
a: Vector2, b: Vector2, ha: float, hb: float, width: float, falloff: float) -> bool:
if res < 2 or width <= 0.0 or heights.size() < res * res:
return false
var cell_x := size_x / float(res - 1)
var cell_z := size_z / float(res - 1)
var half := width * 0.5
var ab := b - a
var len2 := ab.length_squared()
var lo := Vector2(minf(a.x, b.x) - half, minf(a.y, b.y) - half)
var hi := Vector2(maxf(a.x, b.x) + half, maxf(a.y, b.y) + half)
var x0 := maxi(int(floor((lo.x / size_x + 0.5) * float(res - 1))) - 1, 0)
var x1 := mini(int(ceil((hi.x / size_x + 0.5) * float(res - 1))) + 1, res - 1)
var z0 := maxi(int(floor((lo.y / size_z + 0.5) * float(res - 1))) - 1, 0)
var z1 := mini(int(ceil((hi.y / size_z + 0.5) * float(res - 1))) + 1, res - 1)
var changed := false
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var p := Vector2((float(x) / float(res - 1) - 0.5) * size_x,
(float(z) / float(res - 1) - 0.5) * size_z)
var s := 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0)
var d := p.distance_to(a + ab * s)
# Innere Hälfte der Breite voll (ebene Fahrfläche), äußere Hälfte
# läuft mit dem Falloff-Regler ins Gelände aus.
var core := half * 0.5
var w := 1.0 if d <= core else falloff_weight(d - core, half - core, falloff)
if d >= half:
w = 0.0
if w <= 0.0:
continue
var idx := z * res + x
var h := heights[idx]
var nh := lerpf(h, lerpf(ha, hb, s), w)
if nh != h:
heights[idx] = nh
changed = true
return changed
## Pinsel anwenden. amount:
## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer),
## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel).
## Gewicht: Kosinus-Falloff (1 in der Mitte, 0 am Rand) — weiche Hügel statt
## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde.
static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
hmin: float, hmax: float, cx: float, cz: float, radius: float,
mode: int, amount: float, falloff: float = -1.0, target: float = 0.0) -> bool:
## falloff < 0 = alter Kosinus-Falloff (Kompatibilität), sonst Regler-Falloff.
## target nur für FLATTEN (Zielhöhe).
if mode == Mode.RAMP:
return false # Rampe hat ein eigenes Zwei-Punkt-API (apply_ramp)
if mode == Mode.FLATTEN:
return apply_flatten(heights, res, size_x, size_z, cx, cz, radius, target,
clampf(amount, 0.0, 1.0), maxf(falloff, 0.0))
if mode == Mode.PAINT:
# PAINT läuft über apply_material_brush (Material-Schicht, Karte
# t_603ec1a8) — Höhe bleibt unangetastet. Hier ohne Material-Argument
# aufrufbar, aber bewusst NEUTRAL: kein Pattern, keine Höhe.
return false
if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res:
return false
var cell_x := size_x / float(res - 1)
var cell_z := size_z / float(res - 1)
var r_cells_x := radius / cell_x
var r_cells_z := radius / cell_z
var gx := (cx / size_x + 0.5) * float(res - 1)
var gz := (cz / size_z + 0.5) * float(res - 1)
var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0)
var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0)
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
if mode == Mode.SMOOTH:
return _apply_smooth(heights, res, hmin, hmax, gx, gz, r_cells_x, r_cells_z,
x0, x1, z0, z1, clampf(amount, 0.0, 1.0), falloff)
var changed := false
var z := z0
while z <= z1:
var x := x0
while x <= x1:
var dx := (float(x) - gx) * cell_x
var dz := (float(z) - gz) * cell_z
var d := sqrt(dx * dx + dz * dz)
if d >= radius:
x += 1
continue
var w := 0.5 * (1.0 + cos(PI * d / radius)) if falloff < 0.0 \
else falloff_weight(d, radius, falloff)
var idx := z * res + x
var h := heights[idx]
var nh := h + amount * w if mode == Mode.RAISE else h - amount * w
nh = clampf(nh, hmin, hmax)
if nh != h:
heights[idx] = nh
changed = true
x += 1
z += 1
return changed
## Material-Pinsel (Karte t_603ec1a8): setzt die Material-ID aller Zellen im
## Kreis (d < radius) auf material_id — Malen statt Formen, Höhe bleibt unangetastet.
## Kante bewusst CRISP (d < radius): Malen fühlt sich wie ein Malprogramm an,
## die Vertex-Interpolation des Meshes glättet die Grenze optisch.
## mats = dekodierte Material-Schicht (PackedByteArray, res² Einträge).
static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, size_z: float,
cx: float, cz: float, radius: float, material_id: int) -> bool:
if res < 2 or radius <= 0.0 or mats.size() < res * res:
return false
var gx := (cx / size_x + 0.5) * float(res - 1)
var gz := (cz / size_z + 0.5) * float(res - 1)
var r_cells_x := radius / (size_x / float(res - 1))
var r_cells_z := radius / (size_z / float(res - 1))
var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0)
var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0)
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
var changed := false
var z := z0
while z <= z1:
var x := x0
while x <= x1:
var dx := (float(x) - gx) * (size_x / float(res - 1))
var dz := (float(z) - gz) * (size_z / float(res - 1))
if dx * dx + dz * dz < radius * radius:
var idx := z * res + x
if mats[idx] != material_id:
mats[idx] = material_id
changed = true
x += 1
z += 1
return changed
## Malen MIT weichem Rand (Karte 25.10., Sly: „weicher Übergang der Texturen
## am Rand des Malwerkzeugs“). Jeder Punkt kann zwei Materialien mischen:
## a = mats (Hauptschicht), b = mix_b, Anteil von b = mix_w/255. Innerhalb
## radius·(1−weichheit) wird voll gemalt, außen fällt der Anteil des neuen
## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie
## abgeschwächt (Nachmalen am Rand macht ihn kräftiger, nicht schwächer).
## Mischung bleibt zweiteilig: das neue Material + das bisher stärkste andere
## (kommt ein drittes Material dazu, wird das schwächere der beiden ersetzt).
## staerke (0–1, Controller-Trigger/Pen-Druck) deckelt den Zielanteil.
static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArray,
mix_w: PackedByteArray, res: int, size_x: float, size_z: float,
cx: float, cz: float, radius: float, material_id: int, weichheit: float,
staerke: float = 1.0) -> bool:
if res < 2 or radius <= 0.0 or mats.size() < res * res \
or mix_b.size() < res * res or mix_w.size() < res * res:
return false
var soft := clampf(weichheit, 0.0, 1.0)
var inner := radius * (1.0 - soft)
var cell_x := size_x / float(res - 1)
var cell_z := size_z / float(res - 1)
var gx := (cx / size_x + 0.5) * float(res - 1)
var gz := (cz / size_z + 0.5) * float(res - 1)
var x0 := maxi(int(floor(gx - radius / cell_x)) - 1, 0)
var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0)
var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1)
var changed := false
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var dx := (float(x) - gx) * cell_x
var dz := (float(z) - gz) * cell_z
var d := sqrt(dx * dx + dz * dz)
if d >= radius:
continue
var f := 1.0
if d > inner:
var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0)
f = 1.0 - t * t * (3.0 - 2.0 * t)
var fb := int(roundf(f * clampf(staerke, 0.0, 1.0) * 255.0))
if fb <= 0:
continue
var idx := z * res + x
var a := int(mats[idx])
var b := int(mix_b[idx])
var w := int(mix_w[idx])
var anteil := (255 - w if a == material_id else 0) + (w if b == material_id else 0)
if fb <= anteil:
continue
if fb >= 255:
mats[idx] = material_id
mix_b[idx] = material_id
mix_w[idx] = 0
else:
# Ziel in keinem Slot → das SCHWÄCHERE Slot-Material wird ersetzt
# (Anteil von b = w/255, a hat 255−w): w ≥ 128 → b bleibt, sonst a.
var andere: int
if a == material_id:
andere = b
elif b == material_id:
andere = a
else:
andere = b if w >= 128 else a
mats[idx] = andere
mix_b[idx] = material_id
mix_w[idx] = fb
changed = true
return changed
## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird
## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung).
static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float,
gx: float, gz: float, r_cells_x: float, r_cells_z: float,
x0: int, x1: int, z0: int, z1: int, amount: float, falloff: float = -1.0) -> bool:
# Mittelwerte nur für das Pinsel-Rechteck (statt Puffer in Feldgröße pro Schritt).
var bw := x1 - x0 + 1
var avg := PackedFloat32Array()
avg.resize(bw * (z1 - z0 + 1))
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var sum := 0.0
var count := 0
for zi in range(maxi(z - 1, 0), mini(z + 1, res - 1) + 1):
for xi in range(maxi(x - 1, 0), mini(x + 1, res - 1) + 1):
sum += heights[zi * res + xi]
count += 1
avg[(z - z0) * bw + (x - x0)] = sum / float(maxi(count, 1))
var changed := false
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001)
var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001)
var d := sqrt(dx * dx + dz * dz)
if d >= 1.0:
continue
var w := falloff_weight(d, 1.0, falloff)
var idx := z * res + x
var nh := lerpf(heights[idx], avg[(z - z0) * bw + (x - x0)], clampf(amount * w, 0.0, 1.0))
nh = clampf(nh, hmin, hmax)
if nh != heights[idx]:
heights[idx] = nh
changed = true
return changed
## Höhenfeld → Base64-Rohdaten (svmap-Format: uint16 LE, row-major).
## Rundung halbwegs offen (round) — Fehler ≤ ½ Quantisierungsschritt.
static func encode(heights: PackedFloat32Array, hmin: float, hmax: float) -> String:
var span := maxf(hmax - hmin, 0.000001)
var bytes := PackedByteArray()
bytes.resize(heights.size() * 2)
for i in heights.size():
var v := int(round(clampf((heights[i] - hmin) / span, 0.0, 1.0) * 65535.0))
bytes.encode_u16(i * 2, v)
return Marshalls.raw_to_base64(bytes)
## Cursor-Strahl → erster Treffer auf dem Höhenfeld (ray-march + Bisektion).
## Für den Pinsel: der Kreis muss UNTER dem Mauszeiger auf dem GELÄNDE liegen,
## nicht auf der flachen Basis-Ebene. Rückgabe null = kein Treffer.
static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
origin: Vector3, dir: Vector3, max_dist: float = 0.0) -> Variant:
if res < 2 or heights.size() < res * res:
return null
if max_dist <= 0.0:
max_dist = maxf(size_x, size_z) * 3.0
var d := dir
if d.length_squared() < 0.0000001:
return null
d = d.normalized()
var step := maxf(size_x, size_z) / float(res - 1) * 0.5
step = clampf(step, 0.5, 4.0)
# Tangenten-Band (Karte t_234490cf, Teil 2 des Gerätebefunds): Ein Strahl,
# der EXAKT auf eine Hügelkuppe zielt, grast die gewölbte Oberfläche —
# f(t) bleibt minimal knapp ÜBER 0 am Kuppen-Scheitel, DANACH steigt f
# wieder, obwohl weiter hinten (verdeckt hinter dem Hügel) noch ein
# Vorzeichenwechsel an der Basis-Ebene käme. Regel „erster sichtbarer
# Oberflächen-Kontakt gewinnt": kommt f innerhalb des Bandes (½ Schritt)
# an die Oberfläche und entfernt sich der Strahl wieder, gilt die
# Oberfläche als getroffen (lokal verfeinert). Ein echter Treffer
# (f ≤ 0) gewinnt weiterhin immer.
var graze := step * 0.5
var best_t := -1.0
var best_f := INF
var t := 0.0
var prev_t := 0.0
var prev_f := INF
while t < max_dist:
t = minf(t + step, max_dist)
var f := _ray_error(heights, res, size_x, size_z, origin, d, t)
if f < best_f:
best_f = f
best_t = t
if f <= 0.0:
# Vorzeichenwechsel zwischen prev_t und t → Bisektion.
var lo := prev_t
var hi := t
for i in 18:
var mid := (lo + hi) * 0.5
if _ray_error(heights, res, size_x, size_z, origin, d, mid) <= 0.0:
hi = mid
else:
lo = mid
return origin + d * hi
# Tangenten-Erkennung (Karte t_234490cf): war der Strahl im Band an
# der Oberfläche und steigt f JETZT wieder, hat er die Oberfläche
# tangential berührt und entfernt sich — der erste sichtbare Kontakt
# war das Graze-Minimum. Ohne das fiele der Strahl über die Kuppe
# hinweg und träfe die (verdeckte) Basis-Ebene weit hinten.
if f > prev_f and best_f <= graze:
return _graze_hit(heights, res, size_x, size_z, origin, d,
best_t, best_f, step, max_dist)
prev_f = f
prev_t = t
# Band-Minimum direkt am March-Ende (kein steigender Sample mehr kam):
if best_f <= graze and best_t > 0.0:
return _graze_hit(heights, res, size_x, size_z, origin, d,
best_t, best_f, step, max_dist)
# Nichts getroffen → Basis-Ebene als Fallback (wie interaction._ground_point).
if absf(d.y) > 0.0001:
var plane_y: float = _min_h(heights)
var t_plane: float = (plane_y - origin.y) / d.y
if t_plane > 0.0:
return origin + d * t_plane
return null
## Tangenten-Treffer (Karte t_234490cf): dichteste Annäherung des Strahls an
## die Oberfläche lokal verfeinern (Ternärsuche um den besten March-Sample)
## und den Punkt auf dem Strahl zurückgeben — der x/z-Punkt liegt damit dort,
## wo der Cursor die Oberfläche sichtbar berührt, nicht meterweit dahinter.
static func _graze_hit(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
origin: Vector3, d: Vector3, best_t: float, _best_f: float,
step: float, max_dist: float) -> Vector3:
var glo := maxf(best_t - step, 0.0)
var ghi := minf(best_t + step, max_dist)
for i in 12:
var ga := glo + (ghi - glo) / 3.0
var gb := ghi - (ghi - glo) / 3.0
if _ray_error(heights, res, size_x, size_z, origin, d, ga) < _ray_error(heights, res, size_x, size_z, origin, d, gb):
ghi = gb
else:
glo = ga
return origin + d * ((glo + ghi) * 0.5)
## Minimum/Maximum des Feldes (PackedFloat32Array hat kein .min()/.max()).
static func _min_h(heights: PackedFloat32Array) -> float:
var out := INF
for v in heights:
if v < out:
out = v
return out
static func _max_h(heights: PackedFloat32Array) -> float:
var out := -INF
for v in heights:
if v > out:
out = v
return out
## f(t) < 0 → Strahl unter dem Terrain. (prev_f-Nutzung vermieden Godot-Warnung.)
static func _ray_error(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
origin: Vector3, dir: Vector3, t: float) -> float:
var p := origin + dir * t
return p.y - height_at(heights, res, size_x, size_z, p.x, p.z)

View file

@ -1 +0,0 @@
uid://bwhwp7otbp0ie

View file

@ -34,7 +34,7 @@ const APP_TITLE := "ScopedVR Map-Editor"
const ShortcutKeys := preload("res://src/editor/shortcut_keys.gd") const ShortcutKeys := preload("res://src/editor/shortcut_keys.gd")
const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd") const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd")
# Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets. # Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets.
const MoveMath := preload("res://src/editor/move_math.gd") const MoveMath := preload("res://core/editor/move_math.gd")
# Kurzhilfe-Overlay Karte 3/3 (t_11b5c25b): Gesamte Tastenliste als BBCode. # Kurzhilfe-Overlay Karte 3/3 (t_11b5c25b): Gesamte Tastenliste als BBCode.
const HelpText := preload("res://src/ui/help_text.gd") const HelpText := preload("res://src/ui/help_text.gd")
# Zentrale UI-Skalierung (Karte t_297221b5): EIN Faktor für Schrift/Panels/Zeilen. # Zentrale UI-Skalierung (Karte t_297221b5): EIN Faktor für Schrift/Panels/Zeilen.

View file

@ -2,7 +2,7 @@ extends "res://src/ui/panels/editor_panel.gd"
## Loadout-Bereich im Map-Tab (Karte „Loadout pro Map"): pro Map wählbar, ob die ## Loadout-Bereich im Map-Tab (Karte „Loadout pro Map"): pro Map wählbar, ob die
## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout ## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout
## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler ## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler
## Block doc["loadout"] (Regeln: src/svmap/loadout_katalog.gd, V18). Der Server ## Block doc["loadout"] (Regeln: core/svmap/loadout_katalog.gd, V18). Der Server
## setzt ein festes Loadout durch, der Client zeigt es an. In beiden Modi lässt ## setzt ein festes Loadout durch, der Client zeigt es an. In beiden Modi lässt
## sich zusätzlich der Granaten-/Minen-Vorrat pro Leben festlegen (auch für Bots). ## sich zusätzlich der Granaten-/Minen-Vorrat pro Leben festlegen (auch für Bots).
## ##

View file

@ -3,7 +3,7 @@ extends "res://src/ui/panels/editor_panel.gd"
## Conquest / King of the Hill / Deathmatch), Zonen bzw. Hills setzen und eigene ## Conquest / King of the Hill / Deathmatch), Zonen bzw. Hills setzen und eigene
## Spawns je Modus anlegen. Zonen erscheinen als farbige Kreise im 3D-Bild. ## Spawns je Modus anlegen. Zonen erscheinen als farbige Kreise im 3D-Bild.
## Gespeichert als optionale Felder doc["game_mode"], doc["conquest_zones"], ## Gespeichert als optionale Felder doc["game_mode"], doc["conquest_zones"],
## doc["hills"], doc["mode_spawns"] (Regeln: src/svmap/modus_katalog.gd). ## doc["hills"], doc["mode_spawns"] (Regeln: core/svmap/modus_katalog.gd).
## ##
## Jede Änderung ist ein Undo-Schritt (History-Blöcke; fehlt ein Block, gilt der ## Jede Änderung ist ein Undo-Schritt (History-Blöcke; fehlt ein Block, gilt der
## Standard: TDM, keine Zonen, keine eigenen Spawns). ## Standard: TDM, keine Zonen, keine eigenen Spawns).

View file

@ -22,7 +22,7 @@ extends Node3D
## interaction.gd bleibt dadurch UNVERÄNDERT (Karten-Grenze) und der ## interaction.gd bleibt dadurch UNVERÄNDERT (Karten-Grenze) und der
## bestehende Klick-/Zieh-Pfad wird 1:1 wiederverwendet. ## bestehende Klick-/Zieh-Pfad wird 1:1 wiederverwendet.
const SvmapDocument := preload("res://src/svmap/map_document.gd") const SvmapDocument := preload("res://core/svmap/map_document.gd")
const AXIS_LEN := 2.2 # Pfeil-Länge in Metern (Weltmaß) const AXIS_LEN := 2.2 # Pfeil-Länge in Metern (Weltmaß)
const SHAFT_R := 0.045 const SHAFT_R := 0.045

View file

@ -28,8 +28,8 @@ extends Camera3D
## gerechnet. fit_to_bounds() ersetzt frame_map(): ganze Map ins Bild. ## gerechnet. fit_to_bounds() ersetzt frame_map(): ganze Map ins Bild.
const FlyMath := preload("res://src/editor/fly_math.gd") const FlyMath := preload("res://src/editor/fly_math.gd")
const TerrainField := preload("res://src/svmap/terrain_field.gd") const TerrainField := preload("res://core/svmap/terrain_field.gd")
const SvmapIO := preload("res://src/svmap/svmap_io.gd") const SvmapIO := preload("res://core/svmap/svmap_io.gd")
const ORBIT_DEG_PER_PX := 0.3 # Karte K1: Drehgeschwindigkeit gekapselt — kein Nachlaufen const ORBIT_DEG_PER_PX := 0.3 # Karte K1: Drehgeschwindigkeit gekapselt — kein Nachlaufen
const DISTANCE_MIN := 5.0 const DISTANCE_MIN := 5.0

View file

@ -7,7 +7,7 @@ extends SceneTree
## ##
## Ausgabe: /tmp/d4_mapbuilder.png + Prüfzeilen (Modus == GLB, Fußpunkt y=0) ## Ausgabe: /tmp/d4_mapbuilder.png + Prüfzeilen (Modus == GLB, Fußpunkt y=0)
const MapDocument := preload("res://src/svmap/map_document.gd") const MapDocument := preload("res://core/svmap/map_document.gd")
const PLATZ := [ const PLATZ := [
{"id": "barrel_001", "pos": [-2.2, 0.0, 0.0], "rot_y_deg": 0.0}, {"id": "barrel_001", "pos": [-2.2, 0.0, 0.0], "rot_y_deg": 0.0},

View file

@ -22,7 +22,7 @@ const PLATZ := [
{"id": "apc_001", "pos": [9.5, 0.0, 0.0], "rot_y_deg": 0.0, "aufsatz": false}, {"id": "apc_001", "pos": [9.5, 0.0, 0.0], "rot_y_deg": 0.0, "aufsatz": false},
] ]
const MapDocument := preload("res://src/svmap/map_document.gd") const MapDocument := preload("res://core/svmap/map_document.gd")
var vp: SubViewport var vp: SubViewport
var bauer: Node3D var bauer: Node3D

View file

@ -49,7 +49,7 @@ func _initialize() -> void:
var instanz: Object = null var instanz: Object = null
match art: match art:
"editor": "editor":
skript = load("res://src/svmap/kollider_bau.gd") skript = load("res://core/svmap/kollider_bau.gd")
"client": "client":
skript = load("res://scripts/world/svmap_world.gd") skript = load("res://scripts/world/svmap_world.gd")
instanz = skript.new() instanz = skript.new()

View file

@ -42,8 +42,8 @@ const COL_TRIMESH_FEHLT := {
## Skript-Pfade je Projekt (config/name → Loader-Skripte). ## Skript-Pfade je Projekt (config/name → Loader-Skripte).
const SKRIPTE := { const SKRIPTE := {
"ScopedVR Map Editor": { "ScopedVR Map Editor": {
"rotation": "res://src/svmap/svmap_io.gd", "rotation": "res://core/svmap/svmap_io.gd",
"hindernis": "res://src/svmap/kollider_bau.gd", "hindernis": "res://core/svmap/kollider_bau.gd",
"rot_funktion": "objekt_rotation", "rot_funktion": "objekt_rotation",
}, },
"ScopedVR": { "ScopedVR": {

View file

@ -33,6 +33,7 @@ func _init() -> void:
DirAccess.make_dir_recursive_absolute(base) DirAccess.make_dir_recursive_absolute(base)
_run_tests() _run_tests()
_test_ls_remote_warnzeile() _test_ls_remote_warnzeile()
_test_submodul_kommt_mit()
_force_writable(base) _force_writable(base)
_cleanup_dir(base) # best effort — git-Pack-Dateien können read-only sein _cleanup_dir(base) # best effort — git-Pack-Dateien können read-only sein
if failures == 0: if failures == 0:
@ -410,3 +411,43 @@ func _test_ls_remote_warnzeile() -> void:
"normale Zeile funktioniert weiterhin") "normale Zeile funktioniert weiterhin")
_check(EditorUpdater.sha_aus_ls_remote("%s\trefs/heads/anderer" % sha, "main") == sha, _check(EditorUpdater.sha_aus_ls_remote("%s\trefs/heads/anderer" % sha, "main") == sha,
"anderer Branch: SHA bleibt als Notnagel nutzbar") "anderer Branch: SHA bleibt als Notnagel nutzbar")
## Map-Kern als Submodul: Pull allein reicht nicht, das Update muss ihn mitholen.
func _test_submodul_kommt_mit() -> void:
print("== Submodul ==")
OS.set_environment("GIT_ALLOW_PROTOCOL", "file")
var core_bare := base.path_join("core.git")
var app_bare := base.path_join("app.git")
var core_work := base.path_join("core_work")
var app_work := base.path_join("app_work")
var alt := base.path_join("app_alt")
for bare in [core_bare, app_bare]:
_git(base, ["init", "--bare", "--initial-branch=main", bare])
for pair in [[core_work, core_bare, "kern.txt"], [app_work, app_bare, "app.txt"]]:
_git(base, ["init", "--initial-branch=main", pair[0]])
_git(pair[0], ["remote", "add", "origin", pair[1]])
_git(pair[0], ["config", "user.email", "t@example.invalid"])
_git(pair[0], ["config", "user.name", "Update-Test"])
_write_file(str(pair[0]).path_join(pair[2]), "v1\n")
_git(pair[0], ["add", "-A"])
_git(pair[0], ["commit", "-m", "v1"])
_git(pair[0], ["push", "-u", "origin", "main"])
_git(base, ["clone", app_bare, alt])
_check(bool(_git(app_work, ["-c", "protocol.file.allow=always", "submodule", "add", core_bare, "core"])["ok"]),
"Submodul: im App-Repo eingetragen")
_git(app_work, ["commit", "-m", "Kern als Submodul"])
_git(app_work, ["push", "origin", "main"])
var r := EditorUpdater.pull("git", alt)
_check(bool(r["ok"]), "Submodul: Update läuft durch")
_check(FileAccess.file_exists(alt.path_join("core/kern.txt")), "Submodul: Kern ist nach dem Update da")
_write_file(core_work.path_join("kern.txt"), "v2\n")
_git(core_work, ["commit", "-am", "v2"])
_git(core_work, ["push", "origin", "main"])
_git(app_work, ["-c", "protocol.file.allow=always", "submodule", "update", "--remote", "core"])
_git(app_work, ["add", "core"])
_git(app_work, ["commit", "-m", "Kern v2"])
_git(app_work, ["push", "origin", "main"])
r = EditorUpdater.pull("git", alt)
_check(bool(r["ok"]) and _file_text(alt.path_join("core/kern.txt")) == "v2\n",
"Submodul: neuer Kern-Stand kommt beim nächsten Update mit")

View file

@ -3,7 +3,7 @@
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
GODOT=/opt/data/home/bin/godot4 GODOT=/opt/data/home/bin/godot4
fail=0 fail=0
for f in src/ui/main.gd src/ui/inspector_panel.gd tests/test_inspector.gd src/editor/test_run.gd src/editor/library_browser.gd src/editor/object_ops.gd \ for f in src/ui/main.gd src/ui/inspector_panel.gd tests/test_inspector.gd src/editor/test_run.gd src/editor/library_browser.gd core/editor/object_ops.gd \
src/editor/interaction.gd tools/gen_thumbs.gd tests/test_library.gd; do src/editor/interaction.gd tools/gen_thumbs.gd tests/test_library.gd; do
out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true)
if [ -n "$out" ]; then if [ -n "$out" ]; then

View file

@ -5,7 +5,7 @@ GODOT=~/bin/godot4
fail=0 fail=0
for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \ for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \
src/editor/fly_math.gd src/editor/shortcut_policy.gd \ src/editor/fly_math.gd src/editor/shortcut_policy.gd \
src/editor/selection_math.gd src/editor/interaction.gd src/editor/object_ops.gd \ core/editor/selection_math.gd src/editor/interaction.gd core/editor/object_ops.gd \
tests/test_shortcuts.gd tests/test_ux.gd; do tests/test_shortcuts.gd tests/test_ux.gd; do
out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true)
if [ -n "$out" ]; then if [ -n "$out" ]; then

View file

@ -4,8 +4,8 @@ cd "$(dirname "$0")/.."
GODOT=/opt/data/home/bin/godot4 GODOT=/opt/data/home/bin/godot4
fail=0 fail=0
for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \ for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \
src/editor/move_math.gd src/editor/shortcut_policy.gd \ core/editor/move_math.gd src/editor/shortcut_policy.gd \
src/editor/interaction.gd src/editor/object_ops.gd \ src/editor/interaction.gd core/editor/object_ops.gd \
src/editor/build_tool.gd src/editor/terrain_tool.gd \ src/editor/build_tool.gd src/editor/terrain_tool.gd \
tests/test_shortcuts.gd tests/test_gui_smoke.gd; do tests/test_shortcuts.gd tests/test_gui_smoke.gd; do
out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true)