From e8ab954876f88a7f75157265587c99a466f3b347 Mon Sep 17 00:00:00 2001 From: slyphax Date: Sat, 31 Oct 2026 13:00:02 +0100 Subject: [PATCH] =?UTF-8?q?Editor:=20Blickrichtung=20f=C3=BCr=20Spawn-Punk?= =?UTF-8?q?te=20und=20Conquest-Zonen=20(yaw=5Fdeg,=20optional):=20Pfeil=20?= =?UTF-8?q?im=20Bild,=20Spitze=20ziehen=20oder=20Zahl=20im=20Panel,=20Stan?= =?UTF-8?q?dard-Knopf,=20Undo,=20Symmetrie=20spiegelt=20mit,=20Playtest=20?= =?UTF-8?q?nutzt=20sie;=20Pr=C3=BCfung;=20Test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/editor/playtest/playtest_mode.gd | 18 ++++- src/editor/spawn_ops.gd | 39 ++++++++++ src/editor/spawn_tool.gd | 106 ++++++++++++++++++++++++++- src/editor/symmetry_core.gd | 14 ++++ src/svmap/modus_katalog.gd | 4 + src/svmap/svmap_io.gd | 2 + src/ui/panels/check_panel.gd | 31 ++++++++ src/ui/panels/modus_panel.gd | 16 +++- src/viewport/map_view_builder.gd | 82 +++++++++++++++++++++ tests/test_spawn_yaw.gd | 59 +++++++++++++++ 10 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 tests/test_spawn_yaw.gd diff --git a/src/editor/playtest/playtest_mode.gd b/src/editor/playtest/playtest_mode.gd index dcf6342..62053dd 100644 --- a/src/editor/playtest/playtest_mode.gd +++ b/src/editor/playtest/playtest_mode.gd @@ -87,6 +87,10 @@ func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null body.position = Vector3(start.x, body.terrain_height(start.x, start.z) + PlaytestBody.HALF_H + 0.05, start.z) + if start_override == null: + var first := _first_spawn(doc) + if first.has(SpawnOps.YAW_KEY): + body.yaw_deg = float(first[SpawnOps.YAW_KEY]) # Kollider 1:1 vom Quest-Client (Karte t_1da96275): Terrain-Trimesh + # Objekt-Kollider aus der Registry + Bauteil-Trimeshe. Alles als Kind # dieses Modus — exit() räumt es mit ab. @@ -165,6 +169,15 @@ func _set_ui_visible(vis: bool) -> void: ## Start-Punkt: erster Spawn-Punkt aus dem Dokument (spawn_zones-Team „0", ## erster Eintrag der points-Liste); sonst Kartenmitte aus den Bounds. ## doc-Format (svmap): {"spawn_zones": {"0": {"points": [{"id", "pos": [x,y,z]}]}, ...}} +func _first_spawn(doc: Dictionary) -> Dictionary: + var team0 = (doc.get("spawn_zones", {}) as Dictionary).get("0", {}) + if team0 is Dictionary: + var points: Array = (team0 as Dictionary).get("points", []) + if not points.is_empty() and points[0] is Dictionary: + return points[0] + return {} + + func _spawn_start(doc: Dictionary) -> Vector3: var zones: Dictionary = doc.get("spawn_zones", {}) var team0 = zones.get("0", {}) @@ -393,7 +406,8 @@ func _spawn_point_for(index: int) -> Dictionary: if pos.size() < 3: return {} return {"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])), - "label": str((zone as Dictionary).get("label", "Zone %d" % index))} + "label": str((zone as Dictionary).get("label", "Zone %d" % index)), + "yaw": p.get(SpawnOps.YAW_KEY, null)} ## Sprung zu einem Spawnpunkt (Tasten 1–4): exakt auf den Punkt gesetzt @@ -411,6 +425,8 @@ func jump_to_spawn(index: int) -> bool: body.set("position", Vector3(target.x, y + PlaytestBody.HALF_H + 0.02, target.z)) body.set("velocity", Vector3.ZERO) body.set("on_floor", true) + if sp["yaw"] != null: + body.set("yaw_deg", float(sp["yaw"])) show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [ index, str(sp["label"]), target.x, target.z, y]) return true diff --git a/src/editor/spawn_ops.gd b/src/editor/spawn_ops.gd index de420d4..4d48617 100644 --- a/src/editor/spawn_ops.gd +++ b/src/editor/spawn_ops.gd @@ -19,6 +19,7 @@ const DEFAULT_ZONE_RADIUS := 6.0 # Meter — geräumig für einen Spieler-Sta 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) +const YAW_KEY := "yaw_deg" # Am Punkt/an der Zone selbst, Server und Client lesen es # TDM-Konvention (map_format.md): Team 0 = Blau = Prefix "A" (A1, A2 …), # Team 1 = Rot = Prefix "B" (B1, B2 …). @@ -152,6 +153,44 @@ static func snapshot_blocks(hist: ActionHistory, doc: Dictionary) -> Dictionary: return out +## Blickrichtung beim Spawn (optionales Feld yaw_deg an Spawn-Punkten und Conquest-Zonen). +## Fehlt es, schaut der Spieler zur Kartenmitte — der Pfeil zeigt das grau an. +static func has_yaw(entry: Dictionary) -> bool: + return entry.has(YAW_KEY) + + +static func yaw_towards(from: Vector3, to: Vector2) -> float: + var dx := to.x - from.x + var dz := to.y - from.z + if Vector2(dx, dz).length() < 0.5: + return 0.0 + return fposmod(rad_to_deg(atan2(-dx, -dz)), 360.0) + + +## Die Richtung, die im Spiel gilt: gesetzte oder Standard (zur Kartenmitte). +static func effective_yaw(doc: Dictionary, entry: Dictionary) -> float: + if entry.has(YAW_KEY): + return fposmod(float(entry[YAW_KEY]), 360.0) + var pos: Array = entry.get("pos", [0.0, 0.0, 0.0]) + return yaw_towards(Vector3(float(pos[0]), float(pos[1]), float(pos[2])), SymmetryCore.map_center(doc)) + + +## yaw = Grad setzen, null = zurück auf Standard. +static func write_yaw(entry: Dictionary, yaw: Variant) -> void: + if yaw == null: + entry.erase(YAW_KEY) + else: + entry[YAW_KEY] = fposmod(roundf(float(yaw)), 360.0) + + +static func set_point_yaw(doc: Dictionary, team: int, pid: String, yaw: Variant) -> bool: + for p in points(doc, team): + if p is Dictionary and str(p.get("id", "")) == pid: + write_yaw(p, yaw) + return true + return false + + ## 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). diff --git a/src/editor/spawn_tool.gd b/src/editor/spawn_tool.gd index 493d9da..23df0fe 100644 --- a/src/editor/spawn_tool.gd +++ b/src/editor/spawn_tool.gd @@ -5,6 +5,7 @@ extends Node3D ## 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; @@ -16,7 +17,7 @@ extends Node3D ## 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" +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): @@ -41,6 +42,12 @@ 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 @@ -156,6 +163,9 @@ func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion: var mm := event as InputEventMouseMotion _update_cursor(mm.position) + if not _arrow_grab.is_empty(): + _drag_arrow(mm.position) + return if _dragging and selected_team >= 0: var hit: Variant = _ray_hit(mm.position) if hit == null: @@ -231,6 +241,10 @@ func _terrain_half() -> Vector2: # ----------------------------------------------------- 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) @@ -254,6 +268,9 @@ func _on_left_press(viewport_pos: Vector2) -> void: func _on_left_release() -> void: + if not _arrow_grab.is_empty(): + _commit_arrow() + return if not _dragging: return _dragging = false @@ -312,6 +329,80 @@ func _place_zone(viewport_pos: Vector2) -> void: 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) @@ -451,6 +542,8 @@ static func spawn_mirror_add(doc: Dictionary, team: int, pid: String, mode: int) 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)]} @@ -480,6 +573,17 @@ static func spawn_mirror_move(doc: Dictionary, team: int, pid: String, mode: int 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: diff --git a/src/editor/symmetry_core.gd b/src/editor/symmetry_core.gd index 7c2ddcb..7f975ef 100644 --- a/src/editor/symmetry_core.gd +++ b/src/editor/symmetry_core.gd @@ -96,6 +96,20 @@ static func mirror_rot_y(mode: int, rot_y_deg: float) -> float: return rot_y_deg +## Spiegelt eine Blickrichtung (Grad, Blick = (-sin, 0, -cos)) exakt mit: anders als +## mirror_rot_y zeigt der Pfeil danach wirklich in die gespiegelte Richtung. +static func mirror_facing(mode: int, yaw_deg: float) -> float: + match mode: + Mode.POINT: + return fposmod(yaw_deg + 180.0, 360.0) + Mode.AXIS_X: + return fposmod(-yaw_deg, 360.0) + Mode.AXIS_Z: + return fposmod(180.0 - yaw_deg, 360.0) + _: + return yaw_deg + + ## true, wenn der Punkt unter diesem Modus auf sich selbst abgebildet wird ## (Zentrum bei Punktspiegelung, Spiegelachse bei Achsenspiegelung, ± Toleranz). ## Solche Elemente werden beim gespiegelten Bauen NICHT gedoppelt. diff --git a/src/svmap/modus_katalog.gd b/src/svmap/modus_katalog.gd index 82ce886..89649e2 100644 --- a/src/svmap/modus_katalog.gd +++ b/src/svmap/modus_katalog.gd @@ -202,6 +202,8 @@ static func _validate_zone_list(cps, code: String, what: String, bounds: Diction var r = c.get("radius_m", DEFAULT_RADIUS) if not _num(r) or float(r) < MIN_RADIUS or float(r) > MAX_RADIUS: errors.append("%s: %s '%s': radius_m muss 3–60 sein." % [code, what, str(cid)]) + if c.has("yaw_deg") and not _num(c["yaw_deg"]): + errors.append("%s: %s '%s': yaw_deg muss endlich sein." % [code, what, str(cid)]) return errors @@ -244,6 +246,8 @@ static func _validate_mode_spawns(ms, bounds: Dictionary) -> PackedStringArray: errors.append("V24: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % pid) elif not _inside(pos, bounds): errors.append("V24: Spawn '%s' liegt außerhalb der Match-Grenzen." % pid) + if p.has("yaw_deg") and not _num(p["yaw_deg"]): + errors.append("V24: Spawn '%s': yaw_deg muss endlich sein." % pid) return errors diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index e55894e..72c2a49 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -438,6 +438,8 @@ static func _validate_spawns(zones: Dictionary, bounds: Dictionary) -> PackedStr errors.append("V9: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(pid)) elif not _inside_bounds(pos, bounds): errors.append("V9: Spawn '%s' liegt außerhalb der Match-Grenzen." % str(pid)) + if p.has("yaw_deg") and not _is_finite_num(p["yaw_deg"]): + errors.append("V9: Spawn '%s': yaw_deg muss endlich sein." % str(pid)) return errors diff --git a/src/ui/panels/check_panel.gd b/src/ui/panels/check_panel.gd index 8d8c0f4..7f2c246 100644 --- a/src/ui/panels/check_panel.gd +++ b/src/ui/panels/check_panel.gd @@ -13,6 +13,9 @@ 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 +var spawn_yaw_row: HBoxContainer +var spawn_yaw_spin: SpinBox +var spawn_yaw_reset: Button static func spec() -> Dictionary: @@ -75,6 +78,23 @@ func build() -> void: 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_yaw_row = HBoxContainer.new() + spawn_yaw_row.add_theme_constant_override("separation", UIScale.px(8)) + spawn_yaw_row.add_child(_field_label("Blickrichtung °")) + spawn_yaw_spin = SpinBox.new() + spawn_yaw_spin.min_value = 0.0 + spawn_yaw_spin.max_value = 359.0 + spawn_yaw_spin.step = 1.0 + spawn_yaw_spin.tooltip_text = "Wohin Spieler und Bots hier beim Spawnen schauen. Geht auch im Bild: Pfeilspitze ziehen." + spawn_yaw_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spawn_yaw_spin.value_changed.connect(_on_spawn_yaw_changed) + spawn_yaw_row.add_child(spawn_yaw_spin) + spawn_yaw_reset = Button.new() + spawn_yaw_reset.text = "Standard" + spawn_yaw_reset.tooltip_text = "Keine feste Richtung: Spieler schauen zur Kartenmitte, Bots zum Gegner (grauer Pfeil)" + spawn_yaw_reset.pressed.connect(func() -> void: main.spawn_tool.call("set_selected_yaw", null)) + spawn_yaw_row.add_child(spawn_yaw_reset) + spawn_panel.add_child(spawn_yaw_row) spawn_delete_btn = Button.new() spawn_delete_btn.text = "Zone löschen (Entf)" spawn_delete_btn.custom_minimum_size = UIScale.v2(0, 40) @@ -118,6 +138,11 @@ func _on_spawn_radius_changed(value: float) -> void: main.spawn_tool.call("set_radius", value) +func _on_spawn_yaw_changed(value: float) -> void: + if str(main.get("tool_mode")) == "spawns": + main.spawn_tool.call("set_selected_yaw", value) + + func _on_spawn_delete_pressed() -> void: if str(main.get("tool_mode")) == "spawns": main.spawn_tool.call("delete_selected") @@ -132,7 +157,13 @@ func sync_spawn_selection() -> void: if team < 0 or pid == "": spawn_sel_label.text = "Keine Zone ausgewählt.\nKlicke im Viewport auf einen Kreis." spawn_delete_btn.disabled = true + spawn_yaw_row.visible = false return + spawn_yaw_row.visible = true + for p in SpawnOps.points(main.doc, team): + if p is Dictionary and str(p.get("id", "")) == pid: + spawn_yaw_spin.set_value_no_signal(SpawnOps.effective_yaw(main.doc, p)) + spawn_yaw_reset.disabled = not SpawnOps.has_yaw(p) spawn_sel_label.text = "Ausgewählt: %s (%s)\nVerschieben: klicken+ziehen · Radius: Regler oben" % [ pid, SpawnOps.team_label(team)] spawn_delete_btn.disabled = false diff --git a/src/ui/panels/modus_panel.gd b/src/ui/panels/modus_panel.gd index bad4131..21f92de 100644 --- a/src/ui/panels/modus_panel.gd +++ b/src/ui/panels/modus_panel.gd @@ -195,8 +195,10 @@ func edit_zone(id: String, fields: Dictionary) -> void: var nx := clampf(float(fields.get("x", p[0])), -half.x, half.x) var nz := clampf(float(fields.get("z", p[2])), -half.y, half.y) c["pos"] = [nx, SvmapDocument.terrain_height_at(main.doc, nx, nz), nz] + if fields.has(SpawnOps.YAW_KEY): + SpawnOps.write_yaw(c, fields[SpawnOps.YAW_KEY]) main.doc[key] = list - _commit(before, "%s %s geändert" % [ModusKatalog.ZONE_NAMES[mode], id], "cp_%s" % id if fields.has("x") or fields.has("z") or fields.has("radius_m") else "") + _commit(before, "%s %s geändert" % [ModusKatalog.ZONE_NAMES[mode], id], "cp_%s" % id if fields.has("x") or fields.has("z") or fields.has("radius_m") or fields.has(SpawnOps.YAW_KEY) else "") # ---- Spawn-Sätze ---- @@ -378,6 +380,18 @@ func _row(cp: Dictionary, index := 0, count := 1, mode := "conquest") -> Control nums.add_child(_spin("Radius", "radius_m", id, float(cp.get("radius_m", ModusKatalog.DEFAULT_RADIUS)), ModusKatalog.MIN_RADIUS, ModusKatalog.MAX_RADIUS, 1.0)) box.add_child(nums) + if mode == ModusKatalog.MODE_CONQUEST: + var facing := HBoxContainer.new() + facing.add_theme_constant_override("separation", UIScale.px(4)) + facing.add_child(_spin("Blickrichtung °", SpawnOps.YAW_KEY, id, SpawnOps.effective_yaw(main.doc, cp), 0.0, 359.0, 1.0)) + var reset := Button.new() + reset.name = "BlickStandard" + reset.text = "Standard" + reset.tooltip_text = "Keine feste Richtung: Spieler schauen zur Kartenmitte, Bots zum Gegner. Im Spawn-Werkzeug geht's auch per Pfeilspitze." + reset.disabled = not SpawnOps.has_yaw(cp) + reset.pressed.connect(func() -> void: edit_zone(id, {SpawnOps.YAW_KEY: null})) + facing.add_child(reset) + box.add_child(facing) return box diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 6ec8769..8469d9d 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -512,6 +512,10 @@ func _rebuild_spawn_markers(data: Dictionary) -> void: var marker := _spawn_marker( Vector3(float(pos[0]), float(pos[1]), float(pos[2])), team, pid, SpawnOps.zone_radius(data, pid)) + var yaw_col: Color = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE + marker.set_meta("yaw_color", yaw_col) + marker.add_child(_yaw_arrow(SpawnOps.zone_radius(data, pid), + SpawnOps.effective_yaw(data, p), SpawnOps.has_yaw(p), yaw_col)) spawn_markers.append(marker) add_child(marker) @@ -577,6 +581,10 @@ func rebuild_zone_markers(data: Dictionary) -> void: label.font_size = 160 label.pixel_size = 0.01 node.add_child(label) + if mode == ModusKatalog.MODE_CONQUEST: # Hills sind keine Spawns + node.set_meta("zone_id", str(cp.get("id", ""))) + node.set_meta("yaw_radius", r) + node.add_child(_yaw_arrow(r, SpawnOps.effective_yaw(data, cp), SpawnOps.has_yaw(cp), col)) zone_root.add_child(node) @@ -602,6 +610,73 @@ func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3 return marker +## Pfeil der Blickrichtung: flach am Boden, zeigt lokal nach -Z, rotation.y = Blickrichtung. +## Gesetzte Richtung kräftig, Standard (zur Kartenmitte) blass grau. Ohne Tiefentest, damit +## Hügel und Objekte ihn nicht verdecken. +func _yaw_arrow(radius: float, yaw_deg: float, is_set: bool, col: Color) -> MeshInstance3D: + var length := yaw_arrow_length(radius) + var half_w := maxf(0.25, length * 0.04) + var head_w := half_w * 3.0 + var head_l := minf(length * 0.4, head_w * 2.5) + var neck := -(length - head_l) + var verts := PackedVector3Array([ + Vector3(-half_w, 0, 0), Vector3(half_w, 0, 0), Vector3(half_w, 0, neck), + Vector3(-half_w, 0, 0), Vector3(half_w, 0, neck), Vector3(-half_w, 0, neck), + Vector3(-head_w, 0, neck), Vector3(head_w, 0, neck), Vector3(0, 0, -length), + ]) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.no_depth_test = true + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.albedo_color = col.lightened(0.35) if is_set else Color(0.75, 0.75, 0.75, 0.55) + var arrow := MeshInstance3D.new() + arrow.name = "YawArrow" + arrow.mesh = mesh + arrow.material_override = mat + arrow.position.y = 0.4 + arrow.rotation.y = deg_to_rad(yaw_deg) + arrow.set_meta("yaw_deg", yaw_deg) + arrow.set_meta("yaw_set", is_set) + arrow.set_meta("yaw_length", length) + return arrow + + +static func yaw_arrow_length(radius: float) -> float: + return maxf(radius, 3.0) + 2.0 + + +func yaw_arrow_of(node: Node3D) -> MeshInstance3D: + return node.get_node_or_null("YawArrow") as MeshInstance3D + + +## Alle Pfeile zum Anfassen: [{kind:"spawn"|"zone", team, id, node, tip:Vector3}]. +func yaw_handles() -> Array: + var out: Array = [] + var add := func(node: Node3D, entry: Dictionary) -> void: + var arrow := yaw_arrow_of(node) + if arrow == null: + return + var yaw := deg_to_rad(float(arrow.get_meta("yaw_deg", 0.0))) + var length := float(arrow.get_meta("yaw_length", 0.0)) + entry["node"] = node + entry["tip"] = node.global_position + Vector3(-sin(yaw), 0.0, -cos(yaw)) * length + out.append(entry) + for marker in spawn_markers: + if marker != null and is_instance_valid(marker): + add.call(marker, {"kind": "spawn", "team": int(marker.get_meta("spawn_team", -1)), "id": str(marker.get_meta("spawn_id", ""))}) + if zone_root != null and is_instance_valid(zone_root): + for node in zone_root.get_children(): + if node.has_meta("zone_id"): + add.call(node, {"kind": "zone", "team": -1, "id": str(node.get_meta("zone_id"))}) + return out + + ## Kreisfläche EINES Markers (neu) bauen — Werkzeuge rufen nach Radius-Änderung ## style_spawn_marker auf. U6 (t_639135dd): die Fläche bleibt IMMER gleich ## dezent (Teamfarbe, halbtransparent) — die Auswahl zeigt sich als schmaler @@ -668,6 +743,13 @@ func style_spawn_marker(marker: Node3D, radius: float, selected: bool) -> void: var ring := _spawn_ring(radius) marker.add_child(ring) marker.set_meta("spawn_ring", ring) + var arrow := yaw_arrow_of(marker) + if arrow != null: # Länge folgt dem Radius + var yaw_deg := float(arrow.get_meta("yaw_deg", 0.0)) + var yaw_set := bool(arrow.get_meta("yaw_set", false)) + arrow.name = "YawArrowAlt" + arrow.queue_free() + marker.add_child(_yaw_arrow(radius, yaw_deg, yaw_set, marker.get_meta("yaw_color", Color.WHITE))) ## Auswahl-Highlight über ALLE Spawn-Marker (Werkzeug ruft bei Änderung). diff --git a/tests/test_spawn_yaw.gd b/tests/test_spawn_yaw.gd new file mode 100644 index 0000000..6a732a3 --- /dev/null +++ b/tests/test_spawn_yaw.gd @@ -0,0 +1,59 @@ +extends SceneTree +## Blickrichtung an Spawn-Punkten und Conquest-Zonen (optionales Feld yaw_deg). +## Ausführen: godot --headless --path . --script res://tests/test_spawn_yaw.gd + +var failures := 0 + + +func _check(cond: bool, label: String) -> void: + if not cond: + failures += 1 + print("FAIL: ", label) + + +func _fwd(deg: float) -> Vector2: + var r := deg_to_rad(deg) + return Vector2(-sin(r), -cos(r)) + + +func _init() -> void: + SpawnOps.edit_set = ModusKatalog.SPAWN_SET_BASE + var doc := SvmapDocument.create_new("Blick", 512.0, "mittag") + var p := SpawnOps.add_zone(doc, 0, -50.0, 0.0) + var pid := str(p["id"]) + _check(not SpawnOps.has_yaw(p), "neuer Punkt ohne Feld (alte Karten bleiben gleich)") + # Standard: zur Kartenmitte (0,0) — von -50 aus also nach +X. + _check(_fwd(SpawnOps.effective_yaw(doc, p)).distance_to(Vector2(1, 0)) < 0.01, "Standard zeigt zur Mitte") + _check(SpawnOps.set_point_yaw(doc, 0, pid, 450.0), "setzen") + _check(is_equal_approx(float(p["yaw_deg"]), 90.0), "normalisiert auf 0–360") + SpawnOps.set_point_yaw(doc, 0, pid, null) + _check(not p.has("yaw_deg"), "null räumt das Feld weg") + _check(not SpawnOps.set_point_yaw(doc, 0, "gibtsnicht", 1.0), "unbekannte ID") + + # Spiegeln: die Blickrichtung muss wirklich gespiegelt sein. + for yaw in [0.0, 30.0, 135.0, 290.0]: + var f := _fwd(yaw) + _check(_fwd(SymmetryCore.mirror_facing(SymmetryCore.Mode.AXIS_X, yaw)).distance_to(Vector2(-f.x, f.y)) < 0.01, "Achse X %d" % int(yaw)) + _check(_fwd(SymmetryCore.mirror_facing(SymmetryCore.Mode.AXIS_Z, yaw)).distance_to(Vector2(f.x, -f.y)) < 0.01, "Achse Z %d" % int(yaw)) + _check(_fwd(SymmetryCore.mirror_facing(SymmetryCore.Mode.POINT, yaw)).distance_to(-f) < 0.01, "Punkt %d" % int(yaw)) + + # Zwilling erbt und folgt. + var doc2 := SvmapDocument.create_new("Blick2", 512.0, "mittag") + var a := SpawnOps.add_zone(doc2, 0, -40.0, 10.0) + SpawnOps.write_yaw(a, 30.0) + var tool_script := load("res://src/editor/spawn_tool.gd") + tool_script.spawn_mirror_add(doc2, 0, str(a["id"]), SymmetryCore.Mode.POINT) + var twin_id: String = tool_script.spawn_mirror_partner(doc2, str(a["id"])) + var twin: Dictionary = tool_script._spawn_point_of(doc2, 1, twin_id) + _check(is_equal_approx(float(twin.get("yaw_deg", -1.0)), 210.0), "Zwilling schaut gespiegelt") + tool_script.spawn_mirror_yaw(doc2, str(a["id"]), null, SymmetryCore.Mode.POINT) + _check(not twin.has("yaw_deg"), "Standard gilt auch für den Zwilling") + + # Prüfung: Unsinn wird abgelehnt, das Feld selbst nicht. + var bounds: Dictionary = doc["bounds"] + var zone := {"id": "Z1", "pos": [0.0, 0.0, 0.0], "radius_m": 12.0, "yaw_deg": 45.0} + _check(ModusKatalog._validate_zone_list([zone], "V22", "Zone", bounds).is_empty(), "Zone mit yaw_deg gültig") + zone["yaw_deg"] = "Norden" + _check(not ModusKatalog._validate_zone_list([zone], "V22", "Zone", bounds).is_empty(), "Zone mit Text-yaw abgelehnt") + print("ALL OK" if failures == 0 else "FAILED: %d" % failures) + quit(0 if failures == 0 else 1)