470 lines
19 KiB
GDScript
470 lines
19 KiB
GDScript
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
|