713 lines
No EOL
27 KiB
GDScript
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) |