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