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:
parent
e8ab954876
commit
949d73dfdd
54 changed files with 76 additions and 4807 deletions
5
.gitmodules
vendored
Normal file
5
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[submodule "core"]
|
||||||
|
path = core
|
||||||
|
url = ../scopedvr-map-core.git
|
||||||
|
branch = main
|
||||||
|
ignore = untracked
|
||||||
|
|
@ -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
1
core
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 92ee3fbdd1102d24bd34ad9302a494973a525fc3
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cppnjm0fhofpa
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://bj1egufnqcho5
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://d0luucpxtrabo
|
|
||||||
|
|
@ -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ß
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://behfbtvkyai65
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://dnocqoteya1h3
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://00t31s6wj67c
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://c1yvw71ppjl28
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://e08hmo3p6ra3
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://b13m4gw07qdsf
|
|
||||||
|
|
@ -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 []
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cncg002d4e5x7
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://jm1cubdr25s8
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cmyaqvftm41uf
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cprm4ke4dpj58
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cydcauar68si5
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://b0n1rh37a72rd
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://dhfmxbbnonapa
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cx6cfhyxw42f
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://bwhwp7otbp0ie
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
##
|
##
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue