extends Camera3D ## Orbit-Kamera + Freelook-Flug für den Editor-Viewport. ## Steuerung: ## Mittlere Maustaste ziehen = Drehen (Orbit) ## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden) ## Mausrad = Zoom — AUSSER ein Objekt/eine Platzier-Vorschau ## ist aktiv: dann dreht das Mausrad das Objekt ## (interaction.gd meldet das per wheel_filter). ## Rechte Maustaste GEDACHT halten = Freelook-Flug (Karte t_c46d31c2): Umsehen + ## W vor / S zurück / A links / D rechts, ## Leertaste hoch / Shift runter (Shooter- ## Konvention, Sly-Wunsch 17.10.), ## Strg = schneller, Alt = langsamer. ## Mausrad ändert im Flug die ## GRUNDgeschwindigkeit dauerhaft. ## Umschalt+F (main.gd) = Freelook dauerhaft an/aus (Flug bleibt auch ## ohne gedrückte rechte Maustaste aktiv). ## F (main.gd) = Auswahl ins Bild zentrieren → focus_on(). ## Orbit bleibt der Default-Zustand: Beim Verlassen des Flugs werden Orbit- ## Ziel/Winkel/Abstand aus der Flugposition/Blickrichtung zurückgerechnet. ## Die Mathe lebt in core/editor/fly_math.gd (pure, headless testbar). ## Ziel-Punkt, Winkel und Abstand bleiben intern; Position wird jede Frame ## aus ihnen berechnet (Orbit) bzw. im Flug direkt bewegt. ## Karte K1 (t_950fddaa): Beim Dreh-Start liegt der Drehpunkt UNTER DEM ## MAUSZEIGER (Unity/Blender-Verhalten) — Objekt/Bauteil-Treffer zuerst, sonst ## Terrain, sonst alter Ziel-Punkt. Kameraposition und Blickrichtung bleiben ## exakt unverändert (kein Sprung), nur Ziel/Winkel/Abstand werden neu ## gerechnet. fit_to_bounds() ersetzt frame_map(): ganze Map ins Bild. const FlyMath := preload("res://core/editor/fly_math.gd") const TerrainField := preload("res://core/svmap/terrain_field.gd") const SvmapIO := preload("res://core/svmap/svmap_io.gd") const ORBIT_DEG_PER_PX := 0.3 # Karte K1: Drehgeschwindigkeit gekapselt — kein Nachlaufen const DISTANCE_MIN := 5.0 const FIT_FLOOR_MIN := 30.0 # fit_to_bounds: nie näher als 30 m an die Map ran ## Karte K1d (t_6c1027c1): Startabstand nach Laden/Neu — Slys Wahl („Nah dran: ## startet ~80 m über der Kartenmitte, Objekte sind sofort groß und greifbar"). const START_DISTANCE_M := 80.0 const START_YAW_DEG := 45.0 const START_PITCH_DEG := 35.0 var target := Vector3.ZERO var yaw_deg := 45.0 var pitch_deg := 35.0 var distance := 300.0 ## Von main.gd gesetzt: Callable → true, wenn das Mausrad gerade ein Objekt ## drehen soll (Platzier-Modus oder Auswahl) statt zu zoomen. var wheel_filter := Callable() ## Von main.gd gesetzt: Callable für Klartext-Status-Meldungen. var status_cb := Callable() ## Grundgeschwindigkeit des Flugs (Mausrad ändert sie dauerhaft, Karte 1/3). var base_speed_mps := FlyMath.BASE_SPEED_MPS var _dragging := false var _panning := false var _fly_held := false # Rechte Maustaste gehalten → Flug temporär aktiv var _fly_locked := false # Umschalt+F: Flug dauerhaft (auch ohne RMT) var _flying := false # Ergebnis-Flag: gerade im Flugmodus? var _fly_yaw := 0.0 var _fly_pitch := 0.0 var _fit_bounds_mn := Vector3.ZERO # zuletzt gesetzte Map-Bounds (fit_to_bounds) var _fit_bounds_mx := Vector3.ZERO var _fit_bounds_valid := false func _ready() -> void: _apply() func _process(delta: float) -> void: if not _flying or not InputMap.has_action("editor_fly_forward"): return var f := Input.get_action_strength("editor_fly_forward") - Input.get_action_strength("editor_fly_back") var s := Input.get_action_strength("editor_fly_right") - Input.get_action_strength("editor_fly_left") var v := Input.get_action_strength("editor_fly_up") - Input.get_action_strength("editor_fly_down") if f == 0.0 and s == 0.0 and v == 0.0: return var speed := FlyMath.speed_for(base_speed_mps, Input.is_action_pressed("editor_fly_fast"), Input.is_action_pressed("editor_fly_slow")) var forward := FlyMath.forward_from_euler(_fly_yaw, _fly_pitch) var right := FlyMath.forward_from_euler(_fly_yaw, 0.0).cross(Vector3.UP).normalized() position += FlyMath.move_delta(forward, right, f, s, v, speed, delta) _apply_fly_orientation() func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey: # Karte K1: Pos1/Home = ganze Karte ins Bild (fit_to_bounds). _handle_home_key(event as InputEventKey) if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_RIGHT: # Karte t_2470de89: RMB = Flug mit ABSOLUTEM Vorrang — kein Filter, # keine Heuristik. Werkzeuge behandeln RMB nicht mehr, also startet # der Flug in JEDEM Werkzeugzustand (auch Terrain/Platzieren/Bauen). if mb.pressed: _enter_fly() _fly_held = true elif _fly_held: _fly_held = false if not _fly_locked: _exit_fly() elif mb.button_index == MOUSE_BUTTON_MIDDLE: if _flying: return # Im Flug: MMT-Ziehen bleibt unangetastet (kein Orbit-Sprung) if mb.pressed: # Karte K1: Drehpunkt unter den Mauszeiger legen — VOR dem # ersten Ziehen, damit kein Sprung passiert. _pivot_to_cursor(mb.position) _dragging = mb.pressed and not mb.shift_pressed _panning = mb.pressed and mb.shift_pressed elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: if _flying: base_speed_mps = FlyMath.wheel_speed(base_speed_mps, true) _status("Flug-Tempo: %.0f m/s (Grundgeschwindigkeit, bleibt dauerhaft)." % base_speed_mps) return if _wheel_blocked(): return distance = maxf(DISTANCE_MIN, distance * 0.9) _apply() elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: if _flying: base_speed_mps = FlyMath.wheel_speed(base_speed_mps, false) _status("Flug-Tempo: %.0f m/s (Grundgeschwindigkeit, bleibt dauerhaft)." % base_speed_mps) return if _wheel_blocked(): return distance = minf(distance * 1.1, distance_max()) _apply() elif event is InputEventMouseMotion: var mm := event as InputEventMouseMotion if _flying: if _fly_held: # Umsehen: volle 360° Yaw, Pitch −85…+85 (auch von unten hoch). _fly_yaw = wrapf(_fly_yaw - mm.relative.x * 0.25, -180.0, 180.0) _fly_pitch = clampf(_fly_pitch + mm.relative.y * 0.25, -FlyMath.PITCH_MAX_DEG, FlyMath.PITCH_MAX_DEG) _apply_fly_orientation() return if _dragging: yaw_deg = wrapf(yaw_deg - mm.relative.x * ORBIT_DEG_PER_PX, -180.0, 180.0) pitch_deg = clampf(pitch_deg + mm.relative.y * ORBIT_DEG_PER_PX, 5.0, 85.0) _apply() elif _panning: var right := transform.basis.x var fwd := -transform.basis.z fwd.y = 0.0 fwd = fwd.normalized() var scale := distance * 0.0015 target -= right * mm.relative.x * scale target += fwd * mm.relative.y * scale _apply() func _wheel_blocked() -> bool: return wheel_filter.is_valid() and bool(wheel_filter.call()) func distance_max() -> float: return 6000.0 ## Freelook dauerhaft an/aus (Umschalt+F, main.gd ruft an). func toggle_fly_lock() -> void: if _flying: _fly_locked = false if not _fly_held: _exit_fly() _status("Freelook-Flug aus — zurück zum Orbit (Mittlere Maustaste ziehen = drehen).") else: _fly_locked = true _enter_fly() _status("Freelook-Flug DAUERHAFT an: W/S/A/D fliegen, Leertaste hoch / Shift runter, Strg schneller, Alt langsamer — Umschalt+F zurück zum Orbit.") ## Im Flugmodus? (interaction.gd blockt z. B. Mausrad-Objektdrehung dann ab.) func fly_active() -> bool: return _flying ## Aktueller Gierwinkel der BLICKRICHTUNG (Karte t_55257792): im Flug der ## Flug-Yaw, im Orbit der Orbit-Yaw. Für die Pfeiltasten-Richtung (MoveMath ## rastet sie auf die nächste Weltachse). func view_yaw_deg() -> float: return _fly_yaw if _flying else yaw_deg ## Auswahl ins Bild zentrieren (F): Orbit → Ziel-Punkt auf den Auswahl-Mittelpunkt; ## Flug → Blickrichtung drehen, Position bleibt. func focus_on(center: Vector3) -> void: if _flying: var to := center - position if to.length_squared() < 0.01: return var ep := FlyMath.euler_from_forward(to.normalized()) _fly_yaw = ep.x _fly_pitch = clampf(ep.y, -FlyMath.PITCH_MAX_DEG, FlyMath.PITCH_MAX_DEG) _apply_fly_orientation() else: target = center _apply() func _enter_fly() -> void: if _flying: return _fly_yaw = yaw_deg _fly_pitch = pitch_deg _flying = true # Konflikt-Check (Sly-Wunsch 17.10.): Leertaste ist jetzt „hoch fliegen" — # ein fokussierter Button würde Space/Enter als Klick interpretieren # (Bibliothek-Button = Platzierung starten). Im Flug gehört die Leertaste # der Kamera, also GUI-Fokus freigeben. var vp := get_viewport() if vp != null and vp.gui_get_focus_owner() != null: vp.gui_release_focus() _status("Freelook-Flug an: W/S/A/D fliegen, Leertaste hoch / Shift runter, Strg = schnell, Alt = langsam, Mausrad = Grund-Tempo. Rechte Maustaste loslassen = zurück zum Orbit.") func _exit_fly() -> void: if not _flying: return _flying = false # Orbit-Zustand aus Flugposition/Blickrichtung zurückrechnen — kein Sprung. var fwd := -transform.basis.z if fwd.length_squared() < 0.0000001: fwd = FlyMath.forward_from_euler(yaw_deg, pitch_deg) var ep := FlyMath.euler_from_forward(fwd) yaw_deg = ep.x pitch_deg = clampf(ep.y, 5.0, 85.0) distance = clampf(distance, 30.0, distance_max()) target = position + fwd.normalized() * distance _apply() func _apply_fly_orientation() -> void: var fwd := FlyMath.forward_from_euler(_fly_yaw, _fly_pitch) look_at_from_position(position, position + fwd, Vector3.UP) ## Karte K1 (t_950fddaa): Drehpunkt unter den Mauszeiger — aus der unver- ## änderten Kameraposition werden Ziel/Winkel/Abstand neu gerechnet. func set_pivot(hit: Vector3) -> void: var pos := position var fwd := -transform.basis.z if fwd.length_squared() < 0.0000001: fwd = FlyMath.forward_from_euler(yaw_deg, pitch_deg) var ep := FlyMath.euler_from_forward(fwd.normalized()) target = hit distance = pos.distance_to(hit) yaw_deg = ep.x pitch_deg = ep.y # Bewusst KEIN _apply(): Position und Basis bleiben exakt wie vorher — # kein Sprung. Der nächste Dreh-/Zoom-Schritt rechnet aus dem neuen # Zustand dieselbe Position zurück (Offset-Konvention konsistent). ## MMT-Druck: Punkt unter dem Cursor bestimmen — Objekt/Bauteil zuerst, sonst ## Terrain/Boden, sonst aktuellen Ziel-Punkt behalten (kein Sprung). func _pivot_to_cursor(viewport_pos: Vector2) -> void: var inter := _find_sibling("_pick_object") if inter != null: var idx := int(inter.call("_pick_object", viewport_pos)) if idx >= 0: var pt: Variant = _object_pivot_point(idx) if pt != null: set_pivot(pt) return var g: Variant = inter.call("_ground_point", viewport_pos) if g != null: set_pivot(g) return # Kein Treffer: Ziel-Punkt unverändert lassen — Ansicht darf nicht springen. ## Mittelpunkt des Objekt-Markers (Fußpunkt + halbe Höhe — gleiche Konvention ## wie interaction.gd beim F-Tasten-Zentrieren). func _object_pivot_point(idx: int) -> Variant: var builder := _find_builder() if builder == null: return null var markers: Array = builder.get("object_markers") if markers == null or idx < 0 or idx >= (markers as Array).size(): return null var marker = (markers as Array)[idx] if marker == null or not is_instance_valid(marker): return null var half_h := float(marker.get_meta("half_height", 0.0)) return (marker as Node3D).global_position + Vector3.UP * half_h func _find_sibling(has_method_name: String) -> Node: var p := get_parent() if p == null: return null for child in p.get_children(): var c := child as Node if c != self and c.has_method(has_method_name): return c return null func _find_builder() -> Node: var p := get_parent() if p == null: return null for child in p.get_children(): var c := child as Node if c != self and c.get("object_markers") != null: return c return null ## Fit: target = Mitte der bounds; Abstand so, dass die GANZE Map im Frustum ## liegt; Pos1 ruft das mit den zuletzt gesetzten Bounds erneut auf. Ersetzt ## das tote frame_map(). func fit_to_bounds(mn: Vector3, mx: Vector3) -> void: _fit_bounds_mn = Vector3(minf(mn.x, mx.x), minf(mn.y, mx.y), minf(mn.z, mx.z)) _fit_bounds_mx = Vector3(maxf(mn.x, mx.x), maxf(mn.y, mx.y), maxf(mn.z, mx.z)) _fit_bounds_valid = true _fly_held = false _fly_locked = false _flying = false var span := _fit_bounds_mx - _fit_bounds_mn target = _fit_bounds_mn + span * 0.5 # Karte K1: Abstand über Umkugel-Formel — ALLE 8 Ecken sicher im Bild. # (Reiner Faktor der Kantenlänge reicht bei flachen Maps nicht: die nahe # Oberecke liegt dann fast seitlich zur Blickachse.) distance = clampf(_fit_distance(span.length() * 0.5), FIT_FLOOR_MIN, distance_max()) yaw_deg = 45.0 pitch_deg = 35.0 _apply() ## Karte K1d (t_6c1027c1, Neuauflage von K1c): Startansicht NAH am Karteninhalt. ## Laden/Neu setzt die Kamera auf die KARTENMITTE (XZ-Mitte der Map-Bounds, ## Höhe = Gelände dort) mit festem Abstand START_DISTANCE_M (80 m), yaw 45°, ## pitch 35° — Objekte sind ## sofort groß und greifbar (Slys Wahl), statt die 1-km-Fläche von weitem zu ## zeigen. Die Regel ist bewusst UNABHÄNGIG von Größe/Lage des Inhalts: immer ## 80 m. Pos1 (Home) bleibt die GANZE Karte — dafür werden hier die MAP-Bounds ## gemerkt (NICHT die Content-Bounds), sodass fit_to_bounds danach die Karte ## einpasst; Startansicht und Pos1 sind also zwei verschiedene Ansichten. func fit_to_document(doc: Dictionary, map_mn: Vector3, map_mx: Vector3) -> void: var mn := Vector3(minf(map_mn.x, map_mx.x), minf(map_mn.y, map_mx.y), minf(map_mn.z, map_mx.z)) var mx := Vector3(maxf(map_mn.x, map_mx.x), maxf(map_mn.y, map_mx.y), maxf(map_mn.z, map_mx.z)) _fly_held = false _fly_locked = false _flying = false # Pos1 = ganze Karte: MAP-Bounds merken (NICHT die Content-Bounds). _fit_bounds_mn = mn _fit_bounds_mx = mx _fit_bounds_valid = true # Kartenmitte in XZ — in y aber die GELÄNDE-Oberfläche, nicht die Mitte der # Bounds: die Bounds reichen bei neuen Maps von −10 bis 80 m, ihre Mitte # (35 m) liegt 35 m über dem Boden. Damit hätte die Ansicht 35 m über den # Boden gezielt und die Objekte stünden am unteren Bildrand. var mitte := mn + (mx - mn) * 0.5 target = Vector3(mitte.x, SvmapDocument.terrain_height_at(doc, mitte.x, mitte.z), mitte.z) distance = clampf(START_DISTANCE_M, DISTANCE_MIN, distance_max()) yaw_deg = START_YAW_DEG pitch_deg = START_PITCH_DEG _apply() var count := int(content_bounds(doc)["count"]) if count == 0: _status("Ansicht: Kartenmitte, Abstand %d m (kein Inhalt — Pos1 = ganze Karte)." % int(START_DISTANCE_M)) else: _status("Ansicht: Kartenmitte, Abstand %d m (Pos1 = ganze Karte)." % int(START_DISTANCE_M)) ## Bounding-Box des Dokument-INHALTS: Objekte (inkl. Bauteile) und ## Spawnpunkte — Terrain und Map-Bounds zählen NICHT (Karte K1b). ## Rückgabe: {mn: Vector3, mx: Vector3, count: int}; count 0 = leeres Dokument. static func content_bounds(doc: Dictionary) -> Dictionary: var mn := Vector3(INF, INF, INF) var mx := Vector3(-INF, -INF, -INF) var count := 0 for p: Vector3 in _content_points(doc.get("objects", []) as Array): mn = mn.min(p) mx = mx.max(p) count += 1 var zones: Dictionary = doc.get("spawn_zones", {}) as Dictionary for team_key in zones: var zone: Dictionary = zones[team_key] if zones[team_key] is Dictionary else {} for p: Vector3 in _content_points(zone.get("points", []) as Array): mn = mn.min(p) mx = mx.max(p) count += 1 if count == 0: return {"mn": Vector3.ZERO, "mx": Vector3.ZERO, "count": 0} return {"mn": mn, "mx": mx, "count": count} ## pos-Felder [x, y, z] aus einer Dokument-Liste lesen; defekte Einträge ## überspringen (stillschweigend, nie ein harter Fehler beim Laden). static func _content_points(items: Array) -> Array: var pts: Array = [] for item in items: if not (item is Dictionary): continue var pos: Variant = (item as Dictionary).get("pos") if pos is Array and (pos as Array).size() >= 3: pts.append(Vector3(float(pos[0]), float(pos[1]), float(pos[2]))) return pts ## Abstand, damit die Umkugel (Radius um den Ziel-Punkt) komplett sichtbar ## bleibt: d = R / sin(halber FOV der SCHMALEREN Bildachse) + 2 % Reserve. func _fit_distance(radius: float) -> float: var vfov_half := deg_to_rad(fov * 0.5) # fov ist vertikal (KEEP_HEIGHT) var aspect := 16.0 / 9.0 var vp := get_viewport() if vp != null: var vs := vp.get_visible_rect().size if vs.y > 0.0: aspect = vs.x / vs.y var hfov_half := atan(aspect * tan(vfov_half)) var half := minf(vfov_half, hfov_half) if half <= 0.0 or half >= PI * 0.5: half = deg_to_rad(37.5) return maxf(radius, 1.0) / sin(half) * 1.02 func _handle_home_key(key: InputEventKey) -> void: if key.pressed and not key.echo and key.keycode == KEY_HOME: if _fit_bounds_valid: fit_to_bounds(_fit_bounds_mn, _fit_bounds_mx) _status("Pos1: ganze Karte ins Bild.") func _apply() -> void: var pitch := deg_to_rad(pitch_deg) var yaw := deg_to_rad(yaw_deg) var offset := Vector3( sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch) ) * distance position = target + offset look_at(target, Vector3.UP) func _status(text: String) -> void: if status_cb.is_valid(): status_cb.call(text)