scopedvr-map-core/editor/spawn_tool.gd
2026-11-01 19:31:33 +01:00

713 lines
No EOL
27 KiB
GDScript

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)