forked from genewildish/Mainline
feat(pipeline): add Canvas and FontStage for rendering
- Add Canvas class for 2D surface management - Add CanvasStage for pipeline integration - Add FontStage as Transform for font rendering - Update Camera with x, y, w, h, zoom and guardrails - Add get_dimensions() to Display protocol
This commit is contained in:
117
engine/camera.py
117
engine/camera.py
@@ -6,6 +6,8 @@ Provides abstraction for camera motion in different modes:
|
|||||||
- Horizontal: left/right movement
|
- Horizontal: left/right movement
|
||||||
- Omni: combination of both
|
- Omni: combination of both
|
||||||
- Floating: sinusoidal/bobbing motion
|
- Floating: sinusoidal/bobbing motion
|
||||||
|
|
||||||
|
The camera defines a visible viewport into a larger Canvas.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -21,15 +23,32 @@ class CameraMode(Enum):
|
|||||||
FLOATING = auto()
|
FLOATING = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraViewport:
|
||||||
|
"""Represents the visible viewport."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Camera:
|
class Camera:
|
||||||
"""Camera for viewport scrolling.
|
"""Camera for viewport scrolling.
|
||||||
|
|
||||||
|
The camera defines a visible viewport into a Canvas.
|
||||||
|
It can be smaller than the canvas to allow scrolling,
|
||||||
|
and supports zoom to scale the view.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
x: Current horizontal offset (positive = scroll left)
|
x: Current horizontal offset (positive = scroll left)
|
||||||
y: Current vertical offset (positive = scroll up)
|
y: Current vertical offset (positive = scroll up)
|
||||||
mode: Current camera mode
|
mode: Current camera mode
|
||||||
speed: Base scroll speed
|
speed: Base scroll speed
|
||||||
|
zoom: Zoom factor (1.0 = 100%, 2.0 = 200% zoom out)
|
||||||
|
canvas_width: Width of the canvas being viewed
|
||||||
|
canvas_height: Height of the canvas being viewed
|
||||||
custom_update: Optional custom update function
|
custom_update: Optional custom update function
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -37,9 +56,65 @@ class Camera:
|
|||||||
y: int = 0
|
y: int = 0
|
||||||
mode: CameraMode = CameraMode.VERTICAL
|
mode: CameraMode = CameraMode.VERTICAL
|
||||||
speed: float = 1.0
|
speed: float = 1.0
|
||||||
|
zoom: float = 1.0
|
||||||
|
canvas_width: int = 200 # Larger than viewport for scrolling
|
||||||
|
canvas_height: int = 200
|
||||||
custom_update: Callable[["Camera", float], None] | None = None
|
custom_update: Callable[["Camera", float], None] | None = None
|
||||||
_time: float = field(default=0.0, repr=False)
|
_time: float = field(default=0.0, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def w(self) -> int:
|
||||||
|
"""Shorthand for viewport_width."""
|
||||||
|
return self.viewport_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def h(self) -> int:
|
||||||
|
"""Shorthand for viewport_height."""
|
||||||
|
return self.viewport_height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def viewport_width(self) -> int:
|
||||||
|
"""Get the visible viewport width.
|
||||||
|
|
||||||
|
This is the canvas width divided by zoom.
|
||||||
|
"""
|
||||||
|
return max(1, int(self.canvas_width / self.zoom))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def viewport_height(self) -> int:
|
||||||
|
"""Get the visible viewport height.
|
||||||
|
|
||||||
|
This is the canvas height divided by zoom.
|
||||||
|
"""
|
||||||
|
return max(1, int(self.canvas_height / self.zoom))
|
||||||
|
|
||||||
|
def get_viewport(self) -> CameraViewport:
|
||||||
|
"""Get the current viewport bounds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CameraViewport with position and size (clamped to canvas bounds)
|
||||||
|
"""
|
||||||
|
vw = self.viewport_width
|
||||||
|
vh = self.viewport_height
|
||||||
|
|
||||||
|
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
||||||
|
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
||||||
|
|
||||||
|
return CameraViewport(
|
||||||
|
x=clamped_x,
|
||||||
|
y=clamped_y,
|
||||||
|
width=vw,
|
||||||
|
height=vh,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_zoom(self, zoom: float) -> None:
|
||||||
|
"""Set the zoom factor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zoom: Zoom factor (1.0 = 100%, 2.0 = zoomed out 2x, 0.5 = zoomed in 2x)
|
||||||
|
"""
|
||||||
|
self.zoom = max(0.1, min(10.0, zoom))
|
||||||
|
|
||||||
def update(self, dt: float) -> None:
|
def update(self, dt: float) -> None:
|
||||||
"""Update camera position based on mode.
|
"""Update camera position based on mode.
|
||||||
|
|
||||||
@@ -61,6 +136,24 @@ class Camera:
|
|||||||
elif self.mode == CameraMode.FLOATING:
|
elif self.mode == CameraMode.FLOATING:
|
||||||
self._update_floating(dt)
|
self._update_floating(dt)
|
||||||
|
|
||||||
|
self._clamp_to_bounds()
|
||||||
|
|
||||||
|
def _clamp_to_bounds(self) -> None:
|
||||||
|
"""Clamp camera position to stay within canvas bounds.
|
||||||
|
|
||||||
|
Only clamps if the viewport is smaller than the canvas.
|
||||||
|
If viewport equals canvas (no scrolling needed), allows any position
|
||||||
|
for backwards compatibility with original behavior.
|
||||||
|
"""
|
||||||
|
vw = self.viewport_width
|
||||||
|
vh = self.viewport_height
|
||||||
|
|
||||||
|
# Only clamp if there's room to scroll
|
||||||
|
if vw < self.canvas_width:
|
||||||
|
self.x = max(0, min(self.x, self.canvas_width - vw))
|
||||||
|
if vh < self.canvas_height:
|
||||||
|
self.y = max(0, min(self.y, self.canvas_height - vh))
|
||||||
|
|
||||||
def _update_vertical(self, dt: float) -> None:
|
def _update_vertical(self, dt: float) -> None:
|
||||||
self.y += int(self.speed * dt * 60)
|
self.y += int(self.speed * dt * 60)
|
||||||
|
|
||||||
@@ -82,26 +175,42 @@ class Camera:
|
|||||||
self.x = 0
|
self.x = 0
|
||||||
self.y = 0
|
self.y = 0
|
||||||
self._time = 0.0
|
self._time = 0.0
|
||||||
|
self.zoom = 1.0
|
||||||
|
|
||||||
|
def set_canvas_size(self, width: int, height: int) -> None:
|
||||||
|
"""Set the canvas size and clamp position if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: New canvas width
|
||||||
|
height: New canvas height
|
||||||
|
"""
|
||||||
|
self.canvas_width = width
|
||||||
|
self.canvas_height = height
|
||||||
|
self._clamp_to_bounds()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def vertical(cls, speed: float = 1.0) -> "Camera":
|
def vertical(cls, speed: float = 1.0) -> "Camera":
|
||||||
"""Create a vertical scrolling camera."""
|
"""Create a vertical scrolling camera."""
|
||||||
return cls(mode=CameraMode.VERTICAL, speed=speed)
|
return cls(mode=CameraMode.VERTICAL, speed=speed, canvas_height=200)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def horizontal(cls, speed: float = 1.0) -> "Camera":
|
def horizontal(cls, speed: float = 1.0) -> "Camera":
|
||||||
"""Create a horizontal scrolling camera."""
|
"""Create a horizontal scrolling camera."""
|
||||||
return cls(mode=CameraMode.HORIZONTAL, speed=speed)
|
return cls(mode=CameraMode.HORIZONTAL, speed=speed, canvas_width=200)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def omni(cls, speed: float = 1.0) -> "Camera":
|
def omni(cls, speed: float = 1.0) -> "Camera":
|
||||||
"""Create an omnidirectional scrolling camera."""
|
"""Create an omnidirectional scrolling camera."""
|
||||||
return cls(mode=CameraMode.OMNI, speed=speed)
|
return cls(
|
||||||
|
mode=CameraMode.OMNI, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def floating(cls, speed: float = 1.0) -> "Camera":
|
def floating(cls, speed: float = 1.0) -> "Camera":
|
||||||
"""Create a floating/bobbing camera."""
|
"""Create a floating/bobbing camera."""
|
||||||
return cls(mode=CameraMode.FLOATING, speed=speed)
|
return cls(
|
||||||
|
mode=CameraMode.FLOATING, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||||
|
|||||||
146
engine/canvas.py
Normal file
146
engine/canvas.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Canvas - 2D surface for rendering.
|
||||||
|
|
||||||
|
The Canvas represents a full rendered surface that can be larger than the display.
|
||||||
|
The Camera then defines the visible viewport into this canvas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanvasRegion:
|
||||||
|
"""A rectangular region on the canvas."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""Check if region has positive dimensions."""
|
||||||
|
return self.width > 0 and self.height > 0
|
||||||
|
|
||||||
|
|
||||||
|
class Canvas:
|
||||||
|
"""2D canvas for rendering content.
|
||||||
|
|
||||||
|
The canvas is a 2D grid of cells that can hold text content.
|
||||||
|
It can be larger than the visible viewport (display).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
width: Total width in characters
|
||||||
|
height: Total height in characters
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, width: int = 80, height: int = 24):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._grid: list[list[str]] = [
|
||||||
|
[" " for _ in range(width)] for _ in range(height)
|
||||||
|
]
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the entire canvas."""
|
||||||
|
self._grid = [[" " for _ in range(self.width)] for _ in range(self.height)]
|
||||||
|
|
||||||
|
def get_region(self, x: int, y: int, width: int, height: int) -> list[list[str]]:
|
||||||
|
"""Get a rectangular region from the canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
width: Region width
|
||||||
|
height: Region height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
2D list of characters (height rows, width columns)
|
||||||
|
"""
|
||||||
|
region: list[list[str]] = []
|
||||||
|
for py in range(y, y + height):
|
||||||
|
row: list[str] = []
|
||||||
|
for px in range(x, x + width):
|
||||||
|
if 0 <= py < self.height and 0 <= px < self.width:
|
||||||
|
row.append(self._grid[py][px])
|
||||||
|
else:
|
||||||
|
row.append(" ")
|
||||||
|
region.append(row)
|
||||||
|
return region
|
||||||
|
|
||||||
|
def get_region_flat(self, x: int, y: int, width: int, height: int) -> list[str]:
|
||||||
|
"""Get a rectangular region as flat list of lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
width: Region width
|
||||||
|
height: Region height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of strings (one per row)
|
||||||
|
"""
|
||||||
|
region = self.get_region(x, y, width, height)
|
||||||
|
return ["".join(row) for row in region]
|
||||||
|
|
||||||
|
def put_region(self, x: int, y: int, content: list[list[str]]) -> None:
|
||||||
|
"""Put content into a rectangular region on the canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
content: 2D list of characters to place
|
||||||
|
"""
|
||||||
|
for py, row in enumerate(content):
|
||||||
|
for px, char in enumerate(row):
|
||||||
|
canvas_x = x + px
|
||||||
|
canvas_y = y + py
|
||||||
|
if 0 <= canvas_y < self.height and 0 <= canvas_x < self.width:
|
||||||
|
self._grid[canvas_y][canvas_x] = char
|
||||||
|
|
||||||
|
def put_text(self, x: int, y: int, text: str) -> None:
|
||||||
|
"""Put a single line of text at position.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Row position
|
||||||
|
text: Text to place
|
||||||
|
"""
|
||||||
|
for i, char in enumerate(text):
|
||||||
|
canvas_x = x + i
|
||||||
|
if 0 <= canvas_x < self.width and 0 <= y < self.height:
|
||||||
|
self._grid[y][canvas_x] = char
|
||||||
|
|
||||||
|
def fill(self, x: int, y: int, width: int, height: int, char: str = " ") -> None:
|
||||||
|
"""Fill a rectangular region with a character.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
width: Region width
|
||||||
|
height: Region height
|
||||||
|
char: Character to fill with
|
||||||
|
"""
|
||||||
|
for py in range(y, y + height):
|
||||||
|
for px in range(x, x + width):
|
||||||
|
if 0 <= py < self.height and 0 <= px < self.width:
|
||||||
|
self._grid[py][px] = char
|
||||||
|
|
||||||
|
def resize(self, width: int, height: int) -> None:
|
||||||
|
"""Resize the canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: New width
|
||||||
|
height: New height
|
||||||
|
"""
|
||||||
|
if width == self.width and height == self.height:
|
||||||
|
return
|
||||||
|
|
||||||
|
new_grid: list[list[str]] = [[" " for _ in range(width)] for _ in range(height)]
|
||||||
|
|
||||||
|
for py in range(min(self.height, height)):
|
||||||
|
for px in range(min(self.width, width)):
|
||||||
|
new_grid[py][px] = self._grid[py][px]
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._grid = new_grid
|
||||||
@@ -67,6 +67,18 @@ class Display(Protocol):
|
|||||||
"""Shutdown display."""
|
"""Shutdown display."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current terminal dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
|
||||||
|
This method is called after show() to check if the display
|
||||||
|
was resized. The main loop should compare this to the current
|
||||||
|
viewport dimensions and update accordingly.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class DisplayRegistry:
|
class DisplayRegistry:
|
||||||
"""Registry for display backends with auto-discovery."""
|
"""Registry for display backends with auto-discovery."""
|
||||||
|
|||||||
Reference in New Issue
Block a user