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) ## Pfeilspitze ziehen = Blickrichtung der Zone (auch Conquest-Zonen; grau = Standard) ## Regel rechts = Radius der neuen Zonen; mit Auswahl: Radius DER Zone ## Entf = ausgewählte Zone löschen ## ESC = Auswahl weg / Werkzeug beendet (ESC in main.gd; ## Rechte Maustaste = immer Flug, t_2470de89) ## ## 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 · Pfeilspitze ziehen: Blickrichtung · Entf: löschen ·\nRadius/Team rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" ## Symmetrie (M4, Karte t_ffa207b1): Partner-Kopplung gespiegelter Spawn-Zonen. ## Liegt in design_notes (Loader-frei, rundtrip-sicher — wie spawn_radius_m): ## {"A5": "B3", "B3": "A5"} — direkte Partner-IDs, beide Richtungen gesetzt. const SPAWN_MIRROR_KEY := "spawn_mirror" 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 # Pfeilspitze ziehen (Blickrichtung): Griff aus view_builder.yaw_handles(), {} = keiner const ARROW_PICK_M := 2.5 const ARROW_SNAP_DEG := 5.0 var _arrow_grab: Dictionary = {} var _arrow_yaw := 0.0 # 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: var p := MousePointer.from_input(event) if p != null and handle_pointer(p): get_viewport().set_input_as_handled() ## Rechte Maustaste kommt hier nie an (Karte t_2470de89): sie gehört mit ## absolutem Vorrang dem Freelook-Flug. Deselect/Werkzeug beenden läuft über ## ESC (main.gd), die Auswahl bleibt im Flug erhalten. func handle_pointer(p: PointerEvent) -> bool: if not active: return false match p.kind: PointerEvent.Kind.MOVE: _update_cursor(p.position) if not _arrow_grab.is_empty(): _drag_arrow(p.position) return false if _dragging and selected_team >= 0: var hit: Variant = _ray_hit(p.position) if hit == null: return false 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 PointerEvent.Kind.PRESS: _on_left_press(p.position) return true PointerEvent.Kind.RELEASE: _on_left_release() return false 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: _arrow_grab = _pick_arrow(viewport_pos) if not _arrow_grab.is_empty(): _drag_arrow(viewport_pos) return 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 _arrow_grab.is_empty(): _commit_arrow() return 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) # Symmetrie (M4): Zwilling folgt der gespiegelten neuen Position. var mirror_msg: String = spawn_mirror_move(main.doc, selected_team, selected_id, _sym_mode()) 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.%s" % [ selected_id, "" if mirror_msg.is_empty() else " " + mirror_msg]) _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) # Symmetrie (M4): Zwilling im Gegner-Team — innerhalb desselben Undo-Schritts. var mirror_res: Dictionary = spawn_mirror_add(main.doc, team, pid, _sym_mode()) 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() var mirror_text := str(mirror_res["status"]) main.call("_set_status", "Spawn-Zone %s (%s) gesetzt.%s Radius: Regler rechts · verschieben: klicken+ziehen · Entf löscht · Strg+Z macht alles rückgängig." % [ pid, SpawnOps.team_label(team), "" if mirror_text.is_empty() else " " + mirror_text]) ## Kursor-Strahl → nächste Pfeilspitze in Greifweite oder {}. func _pick_arrow(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 := ARROW_PICK_M for h in view_builder.yaw_handles(): var tip: Vector3 = h["tip"] var d := Vector2(p.x - tip.x, p.z - tip.z).length() if d <= best_d: best = h best_d = d return best func _drag_arrow(viewport_pos: Vector2) -> void: var hit: Variant = _ray_hit(viewport_pos) var node: Node3D = _arrow_grab.get("node") if hit == null or node == null or not is_instance_valid(node): return var c := node.global_position var yaw := SpawnOps.yaw_towards(c, Vector2((hit as Vector3).x, (hit as Vector3).z)) _arrow_yaw = fposmod(snappedf(yaw, ARROW_SNAP_DEG), 360.0) var arrow: MeshInstance3D = view_builder.yaw_arrow_of(node) if arrow != null: arrow.rotation.y = deg_to_rad(_arrow_yaw) main.call("_set_status", "Blickrichtung von %s: %d°" % [str(_arrow_grab["id"]), int(_arrow_yaw)]) func _commit_arrow() -> void: var grab := _arrow_grab _arrow_grab = {} if str(grab["kind"]) == "zone": apply_zone_yaw(str(grab["id"]), _arrow_yaw) return selected_team = int(grab["team"]) selected_id = str(grab["id"]) set_selected_yaw(_arrow_yaw) ## Blickrichtung der ausgewählten Zone setzen (yaw = null: Standard). EIN Undo-Eintrag. func set_selected_yaw(yaw: Variant) -> void: if selected_team < 0 or selected_id == "": return var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc) if not SpawnOps.set_point_yaw(main.doc, selected_team, selected_id, yaw): return spawn_mirror_yaw(main.doc, selected_id, yaw, _sym_mode()) main.history.commit("Blickrichtung von %s" % selected_id, before, SpawnOps.snapshot_blocks(main.history, main.doc)) main.call("_after_structure_change") view_builder.call("set_spawn_selected", selected_team, selected_id) refresh_selection_panel() main.call("_set_status", "Blickrichtung von %s: %s — Strg+Z macht das rückgängig." % [ selected_id, "Standard (zur Kartenmitte)" if yaw == null else "%d°" % int(yaw)]) ## Dasselbe für eine Conquest-Zone (der Griff im Bild, das Panel geht über modus_panel). func apply_zone_yaw(zid: String, yaw: Variant) -> void: var zones: Variant = main.doc.get("conquest_zones", []) if not (zones is Array): return var before := {"conquest_zones": (zones as Array).duplicate(true)} for z in zones: if z is Dictionary and str(z.get("id", "")) == zid: SpawnOps.write_yaw(z, yaw) main.history.commit("Blickrichtung von %s" % zid, before, {"conquest_zones": (zones as Array).duplicate(true)}) main.call("_after_structure_change") main.call("_set_status", "Blickrichtung von %s: %d° — Strg+Z macht das rückgängig." % [zid, int(yaw)]) ## 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 # Symmetrie (M4): Zwilling mit entfernen (innerhalb desselben Undo-Schritts). var mirror_msg: String = spawn_mirror_delete(main.doc, selected_id, _sym_mode()) 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 %s braucht mindestens %d Zonen!" % [ label, "" if mirror_msg.is_empty() else " " + mirror_msg, 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") # ------------------------------------------------- Symmetrie (M4) Statics --- ## Karte t_ffa207b1: Gespiegelte Spawn-Zonen. ENTScheidung (Spec vc27, ## „Teams bleiben einander gegenüber fair"): der Zwilling landet im GEGNER- ## Team — Blau (West) ↔ Rot (Ost), denn genau auf den Gegenseiten liegen die ## Default-Streifen der beiden Teams. Jede gesetzte Zone gibt dem anderen Team ## automatisch die faire Gegenstelle. Alles pure Statics (kein ## UI, kein Node-Zugriff) → headless testbar; spawn_ops.gd bleibt unangetastet. ## Partner-ID einer Spawn-Zone ("" = keiner). static func spawn_mirror_partner(doc: Dictionary, pid: String) -> String: var m := _mirror_map_of(doc) return str(m.get(pid, "")) static func _mirror_map_of(doc: Dictionary) -> Dictionary: var notes = doc.get("design_notes", {}) if not (notes is Dictionary): return {} var m = (notes as Dictionary).get(SPAWN_MIRROR_KEY, {}) return m if m is Dictionary else {} static func _mirror_map_set(doc: Dictionary, pid_a: String, pid_b: String) -> void: var notes = doc.get("design_notes", {}) if not (notes is Dictionary): notes = {} doc["design_notes"] = notes var m = (notes as Dictionary).get(SPAWN_MIRROR_KEY, {}) if not (m is Dictionary): m = {} (notes as Dictionary)[SPAWN_MIRROR_KEY] = m m[pid_a] = pid_b m[pid_b] = pid_a static func _mirror_map_clear(doc: Dictionary, pid_a: String, pid_b: String) -> void: var m := _mirror_map_of(doc) if m.is_empty(): return m.erase(pid_a) m.erase(pid_b) ## Gespiegelte Spawn-Zone zu `pid` anlegen (Symmetrie an). Rückgabe: ## {"ok": bool, "status": Klartext}. ok=false + V9-Text = Zwilling NICHT ## gespeichert (Grenzen scharf — Spec vc27). Auf der Symmetrie-Mitte liegende ## Zonen werden nicht gedoppelt (Spec). static func spawn_mirror_add(doc: Dictionary, team: int, pid: String, mode: int) -> Dictionary: if mode == SymmetryCore.Mode.OFF: return {"ok": true, "status": ""} var original := _spawn_point_of(doc, team, pid) if original.is_empty(): return {"ok": false, "status": "Spawn-Zone %s nicht gefunden — Zwilling nicht gesetzt." % pid} var pos := _spawn_pos_of(original) var center := SymmetryCore.map_center(doc) if SymmetryCore.maps_to_self(mode, pos, center): return {"ok": true, "status": "Spawn-Zone %s liegt auf der Symmetrie-Mitte — nicht gedoppelt." % pid} var mirror_pos := SymmetryCore.mirror_point(mode, pos, center) var bounds: Dictionary = doc.get("bounds", {}) if not bounds.is_empty() and not SvmapIO._inside_bounds( [mirror_pos.x, mirror_pos.y, mirror_pos.z], bounds): return {"ok": false, "status": "❌ V9: Gespiegelter Spawn läge außerhalb der Match-Grenzen — nicht gesetzt."} var twin := SpawnOps.add_zone(doc, 1 - team, mirror_pos.x, mirror_pos.z) var twin_id := str(twin["id"]) SpawnOps.set_radius(doc, twin_id, SpawnOps.zone_radius(doc, pid)) _mirror_map_set(doc, pid, twin_id) if SpawnOps.has_yaw(original): SpawnOps.write_yaw(twin, SymmetryCore.mirror_facing(mode, float(original[SpawnOps.YAW_KEY]))) return {"ok": true, "status": "Gespiegelte Zone %s (%s) gesetzt." % [ twin_id, SpawnOps.team_label(1 - team)]} ## Verschiebung spiegeln: Zwilling bekommt die gespiegelte Position der NEUEN ## Position des Originals (y = Terrain-Höhe am gespiegelten Punkt, wie beim ## normalen Verschieben). Rückgabe: Klartext-Status ("" = nichts zu tun). static func spawn_mirror_move(doc: Dictionary, team: int, pid: String, mode: int) -> String: if mode == SymmetryCore.Mode.OFF: return "" var partner := spawn_mirror_partner(doc, pid) if partner.is_empty(): return "" var original := _spawn_point_of(doc, team, pid) if original.is_empty(): return "" var pos := _spawn_pos_of(original) var center := SymmetryCore.map_center(doc) if SymmetryCore.maps_to_self(mode, pos, center): return "" var mirror_pos := SymmetryCore.mirror_point(mode, pos, center) var partner_team := _team_of_point(doc, partner) if partner_team < 0: return "" if not SpawnOps.move_point(doc, partner_team, partner, mirror_pos.x, mirror_pos.z): return "" return "Gespiegelte Zone %s mitverschoben." % partner ## Blickrichtung spiegeln: Zwilling schaut gespiegelt (yaw = null: auch er auf Standard). static func spawn_mirror_yaw(doc: Dictionary, pid: String, yaw: Variant, mode: int) -> void: if mode == SymmetryCore.Mode.OFF: return var partner := spawn_mirror_partner(doc, pid) var partner_team := _team_of_point(doc, partner) if not partner.is_empty() else -1 if partner_team >= 0: SpawnOps.set_point_yaw(doc, partner_team, partner, null if yaw == null else SymmetryCore.mirror_facing(mode, float(yaw))) ## Löschung spiegeln (entfernen → beide weg, inkl. Radius-Eintrag und ## Partner-Map). Rückgabe: Klartext-Status ("" = nur das Original war weg). static func spawn_mirror_delete(doc: Dictionary, pid: String, mode: int) -> String: if mode == SymmetryCore.Mode.OFF: return "" var partner := spawn_mirror_partner(doc, pid) if partner.is_empty(): return "" var partner_team := _team_of_point(doc, partner) if partner_team < 0: _mirror_map_clear(doc, pid, partner) return "" if SpawnOps.remove_point(doc, partner_team, partner): _mirror_map_clear(doc, pid, partner) return "Gespiegelte Zone %s (%s) mit entfernt." % [partner, SpawnOps.team_label(partner_team)] return "" static func _spawn_point_of(doc: Dictionary, team: int, pid: String) -> Dictionary: for p in SpawnOps.points(doc, team): if p is Dictionary and str(p.get("id", "")) == pid: return p as Dictionary return {} static func _team_of_point(doc: Dictionary, pid: String) -> int: for team in [0, 1]: if not _spawn_point_of(doc, team, pid).is_empty(): return team return -1 static func _spawn_pos_of(point: Dictionary) -> Vector3: var pos: Array = point.get("pos", [0.0, 0.0, 0.0]) return Vector3(float(pos[0]), float(pos[1]), float(pos[2])) # ------------------------------------------------------------ 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) # Symmetrie (M4): Zwilling im Gegner-Team — innerhalb desselben Undo-Schritts. spawn_mirror_add(main.doc, zone_team, pid, _sym_mode()) 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) # Symmetrie (M4): Zwilling folgt der gespiegelten neuen Position. spawn_mirror_move(main.doc, zone_team, pid, _sym_mode()) main.history.commit("%s verschoben" % pid, before, SpawnOps.snapshot_blocks(main.history, main.doc)) ## Aktiver Symmetrie-Modus aus main (M1-Schalter). Defensive über main.get(): ## Mock-Mains (Tests) haben sym_mode nicht — dann gilt OFF (altes Verhalten), ## statt mit SCRIPT ERROR abzustürzen (Lektion: SCRIPT ERROR in -s-Suiten = rot). func _sym_mode() -> int: var v: Variant = main.get("sym_mode") if main != null else null return SymmetryCore.Mode.OFF if v == null else int(v) ## 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)