Source code for mini_arcade_core.engine.render.viewport

"""
Viewport management for virtual to screen coordinate transformations.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from enum import Enum

from mini_arcade_core.utils import logger


[docs] class ViewportMode(str, Enum): """ Viewport scaling modes. :cvar FIT: Scale to fit within window, preserving aspect ratio (letterbox). :cvar FILL: Scale to fill entire window, preserving aspect ratio (crop). """ FIT = "fit" # letterbox FILL = "fill" # crop
# Justification: Many attributes needed to describe viewport state # pylint: disable=too-many-instance-attributes
[docs] @dataclass(frozen=True) class ViewportState: """ Current state of the viewport. :ivar virtual_w (int): Virtual canvas width. :ivar virtual_h (int): Virtual canvas height. :ivar window_w (int): Current window width. :ivar window_h (int): Current window height. :ivar mode (ViewportMode): Current viewport mode. :ivar scale (float): Current scale factor. :ivar viewport_w (int): Width of the viewport rectangle on screen. :ivar viewport_h (int): Height of the viewport rectangle on screen. :ivar offset_x (int): X offset of the viewport rectangle on screen. :ivar offset_y (int): Y offset of the viewport rectangle on screen. """ virtual_w: int virtual_h: int window_w: int window_h: int mode: ViewportMode scale: float # viewport rect in screen pixels where the virtual canvas lands # (can be larger than window in FILL mode -> offsets can be negative) viewport_w: int viewport_h: int offset_x: int offset_y: int
# pylint: enable=too-many-instance-attributes
[docs] class Viewport: """ Manages viewport transformations between virtual and screen coordinates. """ def __init__( self, virtual_w: int, virtual_h: int, mode: ViewportMode = ViewportMode.FIT, ): """ :param virtual_w: Virtual canvas width. :type virtual_w: int :param virtual_h: Virtual canvas height. :type virtual_h: int :param mode: Viewport scaling mode. :type mode: ViewportMode """ self._virtual_w = int(virtual_w) self._virtual_h = int(virtual_h) self._mode = mode self._state: ViewportState | None = None
[docs] def set_virtual_resolution(self, w: int, h: int): """ Set a new virtual resolution. :param w: New virtual width. :type w: int :param h: New virtual height. :type h: int """ self._virtual_w = int(w) self._virtual_h = int(h) if self._state: self.resize(self._state.window_w, self._state.window_h)
[docs] def set_mode(self, mode: ViewportMode): """ Set a new viewport mode. :param mode: New viewport mode. :type mode: ViewportMode """ self._mode = mode if self._state: self.resize(self._state.window_w, self._state.window_h)
[docs] def resize(self, window_w: int, window_h: int): """ Resize the viewport based on the current window size. :param window_w: Current window width. :type window_w: int :param window_h: Current window height. :type window_h: int """ window_w = int(window_w) window_h = int(window_h) sx = window_w / self._virtual_w sy = window_h / self._virtual_h scale = min(sx, sy) if self._mode == ViewportMode.FIT else max(sx, sy) if self._mode == ViewportMode.FIT: vw = int(math.floor(self._virtual_w * scale)) vh = int(math.floor(self._virtual_h * scale)) else: # FILL vw = int(math.ceil(self._virtual_w * scale)) vh = int(math.ceil(self._virtual_h * scale)) ox = (window_w - vw) // 2 oy = (window_h - vh) // 2 self._state = ViewportState( virtual_w=self._virtual_w, virtual_h=self._virtual_h, window_w=window_w, window_h=window_h, mode=self._mode, scale=float(scale), viewport_w=vw, viewport_h=vh, offset_x=ox, offset_y=oy, ) logger.debug( f"Viewport resized: window=({window_w}x{window_h}), " f"virtual=({self._virtual_w}x{self._virtual_h}), " f"mode={self._mode}, scale={scale:.3f}, " f"viewport=({vw}x{vh})@({ox},{oy})" )
@property def state(self) -> ViewportState: """ Get the current viewport state. :return: Current ViewportState. :rtype: ViewportState :raises RuntimeError: If the viewport has not been initialized. """ if self._state is None: raise RuntimeError( "Viewport not initialized. Call resize(window_w, window_h)." ) return self._state
[docs] def screen_to_virtual(self, x: float, y: float) -> tuple[float, float]: """ Convert screen coordinates to virtual coordinates. :param x: X coordinate on the screen. :type x: float :param y: Y coordinate on the screen. :type y: float :return: Corresponding virtual coordinates (x, y). :rtype: tuple[float, float] """ s = self.state return ((x - s.offset_x) / s.scale, (y - s.offset_y) / s.scale)
[docs] def virtual_to_screen(self, x: float, y: float) -> tuple[float, float]: """ Convert virtual coordinates to screen coordinates. :param x: X coordinate in virtual space. :type x: float :param y: Y coordinate in virtual space. :type y: float :return: Corresponding screen coordinates (x, y). :rtype: tuple[float, float] """ s = self.state return (s.offset_x + x * s.scale, s.offset_y + y * s.scale)