M2-Karte t_c4162c5d: Spawn-Zonen malen + Live-Validierung + Map-Fertig-Check (Export-Signierung unverändert über SvmapIO)

This commit is contained in:
slyphax 2026-10-15 12:45:54 +00:00
parent 3a5c9d44bb
commit 684b4cfd08
10 changed files with 1479 additions and 54 deletions

View file

@ -120,6 +120,45 @@ Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und
speichert Screenshots.
**Noch offen (folgende Karten der MVP-Kette):** Spawn-Zonen-Editor
(t_c4162c5d — Spawn-Zonen malen, Live-Validierung, Export-Signierung),
Performance-Check-UI, Export-Workflow.
**Noch offen (folgende Karten der MVP-Kette):** 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.

View file

@ -275,8 +275,9 @@ func delete_selected() -> void:
# ------------------------------------------------------------ Eingaben ------
func _unhandled_input(event: InputEvent) -> void:
if main.get("tool_mode") == "terrain":
return # Terrain-Pinsel hat das Zepter (terrain_tool.gd, Karte t_9aba04f1)
var mode: String = main.get("tool_mode")
if mode == "terrain" or mode == "spawns":
return # Terrain-Pinsel / Spawn-Zonen haben das Zepter (eigene Werkzeuge)
if event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if placing:

247
src/editor/spawn_ops.gd Normal file
View 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

View file

@ -0,0 +1 @@
uid://c1yvw71ppjl28

450
src/editor/spawn_tool.gd Normal file
View 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)

View file

@ -0,0 +1 @@
uid://dcr747fch22k

View file

@ -26,7 +26,8 @@ var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeug
var history := ActionHistory.new()
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
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
var content_tree: Tree
@ -66,6 +67,22 @@ var terrain_radius_label: Label
var terrain_strength_slider: HSlider
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
var new_dialog: AcceptDialog
var open_dialog: FileDialog
@ -140,6 +157,14 @@ func _build_ui() -> void:
redo_btn.pressed.connect(_do_redo)
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()
title_label.text = " %s" % APP_TITLE
title_label.add_theme_font_size_override("font_size", 20)
@ -223,6 +248,16 @@ func _build_ui() -> void:
error_dialog.add_child(error_text)
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:
var panel := PanelContainer.new()
@ -353,6 +388,12 @@ func _build_center_viewport() -> SubViewportContainer:
world.add_child(terrain_tool)
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)
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"
@ -494,6 +535,67 @@ func _build_right_panel() -> PanelContainer:
"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."))
# --- 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)
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
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_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
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:
interaction.cancel_placement()
_set_status("Platzieren abgebrochen.")
@ -701,14 +807,17 @@ func _unhandled_key_input(event: InputEvent) -> void:
if not ctrl:
if interaction.placing:
return
interaction.delete_selected()
if tool_mode == "spawns":
spawn_tool.call("delete_selected")
else:
interaction.delete_selected()
get_viewport().set_input_as_handled()
# --------------------------------------------------------- Bibliothek ------
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
if interaction.placing and interaction.place_oid == oid:
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
@ -784,31 +893,74 @@ func _on_terrain_btn_pressed() -> void:
_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:
if mode == tool_mode:
_sync_terrain_button()
_sync_spawn_button()
return
if mode == "terrain":
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()
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.")
_leave_spawn_mode()
_enter_terrain_mode()
elif mode == "spawns":
_leave_terrain_mode()
_enter_spawn_mode()
else:
terrain_tool.call("deactivate")
tool_mode = "objects"
terrain_panel.visible = false
_leave_terrain_mode()
_leave_spawn_mode()
_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()
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()
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.")
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:
@ -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)
# ---------------------------------------------------- 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.
func _on_tree_selected() -> void:
if _tree_select_lock:
@ -979,11 +1223,14 @@ func _after_structure_change() -> void:
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
_refresh_spawn_status()
if interaction != null:
interaction.call("clear_selection", true)
view_builder.call("rebuild", doc, manifest)
_refresh_tree()
_recheck_terrain_mode()
if tool_mode == "spawns" and spawn_tool != null:
spawn_tool.call("refresh_from_doc")
# --------------------------------------------------------------- Anzeige ---
@ -1012,6 +1259,7 @@ func _refresh_all() -> void:
_refresh_file_label()
_refresh_history_buttons()
_refresh_viewport()
_refresh_spawn_status()
_recheck_terrain_mode()
@ -1109,6 +1357,9 @@ func _maybe_start_demo_mode() -> void:
if args.has("--library-demo"):
_start_library_demo(args)
return
if args.has("--spawns-demo"):
_start_spawns_demo(args)
return
if not args.has("--ghost-demo"):
return
var oid := "crate_wood"
@ -1271,4 +1522,48 @@ func _start_library_demo(args: PackedStringArray) -> void:
await _demo_shot(out_dir + "/library_after_snap.png")
print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir)
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

View file

@ -23,6 +23,7 @@ const WEATHER_LIGHT := {
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
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 _base_mat: StandardMaterial3D
@ -38,6 +39,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
for child in get_children():
child.queue_free()
object_markers = []
spawn_markers = []
_base_mat = StandardMaterial3D.new()
_base_mat.albedo_color = OBJECT_COLOR
_base_mat.roughness = 0.8
@ -60,7 +62,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
_build_terrain(data)
_build_grid(size_x, size_z, ground_y)
_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):
@ -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
## Spawn-Marker: halbtransparente Säule + Label3D mit der Punkt-ID, teamfarbig.
func _build_spawns(zones: Dictionary) -> void:
## Spawn-Zonen (Karte t_c4162c5d): JEDE Zone ist ein flacher, teamfarbiger
## 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():
if not (zones[team_key] is Dictionary):
continue
var team := int(team_key)
var zone: Dictionary = zones[team_key]
var points: Array = zone.get("points", [])
var color: Color = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE
var points = zone.get("points", [])
if not (points is Array):
continue
for p in points:
if not (p is Dictionary):
continue
var pid: String = str(p.get("id", "?"))
var pos: Array = p.get("pos", [0.0, 0.0, 0.0])
var marker := Node3D.new()
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
var mi := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = 1.5
cyl.bottom_radius = 1.5
cyl.height = 6.0
mi.mesh = cyl
mi.position.y = 3.0
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(color.r, color.g, color.b, 0.55)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mi.material_override = mat
marker.add_child(mi)
var label := Label3D.new()
label.text = str(p.get("id", "?"))
label.position.y = 7.5
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.modulate = color
label.font_size = 220
label.pixel_size = 0.01
marker.add_child(label)
add_child(marker)
var marker := _spawn_marker(
Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
team, pid, SpawnOps.zone_radius(data, pid))
spawn_markers.append(marker)
add_child(marker)
## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius.
func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D:
var marker := Node3D.new()
marker.position = pos
marker.set_meta("spawn_team", team)
marker.set_meta("spawn_id", pid)
marker.set_meta("spawn_radius", radius)
marker.set_meta("spawn_pick_radius", maxf(radius + 2.0, 4.0))
marker.set_meta("spawn_selected", false)
marker.add_child(_spawn_disc(radius, team))
var label := Label3D.new()
label.text = pid
label.position.y = 2.5
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.modulate = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE
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
View 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
View file

@ -0,0 +1 @@
uid://b2tdv3o82y3qr