scopedvr-map-editor/src/svmap/map_document.gd

430 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) -> 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
var zero_materials := PackedByteArray()
zero_materials.resize(res * res) # 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.
"materials_r8_b64": Marshalls.raw_to_base64(zero_materials),
},
"objects": [],
"gebaeude": [], # Bauen-Modus (Karte t_97ea59c5) — Bauteile, optional
"spawn_zones": _default_spawn_zones(size),
"bounds": {
"min": [-half, -10.0, -half],
"max": [half, 80.0, half],
},
"weather": weather,
}
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 heights_new := PackedFloat32Array()
heights_new.resize(new_res * new_res)
var mats_new := PackedByteArray()
mats_new.resize(new_res * new_res)
var last_new := float(new_res - 1)
var last_old := float(old_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 auflösungstreu (nächster alter Gridpunkt).
var fx := clampf(roundf((x / old_sx + 0.5) * last_old), 0.0, last_old)
var fz := clampf(roundf((z / old_sz + 0.5) * last_old), 0.0, last_old)
mats_new[j * new_res + i] = mats_old[int(fz) * old_res + int(fx)]
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),
"materials_r8_b64": SvmapIO.encode_materials(mats_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])
return {
"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(),
"gebaeude": (doc.get("gebaeude", []) 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", ""),
}
# ------------------------------------------------- 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