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