M2-Karte t_c4162c5d: Spawn-Zonen malen + Live-Validierung + Map-Fertig-Check (Export-Signierung unverändert über SvmapIO)
This commit is contained in:
parent
3a5c9d44bb
commit
684b4cfd08
10 changed files with 1479 additions and 54 deletions
45
README.md
45
README.md
|
|
@ -120,6 +120,45 @@ Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
|
||||||
fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und
|
fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und
|
||||||
speichert Screenshots.
|
speichert Screenshots.
|
||||||
|
|
||||||
**Noch offen (folgende Karten der MVP-Kette):** Spawn-Zonen-Editor
|
**Noch offen (folgende Karten der MVP-Kette):** Performance-Check-UI,
|
||||||
(t_c4162c5d — Spawn-Zonen malen, Live-Validierung, Export-Signierung),
|
Export-Workflow.
|
||||||
Performance-Check-UI, Export-Workflow.
|
|
||||||
|
## Status (M2, Karte t_c4162c5d — Spawn-Zonen FERTIG)
|
||||||
|
|
||||||
|
Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz:
|
||||||
|
|
||||||
|
- **Spawn-Zonen malen (`src/editor/spawn_tool.gd`):** Werkzeug
|
||||||
|
„🚩 Spawn-Zonen malen“ — Team Blau 🔵 / Team Rot 🔴 (TDM-Konvention:
|
||||||
|
IDs A…/B…, mapformat § spawn_zones), Klick setzt eine farbige flache
|
||||||
|
Kreis-Zone auf dem Terrain (Vorschau-Kreis folgt der Maus), Radius
|
||||||
|
2–30 m per Regler. Zone anklicken → auswählen + ziehen (Terrain-Höhe
|
||||||
|
bleibt), Entf löscht, Strg+Z nimmt JEDE Operation zurück (jede Zone =
|
||||||
|
EIN History-Eintrag, Radius-Slider-Ticks sind merge-gekoppelt).
|
||||||
|
- **Live-Validierung (`src/editor/spawn_ops.gd`):** Block „Spawn-Check“
|
||||||
|
rechts bleibt STÄNDIG sichtbar und spiegelt exakt die Loader-Regeln
|
||||||
|
(V11: ≥ 4 Spawn-Punkte pro Team · V7: Objekt-IDs in der Bibliothek) —
|
||||||
|
z. B. „✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 (mindestens 4
|
||||||
|
nötig)“ + Klartext-Hinweis, WIE die Zone ergänzt wird. Fehler zeigt der
|
||||||
|
Editor VOR dem Export, nicht erst beim Speichern.
|
||||||
|
- **Map-Fertig-Check:** Button „✅ Karte prüfen“ oben — Klartext-Liste
|
||||||
|
✅/❌ (Name, Autor, beide Teams, Objekt-IDs, Poly-Budget) in einem
|
||||||
|
Fenster; fehlt etwas, wackelt (Shake-Tween) der Button und die
|
||||||
|
Statusleiste sagt „Map ist noch nicht bereit — siehe Warnungen“.
|
||||||
|
Der harte Gate bleibt das Speichern (`SvmapIO.save_to_file` —
|
||||||
|
dieselbe `_validate_core`-Funktion, EINE Implementierung, kein Drift).
|
||||||
|
- **Export-Signierung:** unverändert der eine Code-Pfad — Editor-Speichern
|
||||||
|
ruft `SvmapIO.sign_map` (SHA-256 über kanonisierte Payload). Abnahme mit
|
||||||
|
ECHTEM Server-Loader: Editor-Export durch `SvmapLoader.parse_and_validate`
|
||||||
|
aus scopedvr-match-server gejagt → ok=true inkl. V13-Prüfsummen-Check
|
||||||
|
(kein Copy-Paste-Drift, Interop bewiesen).
|
||||||
|
- **Radius-Persistenz:** Der Zonen-Radius ist KEIN Formatfeld (spawn_zones
|
||||||
|
speichert Punkte) — er wohnt als `design_notes.spawn_radius_m` in der
|
||||||
|
Map (Loader prüft design_notes nicht → rundtrip-sicher). Zonen sitzen
|
||||||
|
AUF dem Terrain (`terrain_height_at`), egal wie hügelig die Map ist.
|
||||||
|
- **Tests:** `tests/test_spawns.gd` (52 Checks: setzen/ändern/löschen +
|
||||||
|
je Undo/Redo mit Radius, Merge-Tags, Live-Status ✅/❌, ready_report
|
||||||
|
grün/rot, Export→Re-Import identisch + byte-identischer Roundtrip,
|
||||||
|
Zone auf Terrain-Höhe). Parse-Check: 23 Scripts, 0 Fehler.
|
||||||
|
- **Desktop-Smoke:** `godot … -- --spawns-demo [ordner] [--spawns-demo-quit]`
|
||||||
|
fährt Zonen-Übersicht → Live-Check-Alarm → Undo → „Karte prüfen“
|
||||||
|
programmatisch durch und speichert Screenshots.
|
||||||
|
|
|
||||||
|
|
@ -275,8 +275,9 @@ func delete_selected() -> void:
|
||||||
# ------------------------------------------------------------ Eingaben ------
|
# ------------------------------------------------------------ Eingaben ------
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if main.get("tool_mode") == "terrain":
|
var mode: String = main.get("tool_mode")
|
||||||
return # Terrain-Pinsel hat das Zepter (terrain_tool.gd, Karte t_9aba04f1)
|
if mode == "terrain" or mode == "spawns":
|
||||||
|
return # Terrain-Pinsel / Spawn-Zonen haben das Zepter (eigene Werkzeuge)
|
||||||
if event is InputEventMouseMotion:
|
if event is InputEventMouseMotion:
|
||||||
var mm := event as InputEventMouseMotion
|
var mm := event as InputEventMouseMotion
|
||||||
if placing:
|
if placing:
|
||||||
|
|
|
||||||
247
src/editor/spawn_ops.gd
Normal file
247
src/editor/spawn_ops.gd
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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"]
|
||||||
|
|
||||||
|
|
||||||
|
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 = doc.get("spawn_zones", {})
|
||||||
|
if zones is Dictionary:
|
||||||
|
var zone = zones.get(str(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 zones = doc.get("spawn_zones", {})
|
||||||
|
if zones is Dictionary:
|
||||||
|
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)
|
||||||
|
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"])
|
||||||
|
out["design_notes"] = (doc.get("design_notes", {}) as Dictionary).duplicate(true)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## 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 = doc.get("spawn_zones", {})
|
||||||
|
if not (zones is Dictionary):
|
||||||
|
zones = {}
|
||||||
|
var team_key := str(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
|
||||||
1
src/editor/spawn_ops.gd.uid
Normal file
1
src/editor/spawn_ops.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c1yvw71ppjl28
|
||||||
450
src/editor/spawn_tool.gd
Normal file
450
src/editor/spawn_tool.gd
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
extends Node3D
|
||||||
|
## Spawn-Zonen-Werkzeug (M2, Karte t_c4162c5d): Zonen für Team Blau/Rot auf
|
||||||
|
## den Boden malen — im Editor-Modus (Chef-Grundsatz Sly: easy + Spaß).
|
||||||
|
##
|
||||||
|
## Bedienung:
|
||||||
|
## Linksklick = Zone setzen (Klick wie beim Objekt-Platzieren)
|
||||||
|
## Linksklick auf Zone = Zone auswählen; ZIEHEN verschiebt sie (Terrain-Höhe)
|
||||||
|
## Regel rechts = Radius der neuen Zonen; mit Auswahl: Radius DER Zone
|
||||||
|
## Entf = ausgewählte Zone löschen
|
||||||
|
## Rechtsklick/ESC = Auswahl weg / Werkzeug beendet (ESC in main.gd)
|
||||||
|
##
|
||||||
|
## Jede Operation ist EIN Undo-Eintrag über die gemeinsame ActionHistory
|
||||||
|
## (Blöcke spawn_zones + design_notes, SpawnOps.snapshot_blocks).
|
||||||
|
##
|
||||||
|
## Feedback: halbtransparenter Kreis folgt dem Cursor auf dem Terrain
|
||||||
|
## (Vorschau VOR dem Klick — wie der Objekt-Ghost), die gesetzte Zone leuchtet.
|
||||||
|
|
||||||
|
const MODE_HELP := "Linksklick: Zone setzen · Klick+Ziehen: Zone verschieben · Entf: löschen ·\nRadius/Team rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||||
|
|
||||||
|
var main # Hauptfenster (script-erweitert, Variant)
|
||||||
|
var camera: Camera3D
|
||||||
|
var view_builder # Welt-Bauer (script-erweitert, Variant)
|
||||||
|
var interaction # Objekt-Interaktion (für Auswahl-Entkoppelung)
|
||||||
|
|
||||||
|
var active := false
|
||||||
|
var team := 0 # 0 = Blau, 1 = Rot (TDM-Konvention)
|
||||||
|
var zone_radius := SpawnOps.DEFAULT_ZONE_RADIUS
|
||||||
|
|
||||||
|
var selected_team := -1
|
||||||
|
var selected_id := ""
|
||||||
|
|
||||||
|
# Auswähl-Ziehen
|
||||||
|
var _dragging := false
|
||||||
|
var _drag_moved := false
|
||||||
|
var _press_pos := Vector2.ZERO
|
||||||
|
var _grab_offset := Vector3.ZERO
|
||||||
|
|
||||||
|
# Terrain-Cache für Cursor-Raycast (nur LESEN — dieses Werkzeug formt nichts)
|
||||||
|
var _heights := PackedFloat32Array()
|
||||||
|
var _res := 0
|
||||||
|
var _sx := 0.0
|
||||||
|
var _sz := 0.0
|
||||||
|
|
||||||
|
var _cursor_valid := false
|
||||||
|
var _cursor := Vector3.ZERO
|
||||||
|
|
||||||
|
# Ghost-Ring (Radius-Ring) + Fläche als Vorschau
|
||||||
|
var _ghost: Node3D
|
||||||
|
|
||||||
|
|
||||||
|
func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void:
|
||||||
|
main = p_main
|
||||||
|
camera = p_camera
|
||||||
|
view_builder = p_builder
|
||||||
|
interaction = p_interaction
|
||||||
|
_ensure_ghost()
|
||||||
|
|
||||||
|
|
||||||
|
func _ensure_ghost() -> void:
|
||||||
|
if _ghost != null and is_instance_valid(_ghost):
|
||||||
|
return
|
||||||
|
_ghost = Node3D.new()
|
||||||
|
_ghost.visible = false
|
||||||
|
add_child(_ghost)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Aktivierung ---
|
||||||
|
|
||||||
|
func activate() -> bool:
|
||||||
|
active = true
|
||||||
|
selected_team = -1
|
||||||
|
selected_id = ""
|
||||||
|
_dragging = false
|
||||||
|
_cursor_valid = false
|
||||||
|
_refresh_from_doc()
|
||||||
|
if view_builder != null:
|
||||||
|
view_builder.call("set_spawn_selected", -1, "")
|
||||||
|
refresh_selection_panel()
|
||||||
|
return true # nie verweigert — Spawn-Zonen brauchen kein Auflösungs-Limit
|
||||||
|
|
||||||
|
|
||||||
|
func deactivate() -> void:
|
||||||
|
active = false
|
||||||
|
_dragging = false
|
||||||
|
selected_team = -1
|
||||||
|
selected_id = ""
|
||||||
|
if _ghost != null and is_instance_valid(_ghost):
|
||||||
|
_ghost.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
## Nach Undo/Redo/Laden/Neu: Terrain-Cache neu lesen, Auswahl weg.
|
||||||
|
func refresh_from_doc() -> void:
|
||||||
|
_refresh_from_doc()
|
||||||
|
var still_there := false
|
||||||
|
if selected_team >= 0:
|
||||||
|
for p in SpawnOps.points(main.doc, selected_team):
|
||||||
|
if p is Dictionary and str(p.get("id", "")) == selected_id:
|
||||||
|
still_there = true
|
||||||
|
break
|
||||||
|
if not still_there:
|
||||||
|
selected_team = -1
|
||||||
|
selected_id = ""
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_from_doc() -> void:
|
||||||
|
var terrain: Dictionary = main.doc.get("terrain", {})
|
||||||
|
_res = int(terrain.get("resolution", 0))
|
||||||
|
_sx = float(terrain.get("size_x_m", 0.0))
|
||||||
|
_sz = float(terrain.get("size_z_m", 0.0))
|
||||||
|
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
|
||||||
|
_heights = SvmapIO.decode_heights(terrain)
|
||||||
|
else:
|
||||||
|
_heights = PackedFloat32Array()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Werkzeug-UI ---
|
||||||
|
|
||||||
|
func set_team(next_team: int) -> void:
|
||||||
|
team = clampi(next_team, 0, 1)
|
||||||
|
_refresh_ghost_look()
|
||||||
|
if active:
|
||||||
|
main.call("_set_status", "Neue Zonen landen für %s (IDs %s1, %s2, …). Bestehende Zonen anklicken zum Verschieben." % [
|
||||||
|
SpawnOps.team_label(team), SpawnOps.team_prefix(team), SpawnOps.team_prefix(team)])
|
||||||
|
|
||||||
|
|
||||||
|
func set_radius(m: float) -> void:
|
||||||
|
zone_radius = clampf(m, SpawnOps.MIN_ZONE_RADIUS, SpawnOps.MAX_ZONE_RADIUS)
|
||||||
|
_refresh_ghost_look()
|
||||||
|
# Ausgewählte Zone? Dann gilt die Regel SOFORT für sie (mit Undo, merge-gekoppelt).
|
||||||
|
if active and selected_team >= 0 and selected_id != "":
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
|
||||||
|
SpawnOps.set_radius(main.doc, selected_id, zone_radius)
|
||||||
|
main.history.commit("Radius von %s: %d m" % [selected_id, int(zone_radius)],
|
||||||
|
before, SpawnOps.snapshot_blocks(main.history, main.doc),
|
||||||
|
"radius:%s" % selected_id)
|
||||||
|
main.call("_refresh_history_buttons")
|
||||||
|
main.call("_refresh_spawn_status")
|
||||||
|
_refresh_ghost_look()
|
||||||
|
var marker: Node3D = _marker_of(selected_team, selected_id)
|
||||||
|
if marker != null and is_instance_valid(marker):
|
||||||
|
view_builder.call("style_spawn_marker", marker,
|
||||||
|
SpawnOps.zone_radius(main.doc, selected_id), true)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Eingaben ---
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not active:
|
||||||
|
return
|
||||||
|
if event is InputEventMouseMotion:
|
||||||
|
var mm := event as InputEventMouseMotion
|
||||||
|
_update_cursor(mm.position)
|
||||||
|
if _dragging and selected_team >= 0:
|
||||||
|
var hit: Variant = _ray_hit(mm.position)
|
||||||
|
if hit == null:
|
||||||
|
return
|
||||||
|
var pos: Vector3 = _clamped(hit as Vector3) + _grab_offset
|
||||||
|
var half := _terrain_half()
|
||||||
|
pos.x = clampf(pos.x, -half.x + zone_radius, half.x - zone_radius)
|
||||||
|
pos.z = clampf(pos.z, -half.y + zone_radius, half.y - zone_radius)
|
||||||
|
pos.y = SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)
|
||||||
|
_drag_moved = true
|
||||||
|
var marker: Node3D = _marker_of(selected_team, selected_id)
|
||||||
|
if marker != null and is_instance_valid(marker):
|
||||||
|
marker.position = pos
|
||||||
|
return
|
||||||
|
if event is InputEventMouseButton:
|
||||||
|
var mb := event as InputEventMouseButton
|
||||||
|
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
|
||||||
|
_on_left_press(mb.position)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed:
|
||||||
|
_on_left_release()
|
||||||
|
elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
||||||
|
if selected_team >= 0:
|
||||||
|
_clear_selection()
|
||||||
|
main.call("_set_status", "Auswahl aufgehoben — klicke auf den Boden, um eine neue Zone zu malen.")
|
||||||
|
else:
|
||||||
|
main.call("_set_tool_mode", "objects")
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_cursor(viewport_pos: Vector2) -> void:
|
||||||
|
var hit: Variant = _ray_hit(viewport_pos)
|
||||||
|
if hit == null:
|
||||||
|
_cursor_valid = false
|
||||||
|
if _ghost != null and is_instance_valid(_ghost):
|
||||||
|
_ghost.visible = false
|
||||||
|
return
|
||||||
|
_cursor_valid = true
|
||||||
|
_cursor = _clamped(hit as Vector3)
|
||||||
|
if _ghost != null and is_instance_valid(_ghost):
|
||||||
|
_ghost.position = Vector3(_cursor.x, _cursor.y + 0.15, _cursor.z)
|
||||||
|
_ghost.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
## Strahl → Punkt auf dem Terrain (Favorisiert echte Geländeberührung wie
|
||||||
|
## der Terrain-Pinsel; ohne Höhenfeld: Bodenebene).
|
||||||
|
func _ray_hit(viewport_pos: Vector2) -> Variant:
|
||||||
|
if _res >= 2 and _heights.size() >= _res * _res:
|
||||||
|
return TerrainField.raycast(_heights, _res, _sx, _sz,
|
||||||
|
camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos))
|
||||||
|
var terrain: Dictionary = main.doc.get("terrain", {})
|
||||||
|
if terrain.is_empty():
|
||||||
|
return null
|
||||||
|
var plane_y := float(terrain.get("min_height_m", 0.0))
|
||||||
|
var origin := camera.project_ray_origin(viewport_pos)
|
||||||
|
var dir := camera.project_ray_normal(viewport_pos)
|
||||||
|
if absf(dir.y) < 0.0001:
|
||||||
|
return null
|
||||||
|
var t := (plane_y - origin.y) / dir.y
|
||||||
|
if t <= 0.0:
|
||||||
|
return null
|
||||||
|
return origin + dir * t
|
||||||
|
|
||||||
|
|
||||||
|
func _clamped(p: Vector3) -> Vector3:
|
||||||
|
var half := _terrain_half()
|
||||||
|
p.x = clampf(p.x, -half.x, half.x)
|
||||||
|
p.z = clampf(p.z, -half.y, half.y)
|
||||||
|
p.y = SvmapDocument.terrain_height_at(main.doc, p.x, p.z)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
func _terrain_half() -> Vector2:
|
||||||
|
return Vector2(_sx, _sz) * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------- Setzen/Auswählen -----
|
||||||
|
|
||||||
|
func _on_left_press(viewport_pos: Vector2) -> void:
|
||||||
|
var picked := _pick_spawn(viewport_pos)
|
||||||
|
if picked.is_empty():
|
||||||
|
_place_zone(viewport_pos)
|
||||||
|
return
|
||||||
|
# Bestehende Zone anfassen → auswählen + Greifen vorbereiten.
|
||||||
|
selected_team = int(picked["team"])
|
||||||
|
selected_id = str(picked["id"])
|
||||||
|
view_builder.call("set_spawn_selected", selected_team, selected_id)
|
||||||
|
_dragging = true
|
||||||
|
_drag_moved = false
|
||||||
|
_press_pos = viewport_pos
|
||||||
|
_grab_offset = Vector3.ZERO
|
||||||
|
var hit: Variant = _ray_hit(viewport_pos)
|
||||||
|
var marker: Node3D = _marker_of(selected_team, selected_id)
|
||||||
|
if hit != null and marker != null and is_instance_valid(marker):
|
||||||
|
_grab_offset = marker.position - (_clamped(hit as Vector3))
|
||||||
|
_grab_offset.y = 0.0
|
||||||
|
refresh_selection_panel()
|
||||||
|
main.call("_set_status", "%s (%s) ausgewählt — ziehen zum Verschieben, Entf zum Löschen, der Radius-Regel rechts gilt jetzt für SIE." % [
|
||||||
|
selected_id, SpawnOps.team_label(selected_team)])
|
||||||
|
|
||||||
|
|
||||||
|
func _on_left_release() -> void:
|
||||||
|
if not _dragging:
|
||||||
|
return
|
||||||
|
_dragging = false
|
||||||
|
if not (_drag_moved and selected_team >= 0 and selected_id != ""):
|
||||||
|
_drag_moved = false
|
||||||
|
return
|
||||||
|
var marker: Node3D = _marker_of(selected_team, selected_id)
|
||||||
|
if marker == null or not is_instance_valid(marker):
|
||||||
|
_drag_moved = false
|
||||||
|
return
|
||||||
|
var half := _terrain_half()
|
||||||
|
var pos: Vector3 = marker.position
|
||||||
|
pos.x = clampf(pos.x, -half.x, half.x)
|
||||||
|
pos.z = clampf(pos.z, -half.y, half.y)
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
|
||||||
|
SpawnOps.move_point(main.doc, selected_team, selected_id, pos.x, pos.z)
|
||||||
|
main.history.commit("%s verschoben" % selected_id,
|
||||||
|
before, SpawnOps.snapshot_blocks(main.history, main.doc))
|
||||||
|
main.call("_after_structure_change")
|
||||||
|
_point_selection_at_doc()
|
||||||
|
view_builder.call("set_spawn_selected", selected_team, selected_id)
|
||||||
|
main.call("_set_status", "%s verschoben — Strg+Z macht das rückgängig." % selected_id)
|
||||||
|
_drag_moved = false
|
||||||
|
|
||||||
|
|
||||||
|
func _place_zone(viewport_pos: Vector2) -> void:
|
||||||
|
var hit: Variant = _ray_hit(viewport_pos)
|
||||||
|
if hit == null:
|
||||||
|
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
|
||||||
|
return
|
||||||
|
var half := _terrain_half()
|
||||||
|
var pos := _clamped(hit as Vector3)
|
||||||
|
if absf(pos.x) > half.x - zone_radius or absf(pos.z) > half.y - zone_radius:
|
||||||
|
pos.x = clampf(pos.x, -half.x + zone_radius, half.x - zone_radius)
|
||||||
|
pos.z = clampf(pos.z, -half.y + zone_radius, half.y - zone_radius)
|
||||||
|
main.call("_set_status", "Der Kreis würde über den Rand hinausschauen — ich habe ihn ganz nach innen geschoben.")
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
|
||||||
|
var point := SpawnOps.add_zone(main.doc, team, pos.x, pos.z)
|
||||||
|
var pid := str(point["id"])
|
||||||
|
SpawnOps.set_radius(main.doc, pid, zone_radius)
|
||||||
|
main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(team)],
|
||||||
|
before, SpawnOps.snapshot_blocks(main.history, main.doc))
|
||||||
|
main.call("_after_structure_change")
|
||||||
|
# Frische Zone auswählen und anfassen — Radius-Regel gilt ab jetzt für SIE.
|
||||||
|
selected_team = team
|
||||||
|
selected_id = pid
|
||||||
|
view_builder.call("set_spawn_selected", selected_team, selected_id)
|
||||||
|
refresh_selection_panel()
|
||||||
|
main.call("_set_status", "Spawn-Zone %s (%s) gesetzt. Radius: Regler rechts · verschieben: klicken+ziehen · Entf löscht · Strg+Z macht alles rückgängig." % [
|
||||||
|
pid, SpawnOps.team_label(team)])
|
||||||
|
|
||||||
|
|
||||||
|
## Kursor-Strahl → getroffene Zone ({team, id}) oder {}.
|
||||||
|
func _pick_spawn(viewport_pos: Vector2) -> Dictionary:
|
||||||
|
var hit: Variant = _ray_hit(viewport_pos)
|
||||||
|
if hit == null:
|
||||||
|
return {}
|
||||||
|
var p: Vector3 = hit as Vector3
|
||||||
|
var best := {}
|
||||||
|
var best_d := INF
|
||||||
|
for marker in view_builder.spawn_markers:
|
||||||
|
if marker == null or not is_instance_valid(marker):
|
||||||
|
continue
|
||||||
|
var mpos: Vector3 = marker.global_position
|
||||||
|
var d := Vector2(p.x - mpos.x, p.z - mpos.z).length()
|
||||||
|
var pick_r := float(marker.get_meta("spawn_pick_radius", 4.0))
|
||||||
|
if d <= pick_r and d < best_d:
|
||||||
|
best = {"team": int(marker.get_meta("spawn_team", -1)), "id": str(marker.get_meta("spawn_id", ""))}
|
||||||
|
best_d = d
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
func _marker_of(team_i: int, pid: String) -> Node3D:
|
||||||
|
for marker in view_builder.spawn_markers:
|
||||||
|
if marker == null or not is_instance_valid(marker):
|
||||||
|
continue
|
||||||
|
if int(marker.get_meta("spawn_team", -1)) == team_i and str(marker.get_meta("spawn_id", "")) == pid:
|
||||||
|
return marker
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _point_selection_at_doc() -> void:
|
||||||
|
# Nach rebuild: Auswahl-Referenzen im DOC halten (selbes Team/ID-Prinzip).
|
||||||
|
for p in SpawnOps.points(main.doc, selected_team):
|
||||||
|
if p is Dictionary and str(p.get("id", "")) == selected_id:
|
||||||
|
return
|
||||||
|
selected_team = -1
|
||||||
|
selected_id = ""
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_selection() -> void:
|
||||||
|
selected_team = -1
|
||||||
|
selected_id = ""
|
||||||
|
if _dragging:
|
||||||
|
_dragging = false
|
||||||
|
view_builder.call("set_spawn_selected", -1, "")
|
||||||
|
refresh_selection_panel()
|
||||||
|
|
||||||
|
|
||||||
|
func delete_selected() -> void:
|
||||||
|
if selected_team < 0 or selected_id == "":
|
||||||
|
main.call("_set_status", "Nichts ausgewählt — klicke zuerst eine Spawn-Zone an, die weg soll.", true)
|
||||||
|
return
|
||||||
|
var label := "%s (%s)" % [selected_id, SpawnOps.team_label(selected_team)]
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
|
||||||
|
if not SpawnOps.remove_point(main.doc, selected_team, selected_id):
|
||||||
|
_clear_selection()
|
||||||
|
return
|
||||||
|
main.history.commit("%s löschen" % label, before,
|
||||||
|
SpawnOps.snapshot_blocks(main.history, main.doc))
|
||||||
|
_clear_selection()
|
||||||
|
main.call("_after_structure_change")
|
||||||
|
main.call("_set_status", "Spawn-Zone %s gelöscht — Strg+Z holt sie zurück. %s braucht mindestens %d Zonen!" % [
|
||||||
|
label, SpawnOps.team_label(team), SvmapIO.MIN_SPAWN_POINTS_PER_TEAM])
|
||||||
|
|
||||||
|
|
||||||
|
## Panel-Zeile rechts (Welche Zone ist an?) synchron halten — ohne Statuszeile anzufassen.
|
||||||
|
func refresh_selection_panel() -> void:
|
||||||
|
if main.has_method("_sync_spawn_selection"):
|
||||||
|
main.call("_sync_spawn_selection")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Ghost-Optik ---
|
||||||
|
|
||||||
|
func _refresh_ghost_look() -> void:
|
||||||
|
if _ghost == null or not is_instance_valid(_ghost):
|
||||||
|
return
|
||||||
|
# Kreisfläche: flacher Cylinder (Zonen-Scheibe), teamfarbig halbtransparent.
|
||||||
|
for child in _ghost.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
var disc := MeshInstance3D.new()
|
||||||
|
var cyl := CylinderMesh.new()
|
||||||
|
cyl.top_radius = zone_radius
|
||||||
|
cyl.bottom_radius = zone_radius
|
||||||
|
cyl.height = 0.25
|
||||||
|
disc.mesh = cyl
|
||||||
|
disc.position.y = 0.12
|
||||||
|
var col: Color = view_builder.TEAM_COLORS[team]
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color(col.r, col.g, col.b, 0.4)
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
mat.emission_enabled = true
|
||||||
|
mat.emission = col * 0.35
|
||||||
|
disc.material_override = mat
|
||||||
|
_ghost.add_child(disc)
|
||||||
|
var ring := MeshInstance3D.new()
|
||||||
|
var torus := TorusMesh.new()
|
||||||
|
torus.inner_radius = zone_radius - 0.2
|
||||||
|
torus.outer_radius = zone_radius + 0.2
|
||||||
|
torus.rings = 48
|
||||||
|
torus.ring_segments = 6
|
||||||
|
ring.mesh = torus
|
||||||
|
ring.position.y = 0.05
|
||||||
|
ring.material_override = mat
|
||||||
|
_ghost.add_child(ring)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Demo/Screenshot ----
|
||||||
|
|
||||||
|
## Demo-Hook (Desktop-Smoke): Zone programmatisch setzen (inkl. History).
|
||||||
|
## Macht exakt, was ein Klick machen würde — ohne echte Maus-Ereignisse.
|
||||||
|
func demo_place(zone_team: int, x: float, z: float, radius: float) -> String:
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
|
||||||
|
var point := SpawnOps.add_zone(main.doc, zone_team, x, z)
|
||||||
|
var pid := str(point["id"])
|
||||||
|
if radius > 0.0:
|
||||||
|
SpawnOps.set_radius(main.doc, pid, radius)
|
||||||
|
main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(zone_team)],
|
||||||
|
before, SpawnOps.snapshot_blocks(main.history, main.doc))
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
## Demo-Hook: Zone programmatisch verschieben (inkl. History).
|
||||||
|
func demo_move(zone_team: int, pid: String, x: float, z: float) -> void:
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
|
||||||
|
SpawnOps.move_point(main.doc, zone_team, pid, x, z)
|
||||||
|
main.history.commit("%s verschoben" % pid,
|
||||||
|
before, SpawnOps.snapshot_blocks(main.history, main.doc))
|
||||||
|
|
||||||
|
|
||||||
|
## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus).
|
||||||
|
func demo_park_ghost(x: float, z: float, team_i: int, radius: float) -> void:
|
||||||
|
set_team(team_i)
|
||||||
|
if radius > 0.0:
|
||||||
|
set_radius_demo_only(radius)
|
||||||
|
_refresh_ghost_look()
|
||||||
|
_cursor = Vector3(x, SvmapDocument.terrain_height_at(main.doc, x, z), z)
|
||||||
|
_cursor_valid = true
|
||||||
|
_ghost.position = Vector3(_cursor.x, _cursor.y + 0.15, _cursor.z)
|
||||||
|
_ghost.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
## Nur-Editor-Radius (kein Doc-Eintrag, kein Undo) für die Vorschau im Demo-Hook.
|
||||||
|
func set_radius_demo_only(m: float) -> void:
|
||||||
|
zone_radius = clampf(m, SpawnOps.MIN_ZONE_RADIUS, SpawnOps.MAX_ZONE_RADIUS)
|
||||||
1
src/editor/spawn_tool.gd.uid
Normal file
1
src/editor/spawn_tool.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dcr747fch22k
|
||||||
337
src/ui/main.gd
337
src/ui/main.gd
|
|
@ -26,7 +26,8 @@ var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeug
|
||||||
var history := ActionHistory.new()
|
var history := ActionHistory.new()
|
||||||
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
|
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
|
||||||
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1)
|
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1)
|
||||||
var tool_mode := "objects" # "objects" = Platzieren/Auswahl, "terrain" = Höhenfeld-Pinsel
|
var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d)
|
||||||
|
var tool_mode := "objects" # "objects" | "terrain" | "spawns" (Spawn-Zonen)
|
||||||
|
|
||||||
# UI-Referenzen
|
# UI-Referenzen
|
||||||
var content_tree: Tree
|
var content_tree: Tree
|
||||||
|
|
@ -66,6 +67,22 @@ var terrain_radius_label: Label
|
||||||
var terrain_strength_slider: HSlider
|
var terrain_strength_slider: HSlider
|
||||||
var terrain_strength_label: Label
|
var terrain_strength_label: Label
|
||||||
|
|
||||||
|
# Spawn-Zonen-Werkzeug (Karte t_c4162c5d) — großer deutscher Button + Panel
|
||||||
|
var spawn_edit_btn: Button
|
||||||
|
var spawn_panel: VBoxContainer
|
||||||
|
var spawn_team_blue: Button
|
||||||
|
var spawn_team_red: Button
|
||||||
|
var spawn_radius_slider: HSlider
|
||||||
|
var spawn_radius_label: Label
|
||||||
|
var spawn_status: Label # Live-Validierung (IMMER sichtbar, Loader-Regeln)
|
||||||
|
var spawn_sel_label: Label # Welche Zone ist an?
|
||||||
|
var spawn_delete_btn: Button
|
||||||
|
|
||||||
|
# Map-Check (Karte prüfen)
|
||||||
|
var check_btn: Button
|
||||||
|
var check_dialog: AcceptDialog
|
||||||
|
var check_text: Label
|
||||||
|
|
||||||
# Dialoge
|
# Dialoge
|
||||||
var new_dialog: AcceptDialog
|
var new_dialog: AcceptDialog
|
||||||
var open_dialog: FileDialog
|
var open_dialog: FileDialog
|
||||||
|
|
@ -140,6 +157,14 @@ func _build_ui() -> void:
|
||||||
redo_btn.pressed.connect(_do_redo)
|
redo_btn.pressed.connect(_do_redo)
|
||||||
top_h.add_child(redo_btn)
|
top_h.add_child(redo_btn)
|
||||||
|
|
||||||
|
# Map-Fertig-Check (Karte t_c4162c5d): Klartext-Liste VOR dem Export.
|
||||||
|
check_btn = Button.new()
|
||||||
|
check_btn.text = "✅ Karte prüfen"
|
||||||
|
check_btn.custom_minimum_size = Vector2(170, 40)
|
||||||
|
check_btn.tooltip_text = "Prüft, ob die Map bereit für den Export ist — sagen wir VORHER, was fehlt"
|
||||||
|
check_btn.pressed.connect(_on_check_map)
|
||||||
|
top_h.add_child(check_btn)
|
||||||
|
|
||||||
var title_label := Label.new()
|
var title_label := Label.new()
|
||||||
title_label.text = " %s" % APP_TITLE
|
title_label.text = " %s" % APP_TITLE
|
||||||
title_label.add_theme_font_size_override("font_size", 20)
|
title_label.add_theme_font_size_override("font_size", 20)
|
||||||
|
|
@ -223,6 +248,16 @@ func _build_ui() -> void:
|
||||||
error_dialog.add_child(error_text)
|
error_dialog.add_child(error_text)
|
||||||
add_child(error_dialog)
|
add_child(error_dialog)
|
||||||
|
|
||||||
|
# Map-Check-Dialog (Karte prüfen, Karte t_c4162c5d).
|
||||||
|
check_dialog = AcceptDialog.new()
|
||||||
|
check_dialog.title = "Map-Check"
|
||||||
|
check_dialog.ok_button_text = "Danke!"
|
||||||
|
check_text = Label.new()
|
||||||
|
check_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
check_text.custom_minimum_size = Vector2(620, 0)
|
||||||
|
check_dialog.add_child(check_text)
|
||||||
|
add_child(check_dialog)
|
||||||
|
|
||||||
|
|
||||||
func _build_left_panel() -> PanelContainer:
|
func _build_left_panel() -> PanelContainer:
|
||||||
var panel := PanelContainer.new()
|
var panel := PanelContainer.new()
|
||||||
|
|
@ -353,6 +388,12 @@ func _build_center_viewport() -> SubViewportContainer:
|
||||||
world.add_child(terrain_tool)
|
world.add_child(terrain_tool)
|
||||||
terrain_tool.setup(self, camera, view_builder, interaction)
|
terrain_tool.setup(self, camera, view_builder, interaction)
|
||||||
|
|
||||||
|
# Spawn-Zonen-Werkzeug (Karte t_c4162c5d) — ditto eigener Eingabe-Knoten.
|
||||||
|
spawn_tool = Node3D.new()
|
||||||
|
spawn_tool.set_script(load("res://src/editor/spawn_tool.gd"))
|
||||||
|
world.add_child(spawn_tool)
|
||||||
|
spawn_tool.setup(self, camera, view_builder, interaction)
|
||||||
|
|
||||||
# Bedien-Hinweis im Viewport (Overlay)
|
# Bedien-Hinweis im Viewport (Overlay)
|
||||||
help_label = Label.new()
|
help_label = Label.new()
|
||||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
||||||
|
|
@ -494,6 +535,67 @@ func _build_right_panel() -> PanelContainer:
|
||||||
"Linksklick-Ziehen formt den Boden, Rechtsklick-Ziehen macht das Gegenteil. "
|
"Linksklick-Ziehen formt den Boden, Rechtsklick-Ziehen macht das Gegenteil. "
|
||||||
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus."))
|
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus."))
|
||||||
|
|
||||||
|
# --- Spawn-Zonen (Karte t_c4162c5d): Live-Status IMMER sichtbar ---
|
||||||
|
vbox.add_child(_separator())
|
||||||
|
vbox.add_child(_header_label("Spawn-Check"))
|
||||||
|
spawn_status = Label.new()
|
||||||
|
spawn_status.text = ""
|
||||||
|
spawn_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
spawn_status.custom_minimum_size = Vector2(0, 44)
|
||||||
|
vbox.add_child(spawn_status)
|
||||||
|
vbox.add_child(_muted_label("Der Check sagt VOR dem Export, was der Server-Loader verlangen wird (mindestens 4 Zonen pro Team, bekannte Objekt-IDs)."))
|
||||||
|
|
||||||
|
# --- Spawn-Zonen-Werkzeug: großer Button + Bedien-Panel ---
|
||||||
|
spawn_edit_btn = Button.new()
|
||||||
|
spawn_edit_btn.text = "🚩 Spawn-Zonen malen"
|
||||||
|
spawn_edit_btn.custom_minimum_size = Vector2(0, 48)
|
||||||
|
spawn_edit_btn.toggle_mode = true
|
||||||
|
spawn_edit_btn.tooltip_text = "Startzonen für Team Blau und Rot auf den Boden malen (TDM)"
|
||||||
|
spawn_edit_btn.pressed.connect(_on_spawn_btn_pressed)
|
||||||
|
vbox.add_child(spawn_edit_btn)
|
||||||
|
|
||||||
|
spawn_panel = VBoxContainer.new()
|
||||||
|
spawn_panel.add_theme_constant_override("separation", 6)
|
||||||
|
spawn_panel.visible = false
|
||||||
|
vbox.add_child(spawn_panel)
|
||||||
|
|
||||||
|
spawn_panel.add_child(_field_label("Team:"))
|
||||||
|
var team_row := HBoxContainer.new()
|
||||||
|
team_row.add_theme_constant_override("separation", 6)
|
||||||
|
spawn_panel.add_child(team_row)
|
||||||
|
var team_group := ButtonGroup.new()
|
||||||
|
spawn_team_blue = _spawn_team_button("🔵 Team Blau", team_group, 0)
|
||||||
|
spawn_team_red = _spawn_team_button("🔴 Team Rot", team_group, 1)
|
||||||
|
spawn_team_blue.button_pressed = true
|
||||||
|
team_row.add_child(spawn_team_blue)
|
||||||
|
team_row.add_child(spawn_team_red)
|
||||||
|
|
||||||
|
spawn_radius_label = _field_label("Zonen-Radius: 6 m")
|
||||||
|
spawn_panel.add_child(spawn_radius_label)
|
||||||
|
spawn_radius_slider = HSlider.new()
|
||||||
|
spawn_radius_slider.min_value = SpawnOps.MIN_ZONE_RADIUS
|
||||||
|
spawn_radius_slider.max_value = SpawnOps.MAX_ZONE_RADIUS
|
||||||
|
spawn_radius_slider.step = 1.0
|
||||||
|
spawn_radius_slider.value = SpawnOps.DEFAULT_ZONE_RADIUS
|
||||||
|
spawn_radius_slider.custom_minimum_size = Vector2(0, 32)
|
||||||
|
spawn_radius_slider.value_changed.connect(_on_spawn_radius_changed)
|
||||||
|
spawn_panel.add_child(spawn_radius_slider)
|
||||||
|
|
||||||
|
spawn_sel_label = Label.new()
|
||||||
|
spawn_sel_label.text = "Keine Zone ausgewählt."
|
||||||
|
spawn_sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
spawn_panel.add_child(spawn_sel_label)
|
||||||
|
spawn_delete_btn = Button.new()
|
||||||
|
spawn_delete_btn.text = "Zone löschen (Entf)"
|
||||||
|
spawn_delete_btn.custom_minimum_size = Vector2(0, 40)
|
||||||
|
spawn_delete_btn.tooltip_text = "Die ausgewählte Spawn-Zone löschen (Strg+Z holt sie zurück)"
|
||||||
|
spawn_delete_btn.pressed.connect(_on_spawn_delete_pressed)
|
||||||
|
spawn_panel.add_child(spawn_delete_btn)
|
||||||
|
|
||||||
|
spawn_panel.add_child(_muted_label(
|
||||||
|
"Klick setzt eine Zone, Klick+Ziehen verschiebt sie. Jede Zone ist "
|
||||||
|
+ "EIN Rückgängig-Schritt (Strg+Z). ESC beendet das Spawn-Werkzeug."))
|
||||||
|
|
||||||
# Signale (Änderungen → Dokument + Ansicht)
|
# Signale (Änderungen → Dokument + Ansicht)
|
||||||
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
|
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
|
||||||
prop_author.text_changed.connect(_on_prop_changed.bind("author"))
|
prop_author.text_changed.connect(_on_prop_changed.bind("author"))
|
||||||
|
|
@ -690,6 +792,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
_set_tool_mode("objects")
|
_set_tool_mode("objects")
|
||||||
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
|
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
elif tool_mode == "spawns":
|
||||||
|
_set_tool_mode("objects")
|
||||||
|
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
elif interaction.placing:
|
elif interaction.placing:
|
||||||
interaction.cancel_placement()
|
interaction.cancel_placement()
|
||||||
_set_status("Platzieren abgebrochen.")
|
_set_status("Platzieren abgebrochen.")
|
||||||
|
|
@ -701,14 +807,17 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
if not ctrl:
|
if not ctrl:
|
||||||
if interaction.placing:
|
if interaction.placing:
|
||||||
return
|
return
|
||||||
interaction.delete_selected()
|
if tool_mode == "spawns":
|
||||||
|
spawn_tool.call("delete_selected")
|
||||||
|
else:
|
||||||
|
interaction.delete_selected()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------- Bibliothek ------
|
# --------------------------------------------------------- Bibliothek ------
|
||||||
|
|
||||||
func _on_library_pressed(oid: String) -> void:
|
func _on_library_pressed(oid: String) -> void:
|
||||||
if tool_mode == "terrain":
|
if tool_mode != "objects":
|
||||||
_set_tool_mode("objects") # Bibliothek-Klick wechselt automatisch zurück
|
_set_tool_mode("objects") # Bibliothek-Klick wechselt automatisch zurück
|
||||||
if interaction.placing and interaction.place_oid == oid:
|
if interaction.placing and interaction.place_oid == oid:
|
||||||
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
|
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
|
||||||
|
|
@ -784,31 +893,74 @@ func _on_terrain_btn_pressed() -> void:
|
||||||
_set_tool_mode("objects")
|
_set_tool_mode("objects")
|
||||||
|
|
||||||
|
|
||||||
## Werkzeug-Modus wechseln: "objects" (Platzieren/Auswahl) ↔ "terrain" (Pinsel).
|
## Werkzeug-Modus wechseln: "objects" (Platzieren/Auswahl), "terrain" (Pinsel),
|
||||||
|
## "spawns" (Spawn-Zonen malen).
|
||||||
func _set_tool_mode(mode: String) -> void:
|
func _set_tool_mode(mode: String) -> void:
|
||||||
if mode == tool_mode:
|
if mode == tool_mode:
|
||||||
_sync_terrain_button()
|
_sync_terrain_button()
|
||||||
|
_sync_spawn_button()
|
||||||
return
|
return
|
||||||
if mode == "terrain":
|
if mode == "terrain":
|
||||||
interaction.call("cancel_placement")
|
_leave_spawn_mode()
|
||||||
interaction.call("clear_selection", true)
|
_enter_terrain_mode()
|
||||||
if not terrain_tool.call("activate"):
|
elif mode == "spawns":
|
||||||
# Abgelehnt (z. B. Auflösungs-Limit) — Button zurück, Meldung steht.
|
_leave_terrain_mode()
|
||||||
_sync_terrain_button()
|
_enter_spawn_mode()
|
||||||
return
|
|
||||||
tool_mode = "terrain"
|
|
||||||
terrain_panel.visible = true
|
|
||||||
help_label.text = "Linksklick-Ziehen: Boden formen · Rechtsklick-Ziehen: Gegenteil ·\nPinsel-Größe/Stärke rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg"
|
|
||||||
terrain_edit_btn.button_pressed = true
|
|
||||||
terrain_tool.call("set_brush_mode", _selected_terrain_mode())
|
|
||||||
_set_status("Terrain-Modus an: Ziehe mit LINKSKLICK über die Karte, um den Boden zu formen. Die Regler rechts stellen Pinsel und Stärke ein.")
|
|
||||||
else:
|
else:
|
||||||
terrain_tool.call("deactivate")
|
_leave_terrain_mode()
|
||||||
tool_mode = "objects"
|
_leave_spawn_mode()
|
||||||
terrain_panel.visible = false
|
_enter_objects_mode()
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_terrain_mode() -> void:
|
||||||
|
interaction.call("cancel_placement")
|
||||||
|
interaction.call("clear_selection", true)
|
||||||
|
if not terrain_tool.call("activate"):
|
||||||
|
# Abgelehnt (z. B. Auflösungs-Limit) — Button zurück, Meldung steht.
|
||||||
_sync_terrain_button()
|
_sync_terrain_button()
|
||||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
return
|
||||||
_base_tool_status()
|
tool_mode = "terrain"
|
||||||
|
terrain_panel.visible = true
|
||||||
|
help_label.text = "Linksklick-Ziehen: Boden formen · Rechtsklick-Ziehen: Gegenteil ·\nPinsel-Größe/Stärke rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg"
|
||||||
|
terrain_edit_btn.button_pressed = true
|
||||||
|
terrain_tool.call("set_brush_mode", _selected_terrain_mode())
|
||||||
|
_set_status("Terrain-Modus an: Ziehe mit LINKSKLICK über die Karte, um den Boden zu formen. Die Regler rechts stellen Pinsel und Stärke ein.")
|
||||||
|
|
||||||
|
|
||||||
|
func _leave_terrain_mode() -> void:
|
||||||
|
if terrain_tool != null:
|
||||||
|
terrain_tool.call("deactivate")
|
||||||
|
if terrain_panel != null:
|
||||||
|
terrain_panel.visible = false
|
||||||
|
_sync_terrain_button()
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_spawn_mode() -> void:
|
||||||
|
interaction.call("cancel_placement")
|
||||||
|
interaction.call("clear_selection", true)
|
||||||
|
tool_mode = "spawns"
|
||||||
|
spawn_panel.visible = true
|
||||||
|
help_label.text = "Linksklick: Zone setzen · Klick+Ziehen: Zone verschieben · Entf: löschen ·\nTeam/Radius rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||||
|
spawn_edit_btn.button_pressed = true
|
||||||
|
spawn_tool.call("activate")
|
||||||
|
spawn_tool.call("set_team", _selected_spawn_team())
|
||||||
|
spawn_tool.call("set_radius", float(spawn_radius_slider.value))
|
||||||
|
_refresh_spawn_status()
|
||||||
|
_set_status("Spawn-Modus an: Klicke auf den Boden, um Zonen für %s zu malen. Die Kreis-Vorschau folgt der Maus." % SpawnOps.team_label(_selected_spawn_team()))
|
||||||
|
|
||||||
|
|
||||||
|
func _leave_spawn_mode() -> void:
|
||||||
|
if spawn_tool != null:
|
||||||
|
spawn_tool.call("deactivate")
|
||||||
|
if spawn_panel != null:
|
||||||
|
spawn_panel.visible = false
|
||||||
|
_sync_spawn_button()
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_objects_mode() -> void:
|
||||||
|
tool_mode = "objects"
|
||||||
|
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
||||||
|
_base_tool_status()
|
||||||
|
|
||||||
|
|
||||||
func _selected_terrain_mode() -> int:
|
func _selected_terrain_mode() -> int:
|
||||||
|
|
@ -866,6 +1018,98 @@ func _recheck_terrain_mode() -> void:
|
||||||
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
|
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------- Spawn-Zonen-Werkzeug ---
|
||||||
|
# (Karte t_c4162c5d — Spawn-Zonen malen, Live-Check, Map-Fertig-Check)
|
||||||
|
|
||||||
|
func _spawn_team_button(text: String, group: ButtonGroup, team: int) -> Button:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = text
|
||||||
|
btn.toggle_mode = true
|
||||||
|
btn.button_group = group
|
||||||
|
btn.custom_minimum_size = Vector2(0, 40)
|
||||||
|
btn.pressed.connect(_on_spawn_team_changed.bind(team))
|
||||||
|
return btn
|
||||||
|
|
||||||
|
|
||||||
|
func _on_spawn_btn_pressed() -> void:
|
||||||
|
if spawn_edit_btn.button_pressed:
|
||||||
|
_set_tool_mode("spawns")
|
||||||
|
else:
|
||||||
|
_set_tool_mode("objects")
|
||||||
|
|
||||||
|
|
||||||
|
func _selected_spawn_team() -> int:
|
||||||
|
return 1 if spawn_team_red.button_pressed else 0
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_spawn_button() -> void:
|
||||||
|
spawn_edit_btn.button_pressed = tool_mode == "spawns"
|
||||||
|
|
||||||
|
|
||||||
|
func _on_spawn_team_changed(team: int) -> void:
|
||||||
|
if spawn_tool != null and tool_mode == "spawns":
|
||||||
|
spawn_tool.call("set_team", team)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_spawn_radius_changed(value: float) -> void:
|
||||||
|
spawn_radius_label.text = "Zonen-Radius: %d m" % int(value)
|
||||||
|
if spawn_tool != null and tool_mode == "spawns":
|
||||||
|
spawn_tool.call("set_radius", value)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_spawn_delete_pressed() -> void:
|
||||||
|
if tool_mode == "spawns":
|
||||||
|
spawn_tool.call("delete_selected")
|
||||||
|
|
||||||
|
|
||||||
|
## Panel-Zeile „Welche Zone ist angewählt?“ — WIRD vom Spawn-Tool gerufen.
|
||||||
|
func _sync_spawn_selection() -> void:
|
||||||
|
if spawn_tool == null:
|
||||||
|
return
|
||||||
|
var team: int = spawn_tool.get("selected_team")
|
||||||
|
var pid: String = str(spawn_tool.get("selected_id"))
|
||||||
|
if team < 0 or pid == "":
|
||||||
|
spawn_sel_label.text = "Keine Zone ausgewählt.\nKlicke im Viewport auf einen Kreis."
|
||||||
|
spawn_delete_btn.disabled = true
|
||||||
|
return
|
||||||
|
spawn_sel_label.text = "Ausgewählt: %s (%s)\nVerschieben: klicken+ziehen · Radius: Regler oben" % [
|
||||||
|
pid, SpawnOps.team_label(team)]
|
||||||
|
spawn_delete_btn.disabled = false
|
||||||
|
|
||||||
|
|
||||||
|
## LIVE-Validierung (Loader-Regeln) — immer sichtbar, nach JEDEM Struktur-Wechsel.
|
||||||
|
func _refresh_spawn_status() -> void:
|
||||||
|
if doc.is_empty() or spawn_status == null:
|
||||||
|
return
|
||||||
|
spawn_status.text = SpawnOps.live_status(doc, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
## Map-Fertig-Check (Export-Button-Aktion): Klartext-Liste + Shake, wenn fehlt.
|
||||||
|
func _on_check_map() -> void:
|
||||||
|
var report := SpawnOps.ready_report(doc, manifest)
|
||||||
|
var lines := ""
|
||||||
|
for line in report["lines"]:
|
||||||
|
lines += "%s\n" % str(line["text"])
|
||||||
|
var hint := str(line.get("hint", ""))
|
||||||
|
if hint != "":
|
||||||
|
lines += " → %s\n" % hint
|
||||||
|
check_text.text = lines
|
||||||
|
check_dialog.popup_centered()
|
||||||
|
if report["ok"]:
|
||||||
|
_set_status("✅ Karte prüfen: Alles bereit zum Export! Speichern erzeugt die signierte .svmap-Datei.")
|
||||||
|
else:
|
||||||
|
_shake_button(check_btn)
|
||||||
|
_set_status("Map ist noch nicht bereit — siehe Warnungen im Map-Check-Fenster.", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Kurzes Wackeln des Buttons — AUFMERKSAMKEIT statt Fehlerbombe (Chef-Grundsatz).
|
||||||
|
func _shake_button(btn: Control) -> void:
|
||||||
|
var orig: Vector2 = btn.position
|
||||||
|
var tween := create_tween()
|
||||||
|
for offset in [-9.0, 9.0, -6.0, 6.0, -3.0, 0.0]:
|
||||||
|
tween.tween_property(btn, "position", orig + Vector2(offset, 0.0), 0.045)
|
||||||
|
|
||||||
|
|
||||||
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
|
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
|
||||||
func _on_tree_selected() -> void:
|
func _on_tree_selected() -> void:
|
||||||
if _tree_select_lock:
|
if _tree_select_lock:
|
||||||
|
|
@ -979,11 +1223,14 @@ func _after_structure_change() -> void:
|
||||||
_refresh_file_label()
|
_refresh_file_label()
|
||||||
_refresh_stats()
|
_refresh_stats()
|
||||||
_refresh_history_buttons()
|
_refresh_history_buttons()
|
||||||
|
_refresh_spawn_status()
|
||||||
if interaction != null:
|
if interaction != null:
|
||||||
interaction.call("clear_selection", true)
|
interaction.call("clear_selection", true)
|
||||||
view_builder.call("rebuild", doc, manifest)
|
view_builder.call("rebuild", doc, manifest)
|
||||||
_refresh_tree()
|
_refresh_tree()
|
||||||
_recheck_terrain_mode()
|
_recheck_terrain_mode()
|
||||||
|
if tool_mode == "spawns" and spawn_tool != null:
|
||||||
|
spawn_tool.call("refresh_from_doc")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- Anzeige ---
|
# --------------------------------------------------------------- Anzeige ---
|
||||||
|
|
@ -1012,6 +1259,7 @@ func _refresh_all() -> void:
|
||||||
_refresh_file_label()
|
_refresh_file_label()
|
||||||
_refresh_history_buttons()
|
_refresh_history_buttons()
|
||||||
_refresh_viewport()
|
_refresh_viewport()
|
||||||
|
_refresh_spawn_status()
|
||||||
_recheck_terrain_mode()
|
_recheck_terrain_mode()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1109,6 +1357,9 @@ func _maybe_start_demo_mode() -> void:
|
||||||
if args.has("--library-demo"):
|
if args.has("--library-demo"):
|
||||||
_start_library_demo(args)
|
_start_library_demo(args)
|
||||||
return
|
return
|
||||||
|
if args.has("--spawns-demo"):
|
||||||
|
_start_spawns_demo(args)
|
||||||
|
return
|
||||||
if not args.has("--ghost-demo"):
|
if not args.has("--ghost-demo"):
|
||||||
return
|
return
|
||||||
var oid := "crate_wood"
|
var oid := "crate_wood"
|
||||||
|
|
@ -1271,4 +1522,48 @@ func _start_library_demo(args: PackedStringArray) -> void:
|
||||||
await _demo_shot(out_dir + "/library_after_snap.png")
|
await _demo_shot(out_dir + "/library_after_snap.png")
|
||||||
print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir)
|
print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir)
|
||||||
if args.has("--library-demo-quit"):
|
if args.has("--library-demo-quit"):
|
||||||
|
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
|
||||||
|
|
||||||
|
|
||||||
|
## `godot ... -- --spawns-demo [ordner]` — Desktop-Smoke (Karte t_c4162c5d):
|
||||||
|
## Spawn-Zonen-Workflow programmatisch durchspielen, PNGs speichern:
|
||||||
|
## Zonen-Übersicht → Live-Check schlägt Alarm bei fehlenden Zonen → Undo rettet →
|
||||||
|
## Map-Check-Dialog meldet bereit. Die App bleibt offen (Sly sieht das live).
|
||||||
|
func _start_spawns_demo(args: PackedStringArray) -> void:
|
||||||
|
var out_dir := "/tmp/scopedvr_spawns"
|
||||||
|
var idx := args.find("--spawns-demo")
|
||||||
|
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
|
||||||
|
out_dir = args[idx + 1]
|
||||||
|
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||||
|
|
||||||
|
# 1) Übersicht: Default-Zonen beider Teams (Kreise) + rote Ghost-Vorschau.
|
||||||
|
_set_tool_mode("spawns")
|
||||||
|
spawn_team_red.button_pressed = true
|
||||||
|
spawn_tool.call("demo_park_ghost", 260.0, 120.0, 1, 12.0)
|
||||||
|
_demo_camera(Vector3(160.0, 140.0, 0.0), 700.0, 48.0, -90.0)
|
||||||
|
_set_status("Demo 1/4: Spawn-Zonen — blaue Kreise = Team Blau, rote = Team Rot, halbtransparent folgt der Ghost der Maus als Vorschau.")
|
||||||
|
await _demo_shot(out_dir + "/spawns_zones.png")
|
||||||
|
|
||||||
|
# 2) ALLE roten Zonen löschen → Live-Check springt auf ❌ (BEVOR exportiert wird).
|
||||||
|
_clear_edit_state()
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(history, doc)
|
||||||
|
for p in SpawnOps.points(doc, 1).duplicate():
|
||||||
|
SpawnOps.remove_point(doc, 1, str(p["id"]))
|
||||||
|
history.commit("Demo: alle roten Zonen gelöscht", before,
|
||||||
|
SpawnOps.snapshot_blocks(history, doc))
|
||||||
|
_after_structure_change()
|
||||||
|
_set_status("Demo 2/4: ALLE roten Zonen weg — der Spawn-Check (rechts) zeigt sofort ❌: Export wäre blockiert.")
|
||||||
|
await _demo_shot(out_dir + "/spawns_check.png")
|
||||||
|
|
||||||
|
# 3) Strg+Z — alle vier roten Zonen sind zurück (EIN Undo-Schritt).
|
||||||
|
_do_undo()
|
||||||
|
_set_status("Demo 3/4: Strg+Z — alle 4 roten Zonen sind auf EINMAL zurück.")
|
||||||
|
await _demo_shot(out_dir + "/spawns_undo.png")
|
||||||
|
|
||||||
|
# 4) Karte prüfen: Klartext-Liste im Dialog + Status „Alles bereit“.
|
||||||
|
_on_check_map()
|
||||||
|
_set_status("Demo 4/4: „Karte prüfen“ — alles ✅ … beim Speichern wird die .svmap automatisch signiert (sha256).")
|
||||||
|
await _demo_shot(out_dir + "/spawns_ready.png")
|
||||||
|
print("SPAWNS_DEMO_OK: 4 Screenshots in %s" % out_dir)
|
||||||
|
if args.has("--spawns-demo-quit"):
|
||||||
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
|
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
|
||||||
|
|
@ -23,6 +23,7 @@ const WEATHER_LIGHT := {
|
||||||
|
|
||||||
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
|
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
|
||||||
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
|
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
|
||||||
|
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
|
||||||
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
|
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
|
||||||
|
|
||||||
var _base_mat: StandardMaterial3D
|
var _base_mat: StandardMaterial3D
|
||||||
|
|
@ -38,6 +39,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
object_markers = []
|
object_markers = []
|
||||||
|
spawn_markers = []
|
||||||
_base_mat = StandardMaterial3D.new()
|
_base_mat = StandardMaterial3D.new()
|
||||||
_base_mat.albedo_color = OBJECT_COLOR
|
_base_mat.albedo_color = OBJECT_COLOR
|
||||||
_base_mat.roughness = 0.8
|
_base_mat.roughness = 0.8
|
||||||
|
|
@ -60,7 +62,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
||||||
_build_terrain(data)
|
_build_terrain(data)
|
||||||
_build_grid(size_x, size_z, ground_y)
|
_build_grid(size_x, size_z, ground_y)
|
||||||
_build_objects(data.get("objects", []), manifest)
|
_build_objects(data.get("objects", []), manifest)
|
||||||
_build_spawns(data.get("spawn_zones", {}))
|
_rebuild_spawn_markers(data)
|
||||||
|
|
||||||
|
|
||||||
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
|
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
|
||||||
|
|
@ -205,37 +207,111 @@ func set_selected(idx: int) -> void:
|
||||||
mi.material_override = _select_mat if int(marker.get_meta("obj_index", -1)) == idx else _base_mat
|
mi.material_override = _select_mat if int(marker.get_meta("obj_index", -1)) == idx else _base_mat
|
||||||
|
|
||||||
|
|
||||||
## Spawn-Marker: halbtransparente Säule + Label3D mit der Punkt-ID, teamfarbig.
|
## Spawn-Zonen (Karte t_c4162c5d): JEDE Zone ist ein flacher, teamfarbiger
|
||||||
func _build_spawns(zones: Dictionary) -> void:
|
## Kreis auf dem Boden (Radius = design_notes.spawn_radius_m, Editor-eigen)
|
||||||
|
## + Label3D mit der Punkt-ID. Marker-Root bekommt Metas für das Spawn-Tool
|
||||||
|
## (spawn_team, spawn_id, spawn_radius → Anfassen/Regel).
|
||||||
|
func _rebuild_spawn_markers(data: Dictionary) -> void:
|
||||||
|
for marker in spawn_markers:
|
||||||
|
if marker != null and is_instance_valid(marker):
|
||||||
|
marker.queue_free()
|
||||||
|
spawn_markers = []
|
||||||
|
var zones = data.get("spawn_zones", {})
|
||||||
|
if not (zones is Dictionary):
|
||||||
|
return
|
||||||
for team_key in zones.keys():
|
for team_key in zones.keys():
|
||||||
|
if not (zones[team_key] is Dictionary):
|
||||||
|
continue
|
||||||
var team := int(team_key)
|
var team := int(team_key)
|
||||||
var zone: Dictionary = zones[team_key]
|
var zone: Dictionary = zones[team_key]
|
||||||
var points: Array = zone.get("points", [])
|
var points = zone.get("points", [])
|
||||||
var color: Color = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE
|
if not (points is Array):
|
||||||
|
continue
|
||||||
for p in points:
|
for p in points:
|
||||||
if not (p is Dictionary):
|
if not (p is Dictionary):
|
||||||
continue
|
continue
|
||||||
|
var pid: String = str(p.get("id", "?"))
|
||||||
var pos: Array = p.get("pos", [0.0, 0.0, 0.0])
|
var pos: Array = p.get("pos", [0.0, 0.0, 0.0])
|
||||||
var marker := Node3D.new()
|
var marker := _spawn_marker(
|
||||||
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
|
Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
|
||||||
var mi := MeshInstance3D.new()
|
team, pid, SpawnOps.zone_radius(data, pid))
|
||||||
var cyl := CylinderMesh.new()
|
spawn_markers.append(marker)
|
||||||
cyl.top_radius = 1.5
|
add_child(marker)
|
||||||
cyl.bottom_radius = 1.5
|
|
||||||
cyl.height = 6.0
|
|
||||||
mi.mesh = cyl
|
## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius.
|
||||||
mi.position.y = 3.0
|
func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D:
|
||||||
var mat := StandardMaterial3D.new()
|
var marker := Node3D.new()
|
||||||
mat.albedo_color = Color(color.r, color.g, color.b, 0.55)
|
marker.position = pos
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
marker.set_meta("spawn_team", team)
|
||||||
mi.material_override = mat
|
marker.set_meta("spawn_id", pid)
|
||||||
marker.add_child(mi)
|
marker.set_meta("spawn_radius", radius)
|
||||||
var label := Label3D.new()
|
marker.set_meta("spawn_pick_radius", maxf(radius + 2.0, 4.0))
|
||||||
label.text = str(p.get("id", "?"))
|
marker.set_meta("spawn_selected", false)
|
||||||
label.position.y = 7.5
|
marker.add_child(_spawn_disc(radius, team))
|
||||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
var label := Label3D.new()
|
||||||
label.modulate = color
|
label.text = pid
|
||||||
label.font_size = 220
|
label.position.y = 2.5
|
||||||
label.pixel_size = 0.01
|
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
marker.add_child(label)
|
label.modulate = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE
|
||||||
add_child(marker)
|
label.outline_size = 12
|
||||||
|
label.font_size = 160
|
||||||
|
label.pixel_size = 0.01
|
||||||
|
marker.add_child(label)
|
||||||
|
return marker
|
||||||
|
|
||||||
|
|
||||||
|
## Kreisfläche EINES Markers (neu) bauen — Werkzeuge rufen nach Radius-Änderung
|
||||||
|
## style_spawn_marker auf.
|
||||||
|
func _spawn_disc(radius: float, team: int, selected := false) -> MeshInstance3D:
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var cyl := CylinderMesh.new()
|
||||||
|
cyl.top_radius = radius
|
||||||
|
cyl.bottom_radius = radius
|
||||||
|
cyl.height = 0.25
|
||||||
|
mi.mesh = cyl
|
||||||
|
mi.position.y = 0.12
|
||||||
|
var col: Color = TEAM_COLORS[team] if team >= 0 and team < TEAM_COLORS.size() else Color.WHITE
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color(col.r, col.g, col.b, 0.55 if not selected else 0.8)
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
if selected:
|
||||||
|
mat.emission_enabled = true
|
||||||
|
mat.emission = col * 0.6
|
||||||
|
mi.material_override = mat
|
||||||
|
return mi
|
||||||
|
|
||||||
|
|
||||||
|
## Radius/Anfass-Optik eines Spawn-Markers aktualisieren OHNE Gesamtrebuild
|
||||||
|
## (Radius-Regel gedreht, Zone ausgewählt/abgewählt).
|
||||||
|
func style_spawn_marker(marker: Node3D, radius: float, selected: bool) -> void:
|
||||||
|
if marker == null or not is_instance_valid(marker):
|
||||||
|
return
|
||||||
|
var team := int(marker.get_meta("spawn_team", -1))
|
||||||
|
var pid := str(marker.get_meta("spawn_id", ""))
|
||||||
|
marker.set_meta("spawn_radius", radius)
|
||||||
|
marker.set_meta("spawn_pick_radius", maxf(radius + 2.0, 4.0))
|
||||||
|
marker.set_meta("spawn_selected", selected)
|
||||||
|
if marker.get_child_count() > 0 and marker.get_child(0) is MeshInstance3D:
|
||||||
|
(marker.get_child(0) as MeshInstance3D).queue_free()
|
||||||
|
var disc := _spawn_disc(radius, team, selected)
|
||||||
|
marker.add_child(disc)
|
||||||
|
marker.move_child(disc, 0)
|
||||||
|
|
||||||
|
|
||||||
|
## Auswahl-Highlight über ALLE Spawn-Marker (Werkzeug ruft bei Änderung).
|
||||||
|
func set_spawn_selected(team: int, pid: String) -> void:
|
||||||
|
for marker in spawn_markers:
|
||||||
|
if marker == null or not is_instance_valid(marker):
|
||||||
|
continue
|
||||||
|
var is_sel := int(marker.get_meta("spawn_team", -1)) == team \
|
||||||
|
and str(marker.get_meta("spawn_id", "")) == pid
|
||||||
|
if bool(marker.get_meta("spawn_selected", false)) == is_sel:
|
||||||
|
continue
|
||||||
|
style_spawn_marker(marker, float(marker.get_meta("spawn_radius", 6.0)), is_sel)
|
||||||
|
# Label sofort umfärben (auch ohne Disc-Neubau sichtbar).
|
||||||
|
var col: Color = TEAM_COLORS[clampi(team, 0, 1)] if team >= 0 else Color.WHITE
|
||||||
|
if marker.get_child_count() > 1 and marker.get_child(1) is Label3D:
|
||||||
|
var label := marker.get_child(1) as Label3D
|
||||||
|
label.modulate = Color(1.0, 0.85, 0.2) if is_sel else col
|
||||||
314
tests/test_spawns.gd
Normal file
314
tests/test_spawns.gd
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Editor-Tests (headless): Karte t_c4162c5d — Spawn-Zonen-Werkzeug.
|
||||||
|
## Ausführen: godot --headless --path . --script res://tests/test_spawns.gd
|
||||||
|
## Exit 0 = alles grün. Läuft OHNE UI (SpawnOps/SvmapIO/ActionHistory).
|
||||||
|
## GUI-Feinheiten (Kreis-Optik, Shake, Ghost) verifiziert der Desktop-Smoke
|
||||||
|
## (--spawns-demo auf Slys PC), nicht hier.
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
var checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_cleanup()
|
||||||
|
_run_tests()
|
||||||
|
_cleanup()
|
||||||
|
if failures == 0:
|
||||||
|
print("ALL OK: %d checks" % checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FAILED: %d/%d checks" % [failures, checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _cleanup() -> void:
|
||||||
|
for path in ["user://test_spawns.svmap", "user://test_spawns2.svmap"]:
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, label: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if cond:
|
||||||
|
print(" ok: %s" % label)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
print(" FAIL: %s" % label)
|
||||||
|
|
||||||
|
|
||||||
|
func _manifest() -> Dictionary:
|
||||||
|
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
|
||||||
|
return parsed if parsed is Dictionary else {}
|
||||||
|
|
||||||
|
|
||||||
|
func _commit_spawn(hist: ActionHistory, doc: Dictionary, desc: String) -> void:
|
||||||
|
hist.commit(desc, SpawnOps.snapshot_blocks(hist, doc), SpawnOps.snapshot_blocks(hist, doc))
|
||||||
|
|
||||||
|
|
||||||
|
func _run_tests() -> void:
|
||||||
|
_test_defaults_and_ids()
|
||||||
|
_test_place_undo_roundtrip()
|
||||||
|
_test_radius_change_and_merge()
|
||||||
|
_test_delete_and_undo()
|
||||||
|
_test_move_and_undo()
|
||||||
|
_test_live_validation_status()
|
||||||
|
_test_ready_report()
|
||||||
|
_test_export_reimport_identical()
|
||||||
|
_test_zone_on_terrain()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_defaults_and_ids() -> void:
|
||||||
|
print("Testgruppe: Default-Zonen + ID-Vergabe")
|
||||||
|
var doc := SvmapDocument.create_new("ID-Test", 512.0, "mittag")
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 4, "Team Blau: 4 Default-Zonen")
|
||||||
|
_check(SpawnOps.count_points(doc, 1) == 4, "Team Rot: 4 Default-Zonen")
|
||||||
|
_check(SpawnOps.next_id(doc, 0) == "A5", "nächste blaue ID = A5 (A1–A4 belegt)")
|
||||||
|
_check(SpawnOps.next_id(doc, 1) == "B5", "nächste rote ID = B5 (B1–B4 belegt)")
|
||||||
|
_check(SpawnOps.zone_radius(doc, "A1") == SpawnOps.DEFAULT_ZONE_RADIUS,
|
||||||
|
"Radius ohne Eintrag = Default (%d m)" % int(SpawnOps.DEFAULT_ZONE_RADIUS))
|
||||||
|
|
||||||
|
|
||||||
|
## Zone setzen → undo → redo: spawn_zones UND design_notes kommen beide zurück.
|
||||||
|
func _test_place_undo_roundtrip() -> void:
|
||||||
|
print("Testgruppe: Zone setzen/Undo/Redo")
|
||||||
|
var doc := SvmapDocument.create_new("Place-Test", 512.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
|
||||||
|
var point := SpawnOps.add_zone(doc, 0, -30.0, 40.0)
|
||||||
|
SpawnOps.set_radius(doc, str(point["id"]), 9.0)
|
||||||
|
hist.commit("Spawn-Zone %s gesetzt" % point["id"], before, SpawnOps.snapshot_blocks(hist, doc))
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 5, "setzen: 5 blaue Zonen")
|
||||||
|
_check(absf(SpawnOps.zone_radius(doc, str(point["id"])) - 9.0) < 0.001, "Radius 9 m im Doc (design_notes)")
|
||||||
|
var pid: String = str(point["id"])
|
||||||
|
_check(pid == "A5", "automatische ID A5")
|
||||||
|
hist.undo()
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 4, "undo: Zone weg")
|
||||||
|
var notes = doc.get("design_notes", {})
|
||||||
|
var radii = (notes as Dictionary).get("spawn_radius_m", {}) if notes is Dictionary else {}
|
||||||
|
_check(not (radii is Dictionary and (radii as Dictionary).has(pid)),
|
||||||
|
"undo: Radius-Eintrag auch weg (kein Geist in design_notes)")
|
||||||
|
hist.redo()
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 5, "redo: Zone zurück")
|
||||||
|
_check(absf(SpawnOps.zone_radius(doc, pid) - 9.0) < 0.001, "redo: Radius 9 m wieder da")
|
||||||
|
|
||||||
|
|
||||||
|
## Radius-Slider-Ticks dürfen die History NICHT fluten — EIN Eintrag pro Zone.
|
||||||
|
func _test_radius_change_and_merge() -> void:
|
||||||
|
print("Testgruppe: Radius ändern + Merge-Tags")
|
||||||
|
var doc := SvmapDocument.create_new("Radius-Test", 512.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
var entries_before := hist.entries.size()
|
||||||
|
for m in [4.0, 5.0, 6.0]:
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
|
||||||
|
SpawnOps.set_radius(doc, "A1", float(m))
|
||||||
|
hist.commit("Radius von A1: %d m" % int(m), before, SpawnOps.snapshot_blocks(hist, doc), "radius:A1")
|
||||||
|
_check(hist.entries.size() == entries_before + 1, "3 Slider-Ticks → EIN History-Eintrag")
|
||||||
|
_check(absf(SpawnOps.zone_radius(doc, "A1") - 6.0) < 0.001, "Endradius 6 m im Doc")
|
||||||
|
hist.undo()
|
||||||
|
_check(SpawnOps.zone_radius(doc, "A1") == SpawnOps.DEFAULT_ZONE_RADIUS, "Merge-Undo: zurück auf Default")
|
||||||
|
hist.redo()
|
||||||
|
_check(absf(SpawnOps.zone_radius(doc, "A1") - 6.0) < 0.001, "Merge-Redo: wieder 6 m")
|
||||||
|
|
||||||
|
|
||||||
|
## Zone löschen → undo: Punkt MIT Position und Radius-Eintrag zurück.
|
||||||
|
func _test_delete_and_undo() -> void:
|
||||||
|
print("Testgruppe: Zone löschen/Undo")
|
||||||
|
var doc := SvmapDocument.create_new("Delete-Test", 512.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
SpawnOps.set_radius(doc, "A2", 12.0)
|
||||||
|
var pos_before: Array = SpawnOps.points(doc, 0)[1]["pos"]
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
|
||||||
|
SpawnOps.remove_point(doc, 0, "A2")
|
||||||
|
hist.commit("A2 löschen", before, SpawnOps.snapshot_blocks(hist, doc))
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 3, "löschen: 3 blaue Zonen")
|
||||||
|
_check(SpawnOps.next_id(doc, 0) == "A2", "freigewordene ID wird wieder vergeben")
|
||||||
|
hist.undo()
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 4, "undo: 4 blaue Zonen")
|
||||||
|
var pos_after: Array = SpawnOps.points(doc, 0)[1]["pos"]
|
||||||
|
_check(float(pos_after[0]) == float(pos_before[0]) and float(pos_after[2]) == float(pos_before[2]),
|
||||||
|
"undo: Position von A2 identisch zurück")
|
||||||
|
_check(absf(SpawnOps.zone_radius(doc, "A2") - 12.0) < 0.001, "undo: Radius 12 m zurück")
|
||||||
|
|
||||||
|
|
||||||
|
## Zone verschieben → undo → redo (Position und Terrain-Höhe).
|
||||||
|
func _test_move_and_undo() -> void:
|
||||||
|
print("Testgruppe: Zone verschieben/Undo")
|
||||||
|
var doc := SvmapDocument.create_new("Move-Test", 512.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
|
||||||
|
SpawnOps.move_point(doc, 1, "B1", 111.0, -77.0)
|
||||||
|
hist.commit("B1 verschoben", before, SpawnOps.snapshot_blocks(hist, doc))
|
||||||
|
var moved: Array = (SpawnOps.zone_dict(doc, 1)["points"][0])["pos"]
|
||||||
|
_check(absf(float(moved[0]) - 111.0) < 0.001 and absf(float(moved[2]) + 77.0) < 0.001,
|
||||||
|
"verschieben: neue X/Z im Doc")
|
||||||
|
hist.undo()
|
||||||
|
var back: Array = (SpawnOps.zone_dict(doc, 1)["points"][0])["pos"]
|
||||||
|
_check(absf(float(back[0]) - 204.8) < 1.0, "undo: B1 wieder an der Startkante (x = 0.8 × 256 m)")
|
||||||
|
hist.redo()
|
||||||
|
_check(absf(float(moved[0]) - 111.0) < 0.001, "redo: Verschiebung wieder da")
|
||||||
|
|
||||||
|
|
||||||
|
## Live-Validierung spiegelt die Loader-Regeln: Teams < 4 → ❌, unbekannte
|
||||||
|
## Objekt-ID → ❌, sonst ✅. Genau diese Meldungen sieht Sly VOR dem Export.
|
||||||
|
func _test_live_validation_status() -> void:
|
||||||
|
print("Testgruppe: Live-Validierungs-Status")
|
||||||
|
var doc := SvmapDocument.create_new("Live-Test", 512.0, "mittag")
|
||||||
|
var manifest := _manifest()
|
||||||
|
var text := SpawnOps.live_status(doc, manifest)
|
||||||
|
_check(text.contains("✅ Spawns Team Blau: 4"), "Live: ✅ Blau 4 (Default)")
|
||||||
|
_check(text.contains("✅ Spawns Team Rot: 4"), "Live: ✅ Rot 4 (Default)")
|
||||||
|
_check(text.contains("✅ Objekt-IDs"), "Live: ✅ Objekt-IDs ohne Objekte (optional)")
|
||||||
|
|
||||||
|
# 2 rote Zonen löschen (immer den LETZTEN Punkt — next_id liefert nur freie,
|
||||||
|
# nicht existierende IDs und wäre hier die falsche Wahl) → ❌ Team Rot: 2
|
||||||
|
while SpawnOps.count_points(doc, 1) > 2:
|
||||||
|
var pts := SpawnOps.points(doc, 1)
|
||||||
|
SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"]))
|
||||||
|
text = SpawnOps.live_status(doc, manifest)
|
||||||
|
_check(text.contains("❌ Spawns Team Rot: 2"), "Live: ❌ Rot 2 nach Löschaktionen")
|
||||||
|
var lines := SpawnOps.validate_lines(doc, manifest)
|
||||||
|
var team1_hint := ""
|
||||||
|
for line in lines:
|
||||||
|
if str(line["text"]).contains("Team Rot") and not bool(line["ok"]):
|
||||||
|
team1_hint = str(line["hint"])
|
||||||
|
_check(team1_hint.contains("Spawns"), "fehlende Zone → hilfreicher Hinweis (wo Zonen malen)")
|
||||||
|
|
||||||
|
# Objekt mit unbekannter ID → ❌ Objekt-IDs
|
||||||
|
(doc["objects"] as Array).append({"id": "nicht_in_bibliothek", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
text = SpawnOps.live_status(doc, manifest)
|
||||||
|
_check(text.contains("❌ Objekt-IDs"), "Live: ❌ Objekt-IDs bei unbekannter ID")
|
||||||
|
_check(text.contains("1 von 1 unbekannt"), "Live: Anzahl unbekannter Objekte genannt")
|
||||||
|
|
||||||
|
|
||||||
|
## "Karte prüfen": neue Map = bereit; kaputte Map = Klartext-Probleme.
|
||||||
|
func _test_ready_report() -> void:
|
||||||
|
print("Testgruppe: Map-Fertig-Check (ready_report)")
|
||||||
|
var doc := SvmapDocument.create_new("Check-Test", 512.0, "abend")
|
||||||
|
(doc["info"] as Dictionary)["author"] = "Sly"
|
||||||
|
var report := SpawnOps.ready_report(doc, _manifest())
|
||||||
|
_check(bool(report["ok"]), "frische Map (4+4 Spawns): bereit zum Export")
|
||||||
|
var all_ok := true
|
||||||
|
for line in report["lines"]:
|
||||||
|
if not bool(line["ok"]):
|
||||||
|
all_ok = false
|
||||||
|
_check(all_ok, "alle Check-Zeilen ✅ bei frischer Map")
|
||||||
|
var has_poly := false
|
||||||
|
for line in report["lines"]:
|
||||||
|
if str(line["text"]).contains("Poly-Budget"):
|
||||||
|
has_poly = true
|
||||||
|
_check(has_poly, "Poly-Budget-Zeile im Klartext-Report")
|
||||||
|
|
||||||
|
# Kaputt: Name leer + Spawn unter Limit + unbekanntes Objekt.
|
||||||
|
(doc["info"] as Dictionary)["name"] = " "
|
||||||
|
while SpawnOps.count_points(doc, 1) > 1:
|
||||||
|
var pts := SpawnOps.points(doc, 1)
|
||||||
|
SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"]))
|
||||||
|
(doc["objects"] as Array).append({"id": "unicorn_mesh", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
report = SpawnOps.ready_report(doc, _manifest())
|
||||||
|
_check(not bool(report["ok"]), "kaputte Map: NICHT bereit")
|
||||||
|
var found := []
|
||||||
|
for line in report["lines"]:
|
||||||
|
found.append(str(line["text"]))
|
||||||
|
_check(_any_contains(found, "Map-Name"), "Check nennt fehlenden Map-Namen")
|
||||||
|
_check(_any_contains(found, "V11"), "Check nennt V11 (Spawn-Limit) aus dem Loader-Kern")
|
||||||
|
_check(_any_contains(found, "V7"), "Check nennt V7 (unbekannte Objekt-ID) aus dem Loader-Kern")
|
||||||
|
|
||||||
|
|
||||||
|
func _any_contains(texts: Array, needle: String) -> bool:
|
||||||
|
for t in texts:
|
||||||
|
if t.contains(needle):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Editor-Export → Loader-Re-Import = identisch. Alle Änderungen (Zonen +/−,
|
||||||
|
## Radien, Objekte) überleben Speichern/Laden; Prüfsumme validiert.
|
||||||
|
func _test_export_reimport_identical() -> void:
|
||||||
|
print("Testgruppe: Export + Loader-Re-Import = identisch")
|
||||||
|
var doc := SvmapDocument.create_new("Export-Test", 512.0, "regen")
|
||||||
|
(doc["info"] as Dictionary)["author"] = "Sly"
|
||||||
|
# Objekte platzieren (bekannte IDs aus der Bibliothek).
|
||||||
|
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 15.0})
|
||||||
|
(doc["objects"] as Array).append({"id": "bunker_concrete", "pos": [-40.0, 0.0, -20.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
# Spawn-Zonen umbauen: 2 rote löschen, 2 rote NEU setzen (IDs A5/B5,B6),
|
||||||
|
# Radien setzen. Beide Teams bleiben ≥ 4 → Export gültig.
|
||||||
|
while SpawnOps.count_points(doc, 1) > 2:
|
||||||
|
var pts := SpawnOps.points(doc, 1)
|
||||||
|
SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"]))
|
||||||
|
var red1 := SpawnOps.add_zone(doc, 1, -222.0, 11.0)
|
||||||
|
var red2 := SpawnOps.add_zone(doc, 1, -233.0, -11.0)
|
||||||
|
var point := SpawnOps.add_zone(doc, 0, 33.0, -44.0)
|
||||||
|
SpawnOps.set_radius(doc, str(point["id"]), 14.0)
|
||||||
|
SpawnOps.set_radius(doc, "B1", 3.0)
|
||||||
|
SpawnOps.set_radius(doc, str(red1["id"]), 8.0)
|
||||||
|
_check(SpawnOps.count_points(doc, 0) == 5 and SpawnOps.count_points(doc, 1) == 4,
|
||||||
|
"Testdokument: 5 blaue / 4 rote Zonen (beide Teams über dem V11-Limit)")
|
||||||
|
|
||||||
|
# DER Export: derselbe Code wie beim Speichern (SvmapIO.save_to_file → validate + sign).
|
||||||
|
var save := SvmapIO.save_to_file("user://test_spawns.svmap", doc)
|
||||||
|
_check(save["ok"], "save_to_file ok: %s" % str(save["errors"]))
|
||||||
|
|
||||||
|
var load := SvmapIO.load_from_file("user://test_spawns.svmap")
|
||||||
|
_check(load["ok"], "load_from_file ok (Prüfsumme stimmt)")
|
||||||
|
var back: Dictionary = load["data"]
|
||||||
|
_check((back["spawn_zones"]["0"]["points"] as Array).size() == 5, "Re-Import: 5 blaue Zonen")
|
||||||
|
_check((back["spawn_zones"]["1"]["points"] as Array).size() == 4, "Re-Import: 4 rote Zonen")
|
||||||
|
var back_points: Array = back["spawn_zones"]["0"]["points"]
|
||||||
|
var found_new := false
|
||||||
|
for p in back_points:
|
||||||
|
if str(p["id"]) == str(point["id"]):
|
||||||
|
found_new = true
|
||||||
|
_check(found_new, "Re-Import: neue Zone %s ist drin" % point["id"])
|
||||||
|
_check(_radius_from(back, str(point["id"])) == 14.0, "Re-Import: Radius 14 m überlebt (design_notes)")
|
||||||
|
_check(_radius_from(back, "B1") == 3.0, "Re-Import: Radius B1 = 3 m überlebt")
|
||||||
|
_check(_radius_from(back, str(red1["id"])) == 8.0, "Re-Import: Radius der NEUEN roten Zone = 8 m")
|
||||||
|
_check((back["objects"] as Array).size() == 2, "Re-Import: beide Objekte da")
|
||||||
|
|
||||||
|
# Roundtrip: speichern → laden → speichern → byte-identisch (Kanonisierung).
|
||||||
|
var save2 := SvmapIO.save_to_file("user://test_spawns2.svmap", back)
|
||||||
|
_check(save2["ok"], "Re-Save ok")
|
||||||
|
var text1 := FileAccess.get_file_as_string("user://test_spawns.svmap")
|
||||||
|
var text2 := FileAccess.get_file_as_string("user://test_spawns2.svmap")
|
||||||
|
_check(text1 == text2, "speichern→laden→speichern = byte-identisch")
|
||||||
|
|
||||||
|
|
||||||
|
func _radius_from(data: Dictionary, pid: String) -> float:
|
||||||
|
var notes = data.get("design_notes", {})
|
||||||
|
if notes is Dictionary and (notes as Dictionary).get("spawn_radius_m", {}) is Dictionary:
|
||||||
|
return float((notes["spawn_radius_m"] as Dictionary).get(pid, -1.0))
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
|
||||||
|
## Mit Höhenfeld: neue Zone sitzt AUF dem Boden (y = Terrain-Höhe am Punkt).
|
||||||
|
func _test_zone_on_terrain() -> void:
|
||||||
|
print("Testgruppe: Zone auf Terrain-Höhe")
|
||||||
|
# Gefälltes Terrain: Nordkante 0 m, Südkante 100 m (res 17, size 160).
|
||||||
|
var res := 17
|
||||||
|
var bytes := PackedByteArray()
|
||||||
|
bytes.resize(res * res * 2)
|
||||||
|
var i := 0
|
||||||
|
while i < res * res:
|
||||||
|
var row := i / res
|
||||||
|
var raw := int(round(float(row) / float(res - 1) * 65535.0))
|
||||||
|
bytes[i * 2] = raw & 0xFF
|
||||||
|
bytes[i * 2 + 1] = (raw >> 8) & 0xFF
|
||||||
|
i += 1
|
||||||
|
var terrain := {
|
||||||
|
"type": "heightmap", "size_x_m": 160.0, "size_z_m": 160.0,
|
||||||
|
"min_height_m": 0.0, "max_height_m": 100.0, "resolution": res,
|
||||||
|
"heightmap_r16_b64": Marshalls.raw_to_base64(bytes),
|
||||||
|
}
|
||||||
|
var doc := SvmapDocument.create_new("Hügel-Test", 160.0, "abend")
|
||||||
|
doc["terrain"] = terrain
|
||||||
|
var point := SpawnOps.add_zone(doc, 1, 0.0, 80.0)
|
||||||
|
_check(absf(float(point["pos"][1]) - 100.0) < 1.0, "Zone auf Südkante: y ≈ 100 m (auf dem Boden)")
|
||||||
|
SpawnOps.move_point(doc, 1, str(point["id"]), 0.0, -80.0)
|
||||||
|
var moved_pos: Variant = null
|
||||||
|
for p in SpawnOps.points(doc, 1):
|
||||||
|
if str(p["id"]) == str(point["id"]):
|
||||||
|
moved_pos = p["pos"]
|
||||||
|
_check(moved_pos != null and absf(float(moved_pos[1]) - 0.0) < 1.0, "verschieben auf Nordkante: y ≈ 0 m")
|
||||||
1
tests/test_spawns.gd.uid
Normal file
1
tests/test_spawns.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b2tdv3o82y3qr
|
||||||
Loading…
Reference in a new issue