"""Camera tracking and coordinate transforms for PyCairo with graceful leader switching.""" from __future__ import annotations import math from raceforge.schemas.timeline import FrameRacerTransform class FollowLeaderCamera: """Critically-damped broadcast camera with leader hysteresis and battle cluster centroid. Eliminates camera jitter, snaps, and shaking when two or more squares are in close proximity or rapidly alternating ranks. """ def __init__( self, viewport_w: float = 1080.0, viewport_h: float = 1920.0, stiffness: float = 0.08, dead_zone_px: float = 40.0, ) -> None: self.vw = viewport_w self.vh = viewport_h self.stiffness = stiffness self.dead_zone = dead_zone_px self.cam_x = viewport_w / 2.0 self.cam_y = viewport_h / 2.0 self.vel_x = 0.0 self.vel_y = 0.0 self.zoom = 1.55 self.vel_zoom = 0.0 # Leader switching hysteresis (prevents alternating rank chattering) self.locked_leader_id: str | None = None self.candidate_leader_id: str | None = None self.candidate_frames: int = 0 self.switch_threshold_frames: int = 15 # ~0.25s sustained lead to switch primary # Critically damped spring parameters self.omega = 4.0 # Natural frequency (rad/s) self.dt = 1.0 / 60.0 # 60 FPS standard timestep self.max_vel = 280.0 # Max pan velocity in px/s (~4.67 px/frame) def update( self, frame_idx: int, total_frames: int, racers: list[FrameRacerTransform], ) -> tuple[float, float, float]: """Returns (cam_x, cam_y, zoom) for current frame with guaranteed C^2 smoothness.""" if not racers: return (self.cam_x, self.cam_y, self.zoom) # 1. Determine Stable Leader using Hysteresis raw_leader = min(racers, key=lambda r: r.rank) if self.locked_leader_id is None: self.locked_leader_id = raw_leader.id.value self.candidate_leader_id = raw_leader.id.value self.candidate_frames = 0 elif raw_leader.id.value != self.locked_leader_id: if raw_leader.id.value == self.candidate_leader_id: self.candidate_frames += 1 # Switch if new leader has finished or held sustained lead if raw_leader.has_finished or self.candidate_frames >= self.switch_threshold_frames: self.locked_leader_id = raw_leader.id.value self.candidate_frames = 0 else: self.candidate_leader_id = raw_leader.id.value self.candidate_frames = 1 else: self.candidate_frames = 0 self.candidate_leader_id = self.locked_leader_id # 2. Predictive Look-Ahead & Duel Axis Centroid # Anticipates velocity heading so camera looks down the corridor before racers hit corners active_racers = [r for r in racers if not getattr(r, "is_eliminated", False)] or racers r_sorted = sorted(active_racers, key=lambda r: r.rank) leader = r_sorted[0] second = r_sorted[1] if len(r_sorted) > 1 else leader # Predictive lookahead: anticipate trajectory by ~0.16s lookahead_t = 0.16 pred_positions: list[tuple[float, float]] = [] weights: list[float] = [] for r in active_racers: px = r.x + r.vx * lookahead_t py = r.y + r.vy * lookahead_t w = 2.4 / (r.rank ** 0.5) weights.append(w) pred_positions.append((px, py)) tot_w = sum(weights) pack_x = sum(w * p[0] for w, p in zip(weights, pred_positions)) / max(1e-6, tot_w) pack_y = sum(w * p[1] for w, p in zip(weights, pred_positions)) / max(1e-6, tot_w) # Duel axis blend (when Rank 1 and 2 are in a close duel, bias framing toward the battle line) duel_x = (leader.x + second.x) / 2.0 + (leader.vx + second.vx) * 0.5 * lookahead_t duel_y = (leader.y + second.y) / 2.0 + (leader.vy + second.vy) * 0.5 * lookahead_t duel_dist = math.hypot(leader.x - second.x, leader.y - second.y) duel_weight = max(0.0, min(0.60, 1.0 - (duel_dist / 380.0))) target_x = duel_weight * duel_x + (1.0 - duel_weight) * pack_x target_y = duel_weight * duel_y + (1.0 - duel_weight) * pack_y # 3. Smart Adaptive Pack Zoom (Golden Sweet-Spot: 1.38x to 1.72x) xs = [r.x for r in active_racers] ys = [r.y for r in active_racers] spread_x = max(xs) - min(xs) if xs else 100.0 spread_y = max(ys) - min(ys) if ys else 100.0 finish_near = frame_idx > (total_frames - 120) has_winner = any(r.has_finished for r in racers) if has_winner: target_zoom = 1.80 elif finish_near or leader.y > 1520.0: target_zoom = 1.68 elif duel_dist < 130.0: # Intimate neck-and-neck dogfight target_zoom = 1.68 else: # Fit all active contenders comfortably with safe margin padding fit_zoom_x = (self.vw - 180.0) / max(220.0, spread_x + 120.0) fit_zoom_y = (self.vh - 380.0) / max(320.0, spread_y + 180.0) ideal_pack_zoom = min(fit_zoom_x, fit_zoom_y) target_zoom = max(1.38, min(1.68, ideal_pack_zoom)) # Dynamic spring response: faster tracking when racers hit sonic speed boosts max_active_spd = max((math.hypot(r.vx, r.vy) for r in active_racers), default=300.0) eff_omega = self.omega * (1.30 if max_active_spd > 460.0 else 1.0) f_zoom = 2.0 * eff_omega * (target_zoom - self.zoom) - 2.0 * eff_omega * self.vel_zoom self.vel_zoom += f_zoom * self.dt self.zoom += self.vel_zoom * self.dt self.zoom = max(1.35, min(1.85, self.zoom)) # 4. Soft Clamping (Keep camera centered on playable arena with 44% upper-bias framing) half_vw = (self.vw / self.zoom) / 2.0 half_vh = (self.vh / self.zoom) / 2.0 # Bias framing slightly down so racers appear at ~44% screen height, showing path ahead biased_target_y = target_y + (self.vh * 0.05) / self.zoom clamped_target_x = max(half_vw, min(self.vw - half_vw, target_x)) clamped_target_y = max(half_vh, min(self.vh - half_vh, biased_target_y)) # 5. Second-Order Critically-Damped Spring Integration with Adaptive Omega ax = (eff_omega ** 2) * (clamped_target_x - self.cam_x) - 2.0 * eff_omega * self.vel_x ay = (eff_omega ** 2) * (clamped_target_y - self.cam_y) - 2.0 * eff_omega * self.vel_y self.vel_x += ax * self.dt self.vel_y += ay * self.dt # Velocity limiting prevents abrupt camera sweeps curr_spd = math.hypot(self.vel_x, self.vel_y) if curr_spd > self.max_vel: scale = self.max_vel / curr_spd self.vel_x *= scale self.vel_y *= scale self.cam_x += self.vel_x * self.dt self.cam_y += self.vel_y * self.dt return (self.cam_x, self.cam_y, self.zoom)