forked from genewildish/Mainline
feat: Implement pipeline hot-rebuild and camera improvements
- Fixes issue #45: Add state property to EffectContext for motionblur/afterimage effects - Fixes issue #44: Reset camera bounce direction state in reset() method - Fixes issue #43: Implement pipeline hot-rebuild with state preservation - Adds radial camera mode for polar coordinate scanning - Adds afterimage and motionblur effects - Adds acceptance tests for camera and pipeline rebuild Closes #43, #44, #45
This commit is contained in:
115
engine/camera.py
115
engine/camera.py
@@ -23,6 +23,7 @@ class CameraMode(Enum):
|
|||||||
OMNI = auto()
|
OMNI = auto()
|
||||||
FLOATING = auto()
|
FLOATING = auto()
|
||||||
BOUNCE = auto()
|
BOUNCE = auto()
|
||||||
|
RADIAL = auto() # Polar coordinates (r, theta) for radial scanning
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -92,14 +93,17 @@ class Camera:
|
|||||||
"""
|
"""
|
||||||
return max(1, int(self.canvas_height / self.zoom))
|
return max(1, int(self.canvas_height / self.zoom))
|
||||||
|
|
||||||
def get_viewport(self) -> CameraViewport:
|
def get_viewport(self, viewport_height: int | None = None) -> CameraViewport:
|
||||||
"""Get the current viewport bounds.
|
"""Get the current viewport bounds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
viewport_height: Optional viewport height to use instead of camera's viewport_height
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CameraViewport with position and size (clamped to canvas bounds)
|
CameraViewport with position and size (clamped to canvas bounds)
|
||||||
"""
|
"""
|
||||||
vw = self.viewport_width
|
vw = self.viewport_width
|
||||||
vh = self.viewport_height
|
vh = viewport_height if viewport_height is not None else self.viewport_height
|
||||||
|
|
||||||
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
||||||
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
||||||
@@ -111,6 +115,13 @@ class Camera:
|
|||||||
height=vh,
|
height=vh,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return CameraViewport(
|
||||||
|
x=clamped_x,
|
||||||
|
y=clamped_y,
|
||||||
|
width=vw,
|
||||||
|
height=vh,
|
||||||
|
)
|
||||||
|
|
||||||
def set_zoom(self, zoom: float) -> None:
|
def set_zoom(self, zoom: float) -> None:
|
||||||
"""Set the zoom factor.
|
"""Set the zoom factor.
|
||||||
|
|
||||||
@@ -143,6 +154,8 @@ class Camera:
|
|||||||
self._update_floating(dt)
|
self._update_floating(dt)
|
||||||
elif self.mode == CameraMode.BOUNCE:
|
elif self.mode == CameraMode.BOUNCE:
|
||||||
self._update_bounce(dt)
|
self._update_bounce(dt)
|
||||||
|
elif self.mode == CameraMode.RADIAL:
|
||||||
|
self._update_radial(dt)
|
||||||
|
|
||||||
# Bounce mode handles its own bounds checking
|
# Bounce mode handles its own bounds checking
|
||||||
if self.mode != CameraMode.BOUNCE:
|
if self.mode != CameraMode.BOUNCE:
|
||||||
@@ -223,12 +236,85 @@ class Camera:
|
|||||||
self.y = max_y
|
self.y = max_y
|
||||||
self._bounce_dy = -1
|
self._bounce_dy = -1
|
||||||
|
|
||||||
|
def _update_radial(self, dt: float) -> None:
|
||||||
|
"""Radial camera mode: polar coordinate scrolling (r, theta).
|
||||||
|
|
||||||
|
The camera rotates around the center of the canvas while optionally
|
||||||
|
moving outward/inward along rays. This enables:
|
||||||
|
- Radar sweep animations
|
||||||
|
- Pendulum view oscillation
|
||||||
|
- Spiral scanning motion
|
||||||
|
|
||||||
|
Uses polar coordinates internally:
|
||||||
|
- _r_float: radial distance from center (accumulates smoothly)
|
||||||
|
- _theta_float: angle in radians (accumulates smoothly)
|
||||||
|
- Updates x, y based on conversion from polar to Cartesian
|
||||||
|
"""
|
||||||
|
# Initialize radial state if needed
|
||||||
|
if not hasattr(self, "_r_float"):
|
||||||
|
self._r_float = 0.0
|
||||||
|
self._theta_float = 0.0
|
||||||
|
|
||||||
|
# Update angular position (rotation around center)
|
||||||
|
# Speed controls rotation rate
|
||||||
|
theta_speed = self.speed * dt * 1.0 # radians per second
|
||||||
|
self._theta_float += theta_speed
|
||||||
|
|
||||||
|
# Update radial position (inward/outward from center)
|
||||||
|
# Can be modulated by external sensor
|
||||||
|
if hasattr(self, "_radial_input"):
|
||||||
|
r_input = self._radial_input
|
||||||
|
else:
|
||||||
|
# Default: slow outward drift
|
||||||
|
r_input = 0.0
|
||||||
|
|
||||||
|
r_speed = self.speed * dt * 20.0 # pixels per second
|
||||||
|
self._r_float += r_input + r_speed * 0.01
|
||||||
|
|
||||||
|
# Clamp radial position to canvas bounds
|
||||||
|
max_r = min(self.canvas_width, self.canvas_height) / 2
|
||||||
|
self._r_float = max(0.0, min(self._r_float, max_r))
|
||||||
|
|
||||||
|
# Convert polar to Cartesian, centered at canvas center
|
||||||
|
center_x = self.canvas_width / 2
|
||||||
|
center_y = self.canvas_height / 2
|
||||||
|
|
||||||
|
self.x = int(center_x + self._r_float * math.cos(self._theta_float))
|
||||||
|
self.y = int(center_y + self._r_float * math.sin(self._theta_float))
|
||||||
|
|
||||||
|
# Clamp to canvas bounds
|
||||||
|
self._clamp_to_bounds()
|
||||||
|
|
||||||
|
def set_radial_input(self, value: float) -> None:
|
||||||
|
"""Set radial input for sensor-driven radius modulation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Sensor value (0-1) that modulates radial distance
|
||||||
|
"""
|
||||||
|
self._radial_input = value * 10.0 # Scale to reasonable pixel range
|
||||||
|
|
||||||
|
def set_radial_angle(self, angle: float) -> None:
|
||||||
|
"""Set radial angle directly (for OSC integration).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
angle: Angle in radians (0 to 2π)
|
||||||
|
"""
|
||||||
|
self._theta_float = angle
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""Reset camera position."""
|
"""Reset camera position and state."""
|
||||||
self.x = 0
|
self.x = 0
|
||||||
self.y = 0
|
self.y = 0
|
||||||
self._time = 0.0
|
self._time = 0.0
|
||||||
self.zoom = 1.0
|
self.zoom = 1.0
|
||||||
|
# Reset bounce direction state
|
||||||
|
if hasattr(self, "_bounce_dx"):
|
||||||
|
self._bounce_dx = 1
|
||||||
|
self._bounce_dy = 1
|
||||||
|
# Reset radial state
|
||||||
|
if hasattr(self, "_r_float"):
|
||||||
|
self._r_float = 0.0
|
||||||
|
self._theta_float = 0.0
|
||||||
|
|
||||||
def set_canvas_size(self, width: int, height: int) -> None:
|
def set_canvas_size(self, width: int, height: int) -> None:
|
||||||
"""Set the canvas size and clamp position if needed.
|
"""Set the canvas size and clamp position if needed.
|
||||||
@@ -263,7 +349,7 @@ class Camera:
|
|||||||
return buffer
|
return buffer
|
||||||
|
|
||||||
# Get current viewport bounds (clamped to canvas size)
|
# Get current viewport bounds (clamped to canvas size)
|
||||||
viewport = self.get_viewport()
|
viewport = self.get_viewport(viewport_height)
|
||||||
|
|
||||||
# Use provided viewport_height if given, otherwise use camera's viewport
|
# Use provided viewport_height if given, otherwise use camera's viewport
|
||||||
vh = viewport_height if viewport_height is not None else viewport.height
|
vh = viewport_height if viewport_height is not None else viewport.height
|
||||||
@@ -348,6 +434,27 @@ class Camera:
|
|||||||
mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200
|
mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def radial(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a radial camera (polar coordinate scanning).
|
||||||
|
|
||||||
|
The camera rotates around the center of the canvas with smooth angular motion.
|
||||||
|
Enables radar sweep, pendulum view, and spiral scanning animations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
speed: Rotation speed (higher = faster rotation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Camera configured for radial polar coordinate scanning
|
||||||
|
"""
|
||||||
|
cam = cls(
|
||||||
|
mode=CameraMode.RADIAL, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
# Initialize radial state
|
||||||
|
cam._r_float = 0.0
|
||||||
|
cam._theta_float = 0.0
|
||||||
|
return cam
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||||
"""Create a camera with custom update function."""
|
"""Create a camera with custom update function."""
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ ANSI terminal display backend.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
class TerminalDisplay:
|
||||||
@@ -89,16 +88,8 @@ class TerminalDisplay:
|
|||||||
|
|
||||||
from engine.display import get_monitor, render_border
|
from engine.display import get_monitor, render_border
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
||||||
|
# This display renders every frame it receives.
|
||||||
# FPS limiting - skip frame if we're going too fast
|
|
||||||
if self._frame_period > 0:
|
|
||||||
now = time.perf_counter()
|
|
||||||
elapsed = now - self._last_frame_time
|
|
||||||
if elapsed < self._frame_period:
|
|
||||||
# Skip this frame - too soon
|
|
||||||
return
|
|
||||||
self._last_frame_time = now
|
|
||||||
|
|
||||||
# Get metrics for border display
|
# Get metrics for border display
|
||||||
fps = 0.0
|
fps = 0.0
|
||||||
@@ -117,15 +108,9 @@ class TerminalDisplay:
|
|||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
# Write buffer with cursor home + erase down to avoid flicker
|
# Write buffer with cursor home + erase down to avoid flicker
|
||||||
# \033[H = cursor home, \033[J = erase from cursor to end of screen
|
|
||||||
output = "\033[H\033[J" + "".join(buffer)
|
output = "\033[H\033[J" + "".join(buffer)
|
||||||
sys.stdout.buffer.write(output.encode())
|
sys.stdout.buffer.write(output.encode())
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
from engine.terminal import CLR
|
from engine.terminal import CLR
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class EffectContext:
|
|||||||
"""Get a state value from the context."""
|
"""Get a state value from the context."""
|
||||||
return self._state.get(key, default)
|
return self._state.get(key, default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
"""Get the state dictionary for direct access by effects."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EffectConfig:
|
class EffectConfig:
|
||||||
|
|||||||
Reference in New Issue
Block a user