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

454 lines
No EOL
17 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)