diff --git a/README.md b/README.md index cdb76ff..c7d24df 100644 --- a/README.md +++ b/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 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. diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 28d9741..6467504 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -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: diff --git a/src/editor/spawn_ops.gd b/src/editor/spawn_ops.gd new file mode 100644 index 0000000..1bfe4fc --- /dev/null +++ b/src/editor/spawn_ops.gd @@ -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 \ No newline at end of file diff --git a/src/editor/spawn_ops.gd.uid b/src/editor/spawn_ops.gd.uid new file mode 100644 index 0000000..9c86c61 --- /dev/null +++ b/src/editor/spawn_ops.gd.uid @@ -0,0 +1 @@ +uid://c1yvw71ppjl28 diff --git a/src/editor/spawn_tool.gd b/src/editor/spawn_tool.gd new file mode 100644 index 0000000..2e7a648 --- /dev/null +++ b/src/editor/spawn_tool.gd @@ -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) \ No newline at end of file diff --git a/src/editor/spawn_tool.gd.uid b/src/editor/spawn_tool.gd.uid new file mode 100644 index 0000000..44902a0 --- /dev/null +++ b/src/editor/spawn_tool.gd.uid @@ -0,0 +1 @@ +uid://dcr747fch22k diff --git a/src/ui/main.gd b/src/ui/main.gd index aac2284..ed5b0a1 100644 --- a/src/ui/main.gd +++ b/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 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 \ No newline at end of file diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 8d4e8cd..1c74c1e 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/test_spawns.gd b/tests/test_spawns.gd new file mode 100644 index 0000000..71b47b2 --- /dev/null +++ b/tests/test_spawns.gd @@ -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") \ No newline at end of file diff --git a/tests/test_spawns.gd.uid b/tests/test_spawns.gd.uid new file mode 100644 index 0000000..1b96397 --- /dev/null +++ b/tests/test_spawns.gd.uid @@ -0,0 +1 @@ +uid://b2tdv3o82y3qr