|
|
""" |
|
|
Cinematic Camera System for KAPS Visual Trainer |
|
|
================================================ |
|
|
|
|
|
Dynamic camera perspectives that follow the Buzzard from multiple angles: |
|
|
|
|
|
CAMERA MODES: |
|
|
- CHASE: Behind and above, following velocity vector |
|
|
- ORBIT: Circular orbit around the Buzzard |
|
|
- CINEMATIC: Smooth transitions between dramatic angles |
|
|
- COCKPIT: First-person from Buzzard's perspective |
|
|
- TACTICAL: Top-down overview |
|
|
- SIDE: Traditional side view |
|
|
- DYNAMIC: Automatic scene transitions based on action |
|
|
|
|
|
The camera should always give contextual awareness of: |
|
|
1. Where the Buzzard is going (velocity direction) |
|
|
2. Where threats are coming from |
|
|
3. Where the TAB airfoils are positioned |
|
|
""" |
|
|
|
|
|
import numpy as np |
|
|
from typing import Optional, Tuple, List |
|
|
from enum import Enum, auto |
|
|
from dataclasses import dataclass |
|
|
import time |
|
|
|
|
|
|
|
|
class CameraMode(Enum): |
|
|
"""Available camera perspectives.""" |
|
|
CHASE = auto() |
|
|
ORBIT = auto() |
|
|
CINEMATIC = auto() |
|
|
COCKPIT = auto() |
|
|
TACTICAL = auto() |
|
|
SIDE = auto() |
|
|
DYNAMIC = auto() |
|
|
FREE = auto() |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class CameraState: |
|
|
"""Current camera state.""" |
|
|
position: np.ndarray |
|
|
look_at: np.ndarray |
|
|
up_vector: np.ndarray |
|
|
fov: float = 60.0 |
|
|
|
|
|
|
|
|
target_position: np.ndarray = None |
|
|
target_look_at: np.ndarray = None |
|
|
transition_speed: float = 3.0 |
|
|
|
|
|
def __post_init__(self): |
|
|
if self.target_position is None: |
|
|
self.target_position = self.position.copy() |
|
|
if self.target_look_at is None: |
|
|
self.target_look_at = self.look_at.copy() |
|
|
|
|
|
|
|
|
class CinematicSequence: |
|
|
"""A sequence of camera angles for dramatic effect.""" |
|
|
|
|
|
def __init__(self): |
|
|
self.shots: List[dict] = [] |
|
|
self.current_shot = 0 |
|
|
self.shot_start_time = 0 |
|
|
|
|
|
def add_shot(self, |
|
|
offset: np.ndarray, |
|
|
duration: float, |
|
|
fov: float = 60, |
|
|
look_offset: np.ndarray = None): |
|
|
"""Add a camera shot to the sequence.""" |
|
|
self.shots.append({ |
|
|
'offset': offset, |
|
|
'duration': duration, |
|
|
'fov': fov, |
|
|
'look_offset': look_offset or np.zeros(3) |
|
|
}) |
|
|
|
|
|
def get_current_shot(self, elapsed: float) -> Optional[dict]: |
|
|
"""Get current shot based on elapsed time.""" |
|
|
if not self.shots: |
|
|
return None |
|
|
|
|
|
total = 0 |
|
|
for i, shot in enumerate(self.shots): |
|
|
total += shot['duration'] |
|
|
if elapsed < total: |
|
|
return shot |
|
|
|
|
|
|
|
|
return self.shots[0] |
|
|
|
|
|
|
|
|
class CinematicCamera: |
|
|
""" |
|
|
Dynamic cinematic camera system. |
|
|
|
|
|
Provides multiple perspectives and smooth transitions. |
|
|
""" |
|
|
|
|
|
def __init__(self): |
|
|
|
|
|
self.mode = CameraMode.CHASE |
|
|
self.state = CameraState( |
|
|
position=np.array([0.0, -100.0, 50.0]), |
|
|
look_at=np.array([0.0, 0.0, 0.0]), |
|
|
up_vector=np.array([0.0, 0.0, 1.0]) |
|
|
) |
|
|
|
|
|
|
|
|
self.target_position = np.array([0.0, 0.0, 1000.0]) |
|
|
self.target_velocity = np.array([0.0, 50.0, 0.0]) |
|
|
|
|
|
|
|
|
self.chase_distance = 80.0 |
|
|
self.chase_height = 30.0 |
|
|
self.chase_lag = 0.1 |
|
|
|
|
|
self.orbit_radius = 100.0 |
|
|
self.orbit_speed = 0.3 |
|
|
self.orbit_angle = 0.0 |
|
|
self.orbit_height = 40.0 |
|
|
|
|
|
self.tactical_height = 300.0 |
|
|
|
|
|
|
|
|
self.sequences = self._create_default_sequences() |
|
|
self.active_sequence: Optional[CinematicSequence] = None |
|
|
self.sequence_start_time = 0.0 |
|
|
|
|
|
|
|
|
self.in_transition = False |
|
|
self.transition_progress = 0.0 |
|
|
self.transition_duration = 2.0 |
|
|
self.prev_state: Optional[CameraState] = None |
|
|
|
|
|
|
|
|
self.dynamic_last_change = time.time() |
|
|
self.dynamic_interval = 8.0 |
|
|
self.dynamic_mode_sequence = [ |
|
|
CameraMode.CHASE, |
|
|
CameraMode.ORBIT, |
|
|
CameraMode.SIDE, |
|
|
CameraMode.TACTICAL, |
|
|
CameraMode.CHASE, |
|
|
] |
|
|
self.dynamic_mode_index = 0 |
|
|
|
|
|
def _create_default_sequences(self) -> dict: |
|
|
"""Create default cinematic sequences.""" |
|
|
sequences = {} |
|
|
|
|
|
|
|
|
reveal = CinematicSequence() |
|
|
reveal.add_shot(np.array([0, -150, 20]), 3.0, fov=40) |
|
|
reveal.add_shot(np.array([100, -50, 80]), 2.0, fov=50) |
|
|
reveal.add_shot(np.array([0, -60, 40]), 2.0, fov=60) |
|
|
sequences['reveal'] = reveal |
|
|
|
|
|
|
|
|
action = CinematicSequence() |
|
|
action.add_shot(np.array([-50, -30, 20]), 1.5, fov=70) |
|
|
action.add_shot(np.array([50, -30, 20]), 1.5, fov=70) |
|
|
action.add_shot(np.array([0, 50, 100]), 1.0, fov=50) |
|
|
action.add_shot(np.array([0, -80, 30]), 2.0, fov=60) |
|
|
sequences['action'] = action |
|
|
|
|
|
return sequences |
|
|
|
|
|
def set_mode(self, mode: CameraMode, transition: bool = True): |
|
|
"""Change camera mode with optional transition.""" |
|
|
if mode == self.mode: |
|
|
return |
|
|
|
|
|
if transition: |
|
|
self.prev_state = CameraState( |
|
|
position=self.state.position.copy(), |
|
|
look_at=self.state.look_at.copy(), |
|
|
up_vector=self.state.up_vector.copy(), |
|
|
fov=self.state.fov |
|
|
) |
|
|
self.in_transition = True |
|
|
self.transition_progress = 0.0 |
|
|
|
|
|
self.mode = mode |
|
|
print(f"[CAMERA] Mode: {mode.name}") |
|
|
|
|
|
def start_sequence(self, name: str): |
|
|
"""Start a cinematic sequence.""" |
|
|
if name in self.sequences: |
|
|
self.active_sequence = self.sequences[name] |
|
|
self.sequence_start_time = time.time() |
|
|
print(f"[CAMERA] Starting sequence: {name}") |
|
|
|
|
|
def stop_sequence(self): |
|
|
"""Stop active sequence.""" |
|
|
self.active_sequence = None |
|
|
|
|
|
def update(self, |
|
|
target_pos: np.ndarray, |
|
|
target_vel: np.ndarray, |
|
|
dt: float, |
|
|
threats: List = None) -> Tuple[np.ndarray, np.ndarray, float]: |
|
|
""" |
|
|
Update camera position. |
|
|
|
|
|
Returns: (position, look_at, fov) |
|
|
""" |
|
|
self.target_position = target_pos |
|
|
self.target_velocity = target_vel |
|
|
|
|
|
|
|
|
if self.active_sequence: |
|
|
elapsed = time.time() - self.sequence_start_time |
|
|
shot = self.active_sequence.get_current_shot(elapsed) |
|
|
if shot: |
|
|
new_pos = target_pos + shot['offset'] |
|
|
new_look = target_pos + shot['look_offset'] |
|
|
self.state.target_position = new_pos |
|
|
self.state.target_look_at = new_look |
|
|
self.state.fov = shot['fov'] |
|
|
|
|
|
|
|
|
elif self.mode == CameraMode.DYNAMIC: |
|
|
now = time.time() |
|
|
if now - self.dynamic_last_change > self.dynamic_interval: |
|
|
self.dynamic_last_change = now |
|
|
self.dynamic_mode_index = (self.dynamic_mode_index + 1) % len(self.dynamic_mode_sequence) |
|
|
next_mode = self.dynamic_mode_sequence[self.dynamic_mode_index] |
|
|
|
|
|
self._compute_mode_position(next_mode, threats) |
|
|
|
|
|
else: |
|
|
|
|
|
self._compute_mode_position(self.mode, threats) |
|
|
|
|
|
|
|
|
if self.in_transition: |
|
|
self.transition_progress += dt / self.transition_duration |
|
|
if self.transition_progress >= 1.0: |
|
|
self.transition_progress = 1.0 |
|
|
self.in_transition = False |
|
|
self.prev_state = None |
|
|
|
|
|
t = self._ease_in_out(self.transition_progress) |
|
|
if self.prev_state: |
|
|
self.state.position = self._lerp(self.prev_state.position, |
|
|
self.state.target_position, t) |
|
|
self.state.look_at = self._lerp(self.prev_state.look_at, |
|
|
self.state.target_look_at, t) |
|
|
else: |
|
|
|
|
|
alpha = min(1.0, dt * self.state.transition_speed) |
|
|
self.state.position = self._lerp(self.state.position, |
|
|
self.state.target_position, alpha) |
|
|
self.state.look_at = self._lerp(self.state.look_at, |
|
|
self.state.target_look_at, alpha) |
|
|
|
|
|
return self.state.position, self.state.look_at, self.state.fov |
|
|
|
|
|
def _compute_mode_position(self, mode: CameraMode, threats: List = None): |
|
|
"""Compute target camera position for a mode.""" |
|
|
pos = self.target_position |
|
|
vel = self.target_velocity |
|
|
|
|
|
|
|
|
speed = np.linalg.norm(vel) |
|
|
if speed > 0.1: |
|
|
vel_dir = vel / speed |
|
|
else: |
|
|
vel_dir = np.array([0, 1, 0]) |
|
|
|
|
|
if mode == CameraMode.CHASE: |
|
|
|
|
|
behind = -vel_dir * self.chase_distance |
|
|
up = np.array([0, 0, self.chase_height]) |
|
|
self.state.target_position = pos + behind + up |
|
|
self.state.target_look_at = pos + vel_dir * 20 |
|
|
|
|
|
elif mode == CameraMode.ORBIT: |
|
|
|
|
|
self.orbit_angle += 0.016 * self.orbit_speed |
|
|
x = self.orbit_radius * np.cos(self.orbit_angle) |
|
|
y = self.orbit_radius * np.sin(self.orbit_angle) |
|
|
self.state.target_position = pos + np.array([x, y, self.orbit_height]) |
|
|
self.state.target_look_at = pos |
|
|
|
|
|
elif mode == CameraMode.COCKPIT: |
|
|
|
|
|
forward = vel_dir * 5 |
|
|
self.state.target_position = pos + np.array([0, 0, 2]) |
|
|
self.state.target_look_at = pos + forward * 50 |
|
|
|
|
|
elif mode == CameraMode.TACTICAL: |
|
|
|
|
|
self.state.target_position = pos + np.array([0, 0, self.tactical_height]) |
|
|
self.state.target_look_at = pos |
|
|
|
|
|
elif mode == CameraMode.SIDE: |
|
|
|
|
|
right = np.cross(vel_dir, np.array([0, 0, 1])) |
|
|
if np.linalg.norm(right) < 0.1: |
|
|
right = np.array([1, 0, 0]) |
|
|
else: |
|
|
right = right / np.linalg.norm(right) |
|
|
self.state.target_position = pos + right * 80 + np.array([0, 0, 20]) |
|
|
self.state.target_look_at = pos |
|
|
|
|
|
elif mode == CameraMode.CINEMATIC: |
|
|
|
|
|
behind = -vel_dir * 40 |
|
|
self.state.target_position = pos + behind + np.array([20, 0, -10]) |
|
|
self.state.target_look_at = pos + vel_dir * 30 |
|
|
|
|
|
elif mode == CameraMode.FREE: |
|
|
|
|
|
pass |
|
|
|
|
|
def _lerp(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: |
|
|
"""Linear interpolation.""" |
|
|
return a + (b - a) * t |
|
|
|
|
|
def _ease_in_out(self, t: float) -> float: |
|
|
"""Smooth easing function.""" |
|
|
return t * t * (3 - 2 * t) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_orbit_drag(self, dx: float, dy: float): |
|
|
"""Handle mouse drag for orbit control.""" |
|
|
if self.mode == CameraMode.FREE: |
|
|
|
|
|
self.orbit_angle += dx * 0.01 |
|
|
self.orbit_height = np.clip(self.orbit_height + dy * 2, 10, 200) |
|
|
|
|
|
def handle_zoom(self, direction: float): |
|
|
"""Handle zoom in/out.""" |
|
|
factor = 0.9 if direction > 0 else 1.1 |
|
|
self.chase_distance = np.clip(self.chase_distance * factor, 30, 300) |
|
|
self.orbit_radius = np.clip(self.orbit_radius * factor, 40, 400) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CAMERA_KEYBINDS = { |
|
|
'c': 'cycle_mode', |
|
|
'v': CameraMode.CHASE, |
|
|
'b': CameraMode.ORBIT, |
|
|
'n': CameraMode.TACTICAL, |
|
|
'm': CameraMode.DYNAMIC, |
|
|
',': CameraMode.COCKPIT, |
|
|
} |
|
|
|