auto2.0 / squarecore /render /camera.py
Antigravity
feat: predictive lookahead camera, duel-axis pack framing, edge radar badges, and high-stakes comment bait UI
4c906aa
Raw History Blame Contribute Delete
7.15 kB
"""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)