scopedvr-map-core/editor/spawn_ops.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

325 lines
No EOL
13 KiB
GDScript

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