325 lines
No EOL
13 KiB
GDScript
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 |