diff --git a/arcade/__init__.py b/arcade/__init__.py index b51a44b0f..8a03e446b 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -79,13 +79,11 @@ def configure_logging(level: Optional[int] = None): from .window_commands import schedule from .window_commands import run from .window_commands import set_background_color -from .window_commands import set_viewport from .window_commands import set_window from .window_commands import start_render from .window_commands import unschedule from .window_commands import schedule_once -from .camera import SimpleCamera, Camera from .sections import Section, SectionManager from .application import MOUSE_BUTTON_LEFT @@ -221,6 +219,7 @@ def configure_logging(level: Optional[int] = None): # Module imports from arcade import color as color from arcade import csscolor as csscolor +from arcade import camera as camera from arcade import key as key from arcade import resources as resources from arcade import types as types @@ -243,8 +242,6 @@ def configure_logging(level: Optional[int] = None): 'TextureAnimation', 'TextureKeyframe', 'ArcadeContext', - 'Camera', - 'SimpleCamera', 'ControllerManager', 'FACE_DOWN', 'FACE_LEFT', @@ -358,7 +355,6 @@ def configure_logging(level: Optional[int] = None): 'run', 'schedule', 'set_background_color', - 'set_viewport', 'set_window', 'start_render', 'stop_sound', diff --git a/arcade/application.py b/arcade/application.py index 8ff1282b5..44b7d95d0 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -17,13 +17,14 @@ import arcade from arcade import get_display_size -from arcade import set_viewport from arcade import set_window from arcade.color import TRANSPARENT_BLACK from arcade.context import ArcadeContext from arcade.types import Color, RGBOrA255, RGBANormalized from arcade import SectionManager from arcade.utils import is_raspberry_pi +from arcade.camera import Projector +from arcade.camera.default import DefaultProjector LOG = logging.getLogger(__name__) @@ -211,17 +212,17 @@ def __init__( # self.invalid = False set_window(self) + self._ctx: ArcadeContext = ArcadeContext(self, gc_mode=gc_mode, gl_api=gl_api) + self._background_color: Color = TRANSPARENT_BLACK + self._current_view: Optional[View] = None - self.current_camera: Optional[arcade.SimpleCamera] = None + self._default_camera = DefaultProjector(window=self) + self.current_camera: Projector = self._default_camera self.textbox_time = 0.0 self.key: Optional[int] = None self.flip_count: int = 0 self.static_display: bool = False - self._ctx: ArcadeContext = ArcadeContext(self, gc_mode=gc_mode, gl_api=gl_api) - set_viewport(0, self.width, 0, self.height) - self._background_color: Color = TRANSPARENT_BLACK - # See if we should center the window if center_window: self.center_window() @@ -606,13 +607,8 @@ def on_resize(self, width: int, height: int): # The arcade context is not created at that time if hasattr(self, "_ctx"): # Retain projection scrolling if applied - original_viewport = self._ctx.projection_2d - self.set_viewport( - original_viewport[0], - original_viewport[0] + width, - original_viewport[2], - original_viewport[2] + height - ) + self._ctx.viewport = (0, 0, width, height) + self.default_camera.use() def set_min_size(self, width: int, height: int): """ Wrap the Pyglet window call to set minimum size @@ -676,30 +672,19 @@ def set_visible(self, visible: bool = True): """ super().set_visible(visible) - # noinspection PyMethodMayBeStatic - def set_viewport(self, left: float, right: float, bottom: float, top: float): - """ - Set the viewport. (What coordinates we can see. - Used to scale and/or scroll the screen). - - See :py:func:`arcade.set_viewport` for more detailed information. - - :param left: - :param right: - :param bottom: - :param top: - """ - set_viewport(left, right, bottom, top) - - # noinspection PyMethodMayBeStatic - def get_viewport(self) -> Tuple[float, float, float, float]: - """ Get the viewport. (What coordinates we can see.) """ - return self.ctx.projection_2d - def use(self): """Bind the window's framebuffer for rendering commands""" self.ctx.screen.use() + @property + def default_camera(self): + """ + Provides a reference to the default arcade camera. + Automatically sets projection and view to the size + of the screen. Good for resetting the screen. + """ + return self._default_camera + def test(self, frames: int = 10): """ Used by unit test cases. Runs the event loop a few times and stops. diff --git a/arcade/camera.py b/arcade/camera.py deleted file mode 100644 index 8d28ab76e..000000000 --- a/arcade/camera.py +++ /dev/null @@ -1,576 +0,0 @@ -""" -Camera class -""" -from __future__ import annotations - -import math -from typing import TYPE_CHECKING, List, Optional, Tuple, Union - -from pyglet.math import Mat4, Vec2, Vec3 - -import arcade -from arcade.types import Point -from arcade.math import get_distance - -if TYPE_CHECKING: - from arcade import Sprite, SpriteList - -# type aliases -FourIntTuple = Tuple[int, int, int, int] -FourFloatTuple = Tuple[float, float, float, float] - -__all__ = [ - "SimpleCamera", - "Camera" -] - - -class SimpleCamera: - """ - A simple camera that allows to change the viewport, the projection and can move around. - That's it. - See arcade.Camera for more advance stuff. - - :param viewport: Size of the viewport: (left, bottom, width, height) - :param projection: Space to allocate in the viewport of the camera (left, right, bottom, top) - """ - def __init__( - self, - *, - viewport: Optional[FourIntTuple] = None, - projection: Optional[FourFloatTuple] = None, - window: Optional["arcade.Window"] = None, - ) -> None: - # Reference to Context, used to update projection matrix - self._window: "arcade.Window" = window or arcade.get_window() - - # store the viewport and projection tuples - # viewport is the space the camera will hold on the screen (left, bottom, width, height) - self._viewport: FourIntTuple = viewport or (0, 0, self._window.width, self._window.height) - - # projection is what you want to project into the camera viewport (left, right, bottom, top) - self._projection: FourFloatTuple = projection or (0, self._window.width, - 0, self._window.height) - if viewport is not None and projection is None: - # if viewport is provided but projection is not, projection - # will match the provided viewport - self._projection = (viewport[0], viewport[2], viewport[1], viewport[3]) - - # Matrices - - # Projection Matrix is used to apply the camera viewport size - self._projection_matrix: Mat4 = Mat4() - # View Matrix is what the camera is looking at(position) - self._view_matrix: Mat4 = Mat4() - # We multiply projection and view matrices to get combined, - # this is what actually gets sent to GL context - self._combined_matrix: Mat4 = Mat4() - - # Position - self.position: Vec2 = Vec2(0, 0) - - # Camera movement - self.goal_position: Vec2 = Vec2(0, 0) - self.move_speed: float = 1.0 # 1.0 is instant - self.moving: bool = False - - # Init matrixes - # This will pre-compute the projection, view and combined matrixes - self._set_projection_matrix(update_combined_matrix=False) - self._set_view_matrix() - - @property - def viewport_width(self) -> int: - """ Returns the width of the viewport """ - return self._viewport[2] - - @property - def viewport_height(self) -> int: - """ Returns the height of the viewport """ - return self._viewport[3] - - @property - def viewport(self) -> FourIntTuple: - """ The space the camera will hold on the screen (left, bottom, width, height) """ - return self._viewport - - @viewport.setter - def viewport(self, viewport: FourIntTuple) -> None: - """ Sets the viewport """ - self.set_viewport(viewport) - - def set_viewport(self, viewport: FourIntTuple) -> None: - """ Sets the viewport """ - self._viewport = viewport or (0, 0, self._window.width, self._window.height) - - # the viewport affects the view matrix - self._set_view_matrix() - - @property - def projection(self) -> FourFloatTuple: - """ - The dimensions of the space to project in the camera viewport (left, right, bottom, top). - The projection is what you want to project into the camera viewport. - """ - return self._projection - - @projection.setter - def projection(self, new_projection: FourFloatTuple) -> None: - """ - Update the projection of the camera. This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - self._projection = new_projection or (0, self._window.width, 0, self._window.height) - self._set_projection_matrix() - - @property - def viewport_to_projection_width_ratio(self): - """ The ratio of viewport width to projection width """ - return self.viewport_width / (self._projection[1] - self._projection[0]) - - @property - def viewport_to_projection_height_ratio(self): - """ The ratio of viewport height to projection height """ - return self.viewport_height / (self._projection[3] - self._projection[2]) - - @property - def projection_to_viewport_width_ratio(self): - """ The ratio of projection width to viewport width """ - return (self._projection[1] - self._projection[0]) / self.viewport_width - - @property - def projection_to_viewport_height_ratio(self): - """ The ratio of projection height to viewport height """ - return (self._projection[3] - self._projection[2]) / self.viewport_height - - def _set_projection_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the projection and combined matrix - - :param update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - self._projection_matrix = Mat4.orthogonal_projection(*self._projection, -100, 100) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_view_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the view and combined matrix - - :param update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - - # Figure out our 'real' position - result_position = Vec3( - (self.position[0] / (self.viewport_width / 2)), - (self.position[1] / (self.viewport_height / 2)), - 0 - ) - self._view_matrix = ~(Mat4.from_translation(result_position)) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_combined_matrix(self) -> None: - """ Helper method. This will just pre-compute the combined matrix""" - self._combined_matrix = self._view_matrix @ self._projection_matrix - - def move_to(self, vector: Union[Vec2, tuple], speed: float = 1.0) -> None: - """ - Sets the goal position of the camera. - - The camera will lerp towards this position based on the provided speed, - updating its position every time the use() function is called. - - :param vector: Vector to move the camera towards. - :param speed: How fast to move the camera, 1.0 is instant, 0.1 moves slowly - """ - self.goal_position = Vec2(*vector) - self.move_speed = speed - self.moving = True - - def move(self, vector: Union[Vec2, tuple]) -> None: - """ - Moves the camera with a speed of 1.0, aka instant move - - This is equivalent to calling move_to(my_pos, 1.0) - """ - self.move_to(vector, 1.0) - - def center(self, vector: Union[Vec2, tuple], speed: float = 1.0) -> None: - """ - Centers the camera on coordinates - """ - if not isinstance(vector, Vec2): - vector2: Vec2 = Vec2(*vector) - else: - vector2 = vector - - # get the center of the camera viewport - center = Vec2(self.viewport_width, self.viewport_height) / 2 - - # adjust vector to projection ratio - vector2 = Vec2(vector2.x * self.viewport_to_projection_width_ratio, - vector2.y * self.viewport_to_projection_height_ratio) - - # move to the vector subtracting the center - target = (vector2 - center) - - self.move_to(target, speed) - - def get_map_coordinates(self, camera_vector: Union[Vec2, tuple]) -> Vec2: - """ - Returns map coordinates in pixels from screen coordinates based on the camera position - - :param camera_vector: Vector captured from the camera viewport - """ - return Vec2(*self.position) + Vec2(*camera_vector) - - def resize(self, viewport_width: int, viewport_height: int, *, - resize_projection: bool = True) -> None: - """ - Resize the camera's viewport. Call this when the window resizes. - - :param viewport_width: Width of the viewport - :param viewport_height: Height of the viewport - :param resize_projection: if True the projection will also be resized - """ - new_viewport = (self._viewport[0], self._viewport[1], viewport_width, viewport_height) - self.set_viewport(new_viewport) - if resize_projection: - self.projection = (self._projection[0], viewport_width, - self._projection[2], viewport_height) - - def update(self): - """ - Update the camera's viewport to the current settings. - """ - if self.moving: - # Apply Goal Position - self.position = self.position.lerp(self.goal_position, self.move_speed) - if self.position == self.goal_position: - self.moving = False - self._set_view_matrix() # this will also set the combined matrix - - def use(self) -> None: - """ - Select this camera for use. Do this right before you draw. - """ - self._window.current_camera = self - - # update camera position and calculate matrix values if needed - self.update() - - # set Viewport / projection - self._window.ctx.viewport = self._viewport # sets viewport of the camera - self._window.projection = self._combined_matrix # sets projection position and zoom - self._window.view = Mat4() # Set to identity matrix for now - - -class Camera(SimpleCamera): - """ - The Camera class is used for controlling the visible viewport, the projection, zoom and rotation. - It is very useful for separating a scrolling screen of sprites, and a GUI overlay. - For an example of this in action, see :ref:`sprite_move_scrolling`. - - :param viewport: (left, bottom, width, height) size of the viewport. If None the window size will be used. - :param projection: (left, right, bottom, top) size of the projection. If None the window size will be used. - :param zoom: the zoom to apply to the projection - :param rotation: the angle in degrees to rotate the projection - :param anchor: the x, y point where the camera rotation will anchor. Default is the center of the viewport. - :param window: Window to associate with this camera, if working with a multi-window program. - """ - def __init__( - self, *, - viewport: Optional[FourIntTuple] = None, - projection: Optional[FourFloatTuple] = None, - zoom: float = 1.0, - rotation: float = 0.0, - anchor: Optional[Tuple[float, float]] = None, - window: Optional["arcade.Window"] = None, - ): - # scale and zoom - # zoom it's just x scale value. Setting zoom will set scale x, y to the same value - self._scale: Tuple[float, float] = (zoom, zoom) - - # Near and Far - self._near: int = -1 - self._far: int = 1 - - # Shake - self.shake_velocity: Vec2 = Vec2() - self.shake_offset: Vec2 = Vec2() - self.shake_speed: float = 0.0 - self.shake_damping: float = 0.0 - self.shaking: bool = False - - # Call init from superclass here, previous attributes are needed before this call - super().__init__(viewport=viewport, projection=projection, window=window) - - # Rotation - self._rotation: float = rotation # in degrees - self._anchor: Optional[Tuple[float, float]] = anchor # (x, y) to anchor the camera rotation - - # Matrixes - # Rotation matrix holds the matrix used to compute the - # rotation set in window.ctx.view_matrix_2d - self._rotation_matrix: Mat4 = Mat4() - - # Init matrixes - # This will pre-compute the rotation matrix - self._set_rotation_matrix() - - def set_viewport(self, viewport: FourIntTuple) -> None: - """ Sets the viewport """ - super().set_viewport(viewport) - - # the viewport affects the rotation matrix if the rotation anchor is not set - if self._anchor is None: - self._set_rotation_matrix() - - def _set_projection_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the projection and combined matrix - - :param update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - # apply zoom - left, right, bottom, top = self._projection - if self._scale != (1.0, 1.0): - right *= self._scale[0] # x axis scale - top *= self._scale[1] # y axis scale - - self._projection_matrix = Mat4.orthogonal_projection(left, right, bottom, top, self._near, - self._far) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_view_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the view and combined matrix - :param update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - - # Figure out our 'real' position plus the shake - result_position = self.position + self.shake_offset - result_position = Vec3( - (result_position[0] / ((self.viewport_width * self._scale[0]) / 2)), - (result_position[1] / ((self.viewport_height * self._scale[1]) / 2)), - 0 - ) - self._view_matrix = ~(Mat4.from_translation(result_position) @ Mat4().scale( - Vec3(self._scale[0], self._scale[1], 1.0))) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_rotation_matrix(self) -> None: - """ Helper method that computes the rotation_matrix every time is needed """ - rotate = Mat4.from_rotation(math.radians(self._rotation), Vec3(0, 0, 1)) - - # If no anchor is set, use the center of the screen - if self._anchor is None: - offset = Vec3(self.position.x, self.position.y, 0) - offset += Vec3(self.viewport_width / 2, self.viewport_height / 2, 0) - else: - offset = Vec3(self._anchor[0], self._anchor[1], 0) - - translate_pre = Mat4.from_translation(offset) - translate_post = Mat4.from_translation(-offset) - - self._rotation_matrix = translate_post @ rotate @ translate_pre - - @property - def scale(self) -> Tuple[float, float]: - """ - Returns the x, y scale. - """ - return self._scale - - @scale.setter - def scale(self, new_scale: Tuple[float, float]) -> None: - """ - Sets the x, y scale (zoom property just sets scale to the same value). - This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - if new_scale[0] <= 0 or new_scale[1] <= 0: - raise ValueError("Scale must be greater than zero") - - self._scale = (float(new_scale[0]), float(new_scale[1])) - - # Changing the scale (zoom) affects both projection_matrix and view_matrix - self._set_projection_matrix( - update_combined_matrix=False) # combined matrix will be set in the next call - self._set_view_matrix() - - @property - def zoom(self) -> float: - """ The zoom applied to the projection. Just returns the x scale value. """ - return self._scale[0] - - @zoom.setter - def zoom(self, zoom: float) -> None: - """ Apply a zoom to the projection """ - self.scale = zoom, zoom - - @property - def near(self) -> int: - """ The near applied to the projection""" - return self._near - - @near.setter - def near(self, near: int) -> None: - """ - Update the near of the camera. This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - self._near = near - self._set_projection_matrix() - - @property - def far(self) -> int: - """ The far applied to the projection""" - return self._far - - @far.setter - def far(self, far: int) -> None: - """ - Update the far of the camera. This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - self._far = far - self._set_projection_matrix() - - @property - def rotation(self) -> float: - """ - Get or set the rotation in degrees. - - This will rotate the camera clockwise meaning - the contents will rotate counter-clockwise. - """ - return self._rotation - - @rotation.setter - def rotation(self, value: float) -> None: - self._rotation = value - self._set_rotation_matrix() - - @property - def anchor(self) -> Optional[Tuple[float, float]]: - """ - Get or set the rotation anchor for the camera. - - By default, the anchor is the center of the screen - and the anchor value is `None`. Assigning a custom - anchor point will override this behavior. - The anchor point is in world / global coordinates. - - Example:: - - # Set the anchor to the center of the world - camera.anchor = 0, 0 - # Set the anchor to the center of the player - camera.anchor = player.position - """ - return self._anchor - - @anchor.setter - def anchor(self, anchor: Optional[Tuple[float, float]]) -> None: - if anchor is None: - self._anchor = None - else: - self._anchor = anchor[0], anchor[1] - self._set_rotation_matrix() - - def update(self) -> None: - """ - Update the camera's viewport to the current settings. - """ - update_view_matrix = False - - if self.moving: - # Apply Goal Position - self.position = self.position.lerp(self.goal_position, self.move_speed) - if self.position == self.goal_position: - self.moving = False - update_view_matrix = True - - if self.shaking: - # Apply Camera Shake - - # Move our offset based on shake velocity - self.shake_offset += self.shake_velocity - - # Get x and ys - vx = self.shake_velocity[0] - vy = self.shake_velocity[1] - - ox = self.shake_offset[0] - oy = self.shake_offset[1] - - # Calculate the angle our offset is at, and how far out - angle = math.atan2(ox, oy) - distance = get_distance(0, 0, ox, oy) - velocity_mag = get_distance(0, 0, vx, vy) - - # Ok, what's the reverse? Pull it back in. - reverse_speed = min(self.shake_speed, distance) - opposite_angle = angle + math.pi - opposite_vector = Vec2( - math.sin(opposite_angle) * reverse_speed, - math.cos(opposite_angle) * reverse_speed, - ) - - # Shaking almost done? Zero it out - if velocity_mag < self.shake_speed and distance < self.shake_speed: - self.shake_velocity = Vec2(0, 0) - self.shake_offset = Vec2(0, 0) - self.shaking = False - - # Come up with a new velocity, pulled by opposite vector and damped - self.shake_velocity += opposite_vector - self.shake_velocity *= self.shake_damping - - update_view_matrix = True - - if update_view_matrix: - self._set_view_matrix() # this will also set the combined matrix - - def shake(self, velocity: Union[Vec2, tuple], speed: float = 1.5, damping: float = 0.9) -> None: - """ - Add a camera shake. - - :param velocity: Vector to start moving the camera - :param speed: How fast to shake - :param damping: How fast to stop shaking - """ - if not isinstance(velocity, Vec2): - velocity = Vec2(*velocity) - - self.shake_velocity += velocity - self.shake_speed = speed - self.shake_damping = damping - self.shaking = True - - def use(self) -> None: - """ - Select this camera for use. Do this right before you draw. - """ - super().use() # call SimpleCamera.use() - - # set rotation matrix - self._window.ctx.view_matrix_2d = self._rotation_matrix # sets rotation and rotation anchor - - def get_sprites_at_point(self, point: "Point", sprite_list: "SpriteList") -> List["Sprite"]: - """ - Get a list of sprites at a particular point when - This function sees if any sprite overlaps - the specified point. If a sprite has a different center_x/center_y but touches the point, - this will return that sprite. - - :param point: Point to check - :param sprite_list: SpriteList to check against - - :returns: List of sprites colliding, or an empty list. - """ - raise NotImplementedError() diff --git a/arcade/camera/README.md b/arcade/camera/README.md new file mode 100644 index 000000000..fa2827d40 --- /dev/null +++ b/arcade/camera/README.md @@ -0,0 +1,89 @@ +# Arcade Camera + +This is an overview of how the new Arcade Cameras work. + +## Key Concepts + +### World Space +Whenever an object has a position within arcade that position is in world space. How much 1 unit in world +space represents is arbitrary. For example when a sprite has a scale of 1.0 then 1 unit in world space is +equal to one pixel of the sprite's source texture. This does not necessarily equate to one pixel on the screen. + +### Screen Space +The final positions of anything drawn to screen is in screen space. The mouse positions returned by window +events like `on_mouse_press` are also in screen space. Moving 1 unit in screen space is equivalent to moving +one pixel. Often positions in screen space are integer values, but this is not a strict rule. + +### View Matrices +The view matrix represents what part of world space should be focused on. It is made of three components. +The first is the position. This represents what world space position should be at (0, 0, 0). The second is +the forward vector. This is the direction which is considered forward and backwards in world space. the +final component is the up vector. Which determines what world space positions are upwards or downwards in +world space. + +The goal of the view matrix is to prepare everything in world space for projection into screen space. It +achieves this by applying its three components to every world space position. In the end any object with +a world space position equal to the view matrix position will be at (0, 0, 0). Any object along the forward +vector after moving will be placed along the z-axis, and any object along the up vector will be place along +the y-axis. This transformation moves the objects from screen space into view space. Importantly one unit in +world space is equal to one unit in view space + +### Projection Matrices +The projection matrix takes the positions of objects in view space and projects them into screen space. +depending on the type of projection matrix how this exactly applies changes. Projection matrices along +do not fully project objects into screen space, instead they transform positions into unit space. This +special coordinate space ranges from -1 to 1 in the x, y, and z axis. Anything within this range will +be transformed into screen space, and everything outside this range is discarded and left undrawn. +you can conceptualise projection matrices as taking a 6 sided 3D prism volume in view space and +squashing it down into a uniformly sized cube. In every case the closest position projected along the +z-axis is given by the near value, while the furthest is given by the far value. + +#### orthographic +In an orthographic projection the distance from the origin does not impact how much a position gets projected. +This type of projection can be visualised as a rectangular prism with a set width, height, and depth +determined by left, right, bottom, top, near, far values. These values tell you the bounding box of positions +in view space which get projected. + +#### perspective +In an orthographic projection the distance from the origin directly impacts how much a position is projected. +This type of projection can be visualised as a rectangular prism with the sharp end removed. This shape means +that more positions further away from the origin will be squashed down into unit space. This makes objects +that are further away appear smaller. The shape of the prism is determined by an aspect ratio, the field of view, +and the near and far values. The aspect ratio defines the ratio between the height and width of the projection. +The field of view is half of the angle used to determine the height of the projection at a particular depth. + +### Viewports +The final concept to cover is the viewport. This is the pixel area which the unit space will scale to. The ratio +between the size of the viewport and the size of the projection determines the relationship between units in +world space and pixels in screen space. For example if width and height of an orthographic projection is equal +to the width and height of the viewport then one unit in world space will equal one pixel in screen space. This +is the default for arcade. + +The viewport also defines which pixels get drawn to in the final image. Generally this is equal to the entire +screen, but it is possible to draw to only a specific area by defining the right viewport. Note that doing this +will change the ratio of the viewport and projection, so ensure that they match if you would like to keep the same +unit to pixel ratio. Any position outside the viewport which would normally be a valid pixel position will +not be drawn. + +## Key Objects + +- Objects which modify the view and perspective matrices are called "Projectors" + - `arcade.camera.Projector` is a `Protocol` used internally by arcade + - `Projector.use()` sets the internal projection and view matrices used by Arcade and Pyglet + - `Projector.activate()` is the same as use, but works within a context manager using the `with` syntax + - `Projector.map_screen_to_world_coordinate(screen_coordinate, depth)` +provides a way to find the world position of any pixel position on screen. +- There are multiple data types which provide the information required to make view and projection matrices + - `camera.CameraData` holds the position, forward, and up vectors along with a zoom value used to create the +view matrix + - `camera.OrthographicProjectionData` holds the left, right, bottom, top, near, far values needed to create a +orthographic projection matrix + - `camera.PerspectiveProjectionData` holds the aspect ratio, field of view, near and far needed to create a +perspective projection matrix. + - both ProjectionData data types also provide a viewport for setting the draw area when using the camera. +- There are three primary `Projectors` in `arcade.camera` + - `arcade.camera.Camera2D` is locked to the x-y plane and is perfect for most use cases within arcade. + - `arcade.camera.OrthographicProjector` can be freely positioned in 3D space, but the scale of objects does not +depend on the distance to the projector + - [not yet implemented ] `arcade.camera.PerspectiveProjector` can be freely position in 3D space, +and objects look smaller the further from the camera they are diff --git a/arcade/camera/__init__.py b/arcade/camera/__init__.py new file mode 100644 index 000000000..cc4f9f2a3 --- /dev/null +++ b/arcade/camera/__init__.py @@ -0,0 +1,25 @@ +""" +The Cinematic Types, Classes, and Methods of Arcade. +Providing a multitude of camera's for any need. +""" + +from arcade.camera.data_types import Projection, Projector, CameraData, OrthographicProjectionData + +from arcade.camera.orthographic import OrthographicProjector + +from arcade.camera.simple_camera import SimpleCamera +from arcade.camera.camera_2d import Camera2D + +import arcade.camera.grips as grips + + +__all__ = [ + 'Projection', + 'Projector', + 'CameraData', + 'OrthographicProjectionData', + 'OrthographicProjector', + 'SimpleCamera', + 'Camera2D', + 'grips' +] diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py new file mode 100644 index 000000000..3b59283ce --- /dev/null +++ b/arcade/camera/camera_2d.py @@ -0,0 +1,742 @@ +from typing import TYPE_CHECKING, Optional, Tuple, Iterator +from math import degrees, radians, atan2, cos, sin +from contextlib import contextmanager + +from arcade.camera.orthographic import OrthographicProjector +from arcade.camera.data_types import CameraData, OrthographicProjectionData, Projector +from arcade.gl import Framebuffer + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade.application import Window + + +__all__ = [ + 'Camera2D' +] + + +class Camera2D: + """ + A simple orthographic camera. Similar to SimpleCamera, but takes better advantage of the new data structures. + As the Simple Camera is depreciated, any new project should use this camera instead. + + It provides properties to access every important variable for controlling the camera. + 3D properties such as pos, and up are constrained to a 2D plane. There is no access to the + forward vector (as a property). + + The method fully fulfils both the Camera and Projector protocols. + + There are also ease of use methods for matching the viewport and projector to the window size. + + Provides 4 sets of left, right, bottom, top: + + * View Data, or where the camera is in + * Projection without zoom scaling. + * Projection with zoom scaling. + * Viewport in screen pixels + + .. warning:: Do not replace the ``camera_data`` and ``projection_data`` + instances after initialization! + + Replacing the camera data and projection data may break controllers. Their + contents are exposed via properties rather than directly to prevent this. + + :param window: The Arcade Window to bind the camera to. + Defaults to the currently active window. + :param camera_data: A :py:class:`~arcade.camera.data.CameraData` + describing the viewport, position, up, and zoom. + :param projection_data: A :py:class:`~arcade.camera.data.OrthographicProjectionData` + which describes the left, right, top, bottom, far, near planes for an orthographic projection. + :param render_target: The FrameBuffer that the camera uses. Defaults to the screen. + If the framebuffer is not the default screen nothing drawn after this camera is used will + show up. The FrameBuffer's internal viewport is ignored. + """ + def __init__(self, *, + camera_data: Optional[CameraData] = None, + projection_data: Optional[OrthographicProjectionData] = None, + render_target: Optional[Framebuffer] = None, + window: Optional["Window"] = None): + self._window: "Window" = window or get_window() + self.render_target: Framebuffer = render_target or self._window.ctx.screen + + half_width = self._window.width / 2 + half_height = self._window.height / 2 + + self._data = camera_data or CameraData( + (half_width, half_height, 0.0), # position + (0.0, 1.0, 0.0), # up vector + (0.0, 0.0, -1.0), # forward vector + 1.0 # zoom + ) + self._projection: OrthographicProjectionData = projection_data or OrthographicProjectionData( + -half_width, half_width, # Left and Right. + -half_height, half_height, # Bottom and Top. + 0.0, 100.0, # Near and Far. + (0, 0, self._window.width, self._window.height) # Viewport + ) + + self._ortho_projector: OrthographicProjector = OrthographicProjector( + window=self._window, + view=self._data, + projection=self._projection + ) + + @staticmethod + def from_raw_data( + viewport: Optional[Tuple[int, int, int, int]] = None, + position: Optional[Tuple[float, float]] = None, + up: Tuple[float, float] = (0.0, 1.0), + zoom: float = 1.0, + projection: Optional[Tuple[float, float, float, float]] = None, + near: float = -100, + far: float = 100, + *, + render_target: Optional[Framebuffer] = None, + window: Optional["Window"] = None + ): + """ + Create a Camera2D without first defining CameraData or an OrthographicProjectionData object. + + :param viewport: A 4-int tuple which defines the pixel bounds which the camera with project to. + :param position: The 2D position of the camera in the XY plane. + :param up: The 2D unit vector which defines the +Y-axis of the camera space. + :param zoom: A scalar value which is inversely proportional to the size of the camera projection. + i.e. a zoom of 2.0 halves the size of the projection, doubling the perceived size of objects. + :param projection: A 4-float tuple which defines the world space + bounds which the camera projects to the viewport. + :param near: The near clipping plane of the camera. + :param far: The far clipping plane of the camera. + :param render_target: The FrameBuffer that the camera uses. Defaults to the screen. + If the framebuffer is not the default screen nothing drawn after this camera is used will + show up. The FrameBuffer's internal viewport is ignored. + :param window: The Arcade Window to bind the camera to. + Defaults to the currently active window. + """ + window = window or get_window() + + half_width = window.width / 2 + half_height = window.height / 2 + + _pos = position or (half_width, half_height) + _data: CameraData = CameraData( + (_pos[0], _pos[1], 0.0), # position + (up[0], up[1], 0.0), # up vector + (0.0, 0.0, -1.0), # forward vector + zoom # zoom + ) + + left, right, bottom, top = projection or (-half_width, half_width, -half_height, half_height) + _projection: OrthographicProjectionData = OrthographicProjectionData( + left, right, # Left and Right. + top, bottom, # Bottom and Top. + near or 0.0, far or 100.0, # Near and Far. + viewport or (0, 0, window.width, window.height) # Viewport + ) + + return Camera2D( + camera_data=_data, + projection_data=_projection, + window=window, + render_target=(render_target or window.ctx.screen) + ) + + @property + def view_data(self) -> CameraData: + """The view data for the camera. + + This includes: + + * the position + * forward vector + * up direction + * zoom. + + Camera controllers use this property. You will need to access + it if you use implement a custom one. + """ + return self._data + + @property + def projection_data(self) -> OrthographicProjectionData: + """The projection data for the camera. + + This is an Orthographic projection. with a + right, left, top, bottom, near, and far value. + + An easy way to understand the use of the projection is + that the right value of the projection tells the + camera what value will be at the right most + pixel in the viewport. + + Due to the view data having a zoom component + most use cases will only change the projection + on screen resize. + """ + return self._projection + + @property + def position(self) -> Tuple[float, float]: + """The 2D world position of the camera along the X and Y axes.""" + return self._data.position[:2] + + @position.setter + def position(self, _pos: Tuple[float, float]) -> None: + self._data.position = (_pos[0], _pos[1], self._data.position[2]) + + @property + def left(self) -> float: + """The left side of the camera in world space. + + Useful for checking if a :py:class:`~arcade.Sprite` is on screen. + """ + return self._data.position[0] + self._projection.left/self._data.zoom + + @left.setter + def left(self, _left: float) -> None: + self._data.position = (_left - self._projection.left/self._data.zoom,) + self._data.position[1:] + + @property + def right(self) -> float: + """The right edge of the camera in world space. + + Useful for checking if a :py:class:`~arcade.Sprite` is on screen. + """ + return self._data.position[0] + self._projection.right/self._data.zoom + + @right.setter + def right(self, _right: float) -> None: + self._data.position = (_right - self._projection.right/self._data.zoom,) + self._data.position[1:] + + @property + def bottom(self) -> float: + """The bottom edge of the camera in world space. + + Useful for checking if a :py:class:`~arcade.Sprite` is on screen. + """ + return self._data.position[1] + self._projection.bottom/self._data.zoom + + @bottom.setter + def bottom(self, _bottom: float) -> None: + self._data.position = ( + self._data.position[0], + _bottom - self._projection.bottom/self._data.zoom, + self._data.position[2] + ) + + @property + def top(self) -> float: + """The top edge of the camera in world space. + + Useful for checking if a :py:class:`~arcade.Sprite` is on screen. + """ + return self._data.position[1] + self._projection.top/self._data.zoom + + @top.setter + def top(self, _top: float) -> None: + self._data.position = ( + self._data.position[0], + _top - self._projection.top/self._data.zoom, + self._data.position[2] + ) + + @property + def projection(self) -> Tuple[float, float, float, float]: + """The camera's left, right, bottom, top projection values. + + These control how the camera projects the world onto the pixels + of the screen. + """ + _p = self._projection + return _p.left, _p.right, _p.bottom, _p.top + + @projection.setter + def projection(self, value: Tuple[float, float, float, float]) -> None: + _p = self._projection + _p.left, _p.right, _p.bottom, _p.top = value + + @property + def projection_width(self) -> float: + """ + The width of the projection from left to right. + This is in world space coordinates not pixel coordinates. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_width_scaled instead. + """ + return self._projection.right - self._projection.left + + @projection_width.setter + def projection_width(self, _width: float) -> None: + w = self.projection_width + l = self.projection_left / w # Normalised Projection left + r = self.projection_right / w # Normalised Projection Right + + self.projection_left = l * _width + self.projection_right = r * _width + + @property + def projection_width_scaled(self) -> float: + """ + The width of the projection from left to right. + This is in world space coordinates not pixel coordinates. + + NOTE this IS scaled by zoom. + If this isn't what you want, + use projection_width instead. + """ + return (self._projection.right - self._projection.left) / self._data.zoom + + @projection_width_scaled.setter + def projection_width_scaled(self, _width: float) -> None: + w = self.projection_width * self._data.zoom + l = self.projection_left / w # Normalised Projection left + r = self.projection_right / w # Normalised Projection Right + + self.projection_left = l * _width + self.projection_right = r * _width + + @property + def projection_height(self) -> float: + """ + The height of the projection from bottom to top. + This is in world space coordinates not pixel coordinates. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_height_scaled instead. + """ + return self._projection.top - self._projection.bottom + + @projection_height.setter + def projection_height(self, _height: float) -> None: + h = self.projection_height + b = self.projection_bottom / h # Normalised Projection Bottom + t = self.projection_top / h # Normalised Projection Top + + self.projection_bottom = b * _height + self.projection_top = t * _height + + @property + def projection_height_scaled(self) -> float: + """ + The height of the projection from bottom to top. + This is in world space coordinates not pixel coordinates. + + NOTE this IS scaled by zoom. + If this isn't what you want, + use projection_height instead. + """ + return (self._projection.top - self._projection.bottom) / self._data.zoom + + @projection_height_scaled.setter + def projection_height_scaled(self, _height: float) -> None: + h = self.projection_height * self._data.zoom + b = self.projection_bottom / h # Normalised Projection Bottom + t = self.projection_top / h # Normalised Projection Top + + self.projection_bottom = b * _height + self.projection_top = t * _height + + @property + def projection_left(self) -> float: + """ + The left edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_left_scaled instead. + """ + return self._projection.left + + @projection_left.setter + def projection_left(self, _left: float) -> None: + self._projection.left = _left + + @property + def projection_left_scaled(self) -> float: + """ + The left edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS scaled by zoom. + If this isn't what you want, + use projection_left instead. + """ + return self._projection.left / self._data.zoom + + @projection_left_scaled.setter + def projection_left_scaled(self, _left: float) -> None: + self._projection.left = _left * self._data.zoom + + @property + def projection_right(self) -> float: + """ + The right edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_right_scaled instead. + """ + return self._projection.right + + @projection_right.setter + def projection_right(self, _right: float) -> None: + self._projection.right = _right + + @property + def projection_right_scaled(self) -> float: + """ + The right edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS scaled by zoom. + If this isn't what you want, + use projection_right instead. + """ + return self._projection.right / self._data.zoom + + @projection_right_scaled.setter + def projection_right_scaled(self, _right: float) -> None: + self._projection.right = _right * self._data.zoom + + @property + def projection_bottom(self) -> float: + """ + The bottom edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_bottom_scaled instead. + """ + return self._projection.bottom + + @projection_bottom.setter + def projection_bottom(self, _bottom: float) -> None: + self._projection.bottom = _bottom + + @property + def projection_bottom_scaled(self) -> float: + """ + The bottom edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS scaled by zoom. + If this isn't what you want, + use projection_bottom instead. + """ + return self._projection.bottom / self._data.zoom + + @projection_bottom_scaled.setter + def projection_bottom_scaled(self, _bottom: float) -> None: + self._projection.bottom = _bottom * self._data.zoom + + @property + def projection_top(self) -> float: + """ + The top edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_top_scaled instead. + """ + return self._projection.top + + @projection_top.setter + def projection_top(self, _top: float) -> None: + self._projection.top = _top + + @property + def projection_top_scaled(self) -> float: + """ + The top edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS scaled by zoom. + If this isn't what you want, + use projection_top instead. + """ + return self._projection.top / self._data.zoom + + @projection_top_scaled.setter + def projection_top_scaled(self, _top: float) -> None: + self._projection.top = _top * self._data.zoom + + @property + def projection_near(self) -> float: + """ + The near plane of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + """ + return self._projection.near + + @projection_near.setter + def projection_near(self, _near: float) -> None: + self._projection.near = _near + + @property + def projection_far(self) -> float: + """ + The far plane of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + """ + return self._projection.far + + @projection_far.setter + def projection_far(self, _far: float) -> None: + self._projection.far = _far + + @property + def viewport(self) -> Tuple[int, int, int, int]: + """ + The pixel area that will be drawn to while the camera is active. + (left, right, bottom, top) + """ + return self._projection.viewport + + @viewport.setter + def viewport(self, _viewport: Tuple[int, int, int, int]) -> None: + self._projection.viewport = _viewport + + @property + def viewport_width(self) -> int: + """ + The width of the viewport. + Defines the number of pixels drawn too horizontally. + """ + return self._projection.viewport[2] + + @viewport_width.setter + def viewport_width(self, _width: int) -> None: + self._projection.viewport = (self._projection.viewport[0], self._projection.viewport[1], + _width, self._projection.viewport[3]) + + @property + def viewport_height(self) -> int: + """ + The height of the viewport. + Defines the number of pixels drawn too vertically. + """ + return self._projection.viewport[3] + + @viewport_height.setter + def viewport_height(self, _height: int) -> None: + self._projection.viewport = (self._projection.viewport[0], self._projection.viewport[1], + self._projection.viewport[2], _height) + + @property + def viewport_left(self) -> int: + """ + The left most pixel drawn to on the X axis. + """ + return self._projection.viewport[0] + + @viewport_left.setter + def viewport_left(self, _left: int) -> None: + self._projection.viewport = (_left,) + self._projection.viewport[1:] + + @property + def viewport_right(self) -> int: + """ + The right most pixel drawn to on the X axis. + """ + return self._projection.viewport[0] + self._projection.viewport[2] + + @viewport_right.setter + def viewport_right(self, _right: int) -> None: + """ + Set the right most pixel drawn to on the X axis. + This moves the position of the viewport, not change the size. + """ + self._projection.viewport = (_right - self._projection.viewport[2], self._projection.viewport[1], + self._projection.viewport[2], self._projection.viewport[3]) + + @property + def viewport_bottom(self) -> int: + """ + The bottom most pixel drawn to on the Y axis. + """ + return self._projection.viewport[1] + + @viewport_bottom.setter + def viewport_bottom(self, _bottom: int) -> None: + """ + Set the bottom most pixel drawn to on the Y axis. + """ + self._projection.viewport = (self._projection.viewport[0], _bottom, + self._projection.viewport[2], self._projection.viewport[3]) + + @property + def viewport_top(self) -> int: + """ + The top most pixel drawn to on the Y axis. + """ + return self._projection.viewport[1] + self._projection.viewport[3] + + @viewport_top.setter + def viewport_top(self, _top: int) -> None: + """ + Set the top most pixel drawn to on the Y axis. + This moves the position of the viewport, not change the size. + """ + self._projection.viewport = (self._projection.viewport[0], _top - self._projection.viewport[3], + self._projection.viewport[2], self._projection.viewport[3]) + + @property + def up(self) -> Tuple[float, float]: + """ + A 2D vector which describes what is mapped + to the +Y direction on screen. + This is equivalent to rotating the screen. + The base vector is 3D, but the simplified + camera only provides a 2D view. + """ + return self._data.up[:2] + + @up.setter + def up(self, _up: Tuple[float, float]) -> None: + """ + Set the 2D vector which describes what is + mapped to the +Y direction on screen. + This is equivalent to rotating the screen. + The base vector is 3D, but the simplified + camera only provides a 2D view. + + NOTE that this is assumed to be normalised. + """ + self._data.up = (_up[0], _up[1], 0.0) + + @property + def angle(self) -> float: + """ + An angle representation of the 2D UP vector. + This starts with 0 degrees as [0, 1] rotating + clock-wise. + """ + # Note that this is flipped as we want 0 degrees to be vert. Normally you have y first and then x. + return degrees(atan2(self._data.position[0], self._data.position[1])) + + @angle.setter + def angle(self, value: float) -> None: + """ + Set the 2D UP vector using an angle. + This starts with 0 degrees as [0, 1] + rotating clock-wise. + """ + _r = radians(value) + # Note that this is flipped as we want 0 degrees to be vert. + self._data.position = (sin(_r), cos(_r), 0.0) + + @property + def zoom(self) -> float: + """ + A scalar value which describes + how much the projection should + be scaled towards from its center. + + A value of 2.0 causes the projection + to be half its original size. + This causes sprites to appear 2.0x larger. + """ + return self._data.zoom + + @zoom.setter + def zoom(self, _zoom: float) -> None: + """ + Set the scalar value which describes + how much the projection should + be scaled towards from its center. + + A value of 2.0 causes the projection + to be half its original size. + This causes sprites to appear 2.0x larger. + """ + self._data.zoom = _zoom + + def equalise(self) -> None: + """ + Forces the projection to match the size of the viewport. + When matching the projection to the viewport the method keeps + the projections center in the same relative place. + """ + + self.projection_width = self.viewport_width + self.projection_height = self.viewport_height + + def match_screen(self, and_projection: bool = True) -> None: + """ + Sets the viewport to the size of the screen. + Should be called when the screen is resized. + + Args: + and_projection: Flag whether to also equalise the projection to the viewport. + """ + self.viewport = (0, 0, self._window.width, self._window.height) + + if and_projection: + self.equalise() + + def use(self) -> None: + """ + Set internal projector as window projector, + and set the projection and view matrix. + call every time you want to 'look through' this camera. + + If you want to use a 'with' block use activate() instead. + """ + self.render_target.use() + self._ortho_projector.use() + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + Set internal projector as window projector, + and set the projection and view matrix. + + This method works with 'with' blocks. + After using this method it automatically resets + the projector to the one previously in use. + """ + previous_projection = self._window.current_camera + previous_framebuffer = self._window.ctx.active_framebuffer + try: + self.render_target.use() + self.use() + yield self + finally: + previous_framebuffer.use() + previous_projection.use() + + def map_screen_to_world_coordinate( + self, + screen_coordinate: Tuple[float, float], + depth: float = 0.0 + ) -> Tuple[float, float]: + """ + Take in a pixel coordinate from within + the range of the window size and returns + the world space coordinates. + + Essentially reverses the effects of the projector. + + Args: + screen_coordinate: A 2D position in pixels from the bottom left of the screen. + This should ALWAYS be in the range of 0.0 - screen size. + depth: The depth value which is mapped along with the screen coordinates. Because of how + Orthographic perspectives work this does not impact how the screen_coordinates are mapped. + Returns: + A 2D vector (Along the XY plane) in world space (same as sprites). + perfect for finding if the mouse overlaps with a sprite or ui element irrespective + of the camera. + """ + + return self._ortho_projector.map_screen_to_world_coordinate(screen_coordinate, depth)[:2] diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py new file mode 100644 index 000000000..442e324cb --- /dev/null +++ b/arcade/camera/data_types.py @@ -0,0 +1,201 @@ +"""Packets of data and base types supporting cameras. + +These are placed in their own module to simplify imports due to their +wide usage throughout Arcade's camera code. +""" +from __future__ import annotations +from typing import Protocol, Tuple, Iterator +from contextlib import contextmanager + +from pyglet.math import Vec3 + + +__all__ = [ + 'CameraData', + 'OrthographicProjectionData', + 'PerspectiveProjectionData', + 'Projection', + 'Projector', + 'Camera' +] + + +class CameraData: + """Stores position, orientation, and zoom for a camera. + + This is like where a camera is placed in 3D space. + + Attributes: + position: A 3D vector which describes where the camera is located. + up: A 3D vector which describes which direction is up (+y). + forward: a 3D vector which describes which direction is forwards (+z). + zoom: A scaler that records the zoom of the camera. While this most often affects the projection matrix + it allows camera controllers access to the zoom functionality + without interacting with the projection data. + """ + + __slots__ = ("position", "up", "forward", "zoom") + + def __init__(self, + position: Tuple[float, float, float] = (0.0, 0.0, 0.0), + up: Tuple[float, float, float] = (0.0, 1.0, 0.0), + forward: Tuple[float, float, float] = (0.0, 0.0, -1.0), + zoom: float = 1.0): + + # View matrix data + self.position: Tuple[float, float, float] = position + self.up: Tuple[float, float, float] = up + self.forward: Tuple[float, float, float] = forward + + # Zoom + self.zoom: float = zoom + + def __str__(self): + return f"CameraData<{self.position=}, {self.up=}, {self.forward=}, {self.zoom=}>" + + def __repr__(self): + return self.__str__() + + +def duplicate_camera_data(origin: CameraData): + return CameraData(origin.position, origin.up, origin.forward, float(origin.zoom)) + + +def constrain_camera_data(data: CameraData, forward_priority: bool = False): + """ + Ensure that the camera data forward and up vectors are length one, + and are perpendicular + + :param data: the camera data to constrain + :param forward_priority: whether up or forward gets constrained + """ + forward_vec = Vec3(*data.forward).normalize() + up_vec = Vec3(*data.up).normalize() + right_vec = forward_vec.cross(up_vec).normalize() + if forward_priority: + up_vec = forward_vec.cross(right_vec) + else: + forward_vec = up_vec.cross(right_vec) + + data.forward = (forward_vec.x, forward_vec.y, forward_vec.z) + data.up = (up_vec.x, up_vec.y, up_vec.z) + + +class OrthographicProjectionData: + """Describes an Orthographic projection. + + This is by default a Left-handed system. with the X axis going from left to right, The Y axis going from + bottom to top, and the Z axis going from towards the screen to away from the screen. This can be made + right-handed by making the near value greater than the far value. + + Attributes: + left: The left most value, which gets mapped to x = -1.0 (anything below this value is not visible). + right: The right most value, which gets mapped to x = 1.0 (anything above this value is not visible). + bottom: The bottom most value, which gets mapped to y = -1.0 (anything below this value is not visible). + top: The top most value, which gets mapped to y = 1.0 (anything above this value is not visible). + near: The 'closest' value, which gets mapped to z = -1.0 (anything below this value is not visible). + far: The 'furthest' value, Which gets mapped to z = 1.0 (anything above this value is not visible). + viewport: The pixel bounds which will be drawn onto. (left, bottom, width, height) + """ + + __slots__ = ("left", "right", "bottom", "top", "near", "far", "viewport") + + def __init__( + self, + left: float, + right: float, + bottom: float, + top: float, + near: float, + far: float, + viewport: Tuple[int, int, int, int]): + + # Data for generating Orthographic Projection matrix + self.left: float = left + self.right: float = right + self.bottom: float = bottom + self.top: float = top + self.near: float = near + self.far: float = far + + # Viewport for setting which pixels to draw to + self.viewport: Tuple[int, int, int, int] = viewport + + def __str__(self): + return (f"OrthographicProjection<" + f"LRBT={(self.left, self.right, self.bottom, self.top)}, " + f"{self.near=}, " + f"{self.far=}, " + f"{self.viewport=}>") + + def __repr__(self): + return self.__str__() + + +class PerspectiveProjectionData: + """Describes a perspective projection. + + Attributes: + aspect: The aspect ratio of the screen (width over height). + fov: The field of view in degrees. With the aspect ratio defines + the size of the projection at any given depth. + near: The 'closest' value, which gets mapped to z = -1.0 (anything below this value is not visible). + far: The 'furthest' value, Which gets mapped to z = 1.0 (anything above this value is not visible). + viewport: The pixel bounds which will be drawn onto. (left, bottom, width, height) + """ + __slots__ = ("aspect", "fov", "near", "far", "viewport") + + def __init__(self, + aspect: float, + fov: float, + near: float, + far: float, + + viewport: Tuple[int, int, int, int]): + # Data for generating Perspective Projection matrix + self.aspect: float = aspect + self.fov: float = fov + self.near: float = near + self.far: float = far + + # Viewport for setting which pixels to draw to + self.viewport: Tuple[int, int, int, int] = viewport + + def __str__(self): + return f"PerspectiveProjection<{self.aspect=}, {self.fov=}, {self.near=}, {self.far=}, {self.viewport=}>" + + def __repr__(self): + return self.__str__() + + +class Projection(Protocol): + viewport: Tuple[int, int, int, int] + near: float + far: float + + +class Projector(Protocol): + + def use(self) -> None: + ... + + @contextmanager + def activate(self) -> Iterator[Projector]: + ... + + def map_screen_to_world_coordinate( + self, + screen_coordinate: Tuple[float, float], + depth: float = 0.0 + ) -> Tuple[float, ...]: + ... + + +class Camera(Protocol): + + def use(self) -> None: + ... + + @contextmanager + def activate(self) -> Iterator[Projector]: + ... diff --git a/arcade/camera/default.py b/arcade/camera/default.py new file mode 100644 index 000000000..71dfee10a --- /dev/null +++ b/arcade/camera/default.py @@ -0,0 +1,110 @@ +from typing import Optional, Tuple, Iterator, TYPE_CHECKING +from contextlib import contextmanager + +from pyglet.math import Mat4 + +from arcade.camera.data_types import Projector +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade.application import Window + +__all__ = [ + 'ViewportProjector', + 'DefaultProjector' +] + + +class ViewportProjector: + """ + A simple Projector which does not rely on any camera PoDs. + + Does not have a way of moving, rotating, or zooming the camera. + perfect for something like UI or for mapping to an offscreen framebuffer. + + Args: + viewport: The viewport to project to. + window: The window to bind the camera to. Defaults to the currently active window. + """ + def __init__(self, viewport: Optional[Tuple[int, int, int, int]] = None, *, window: Optional["Window"] = None): + self._window = window or get_window() + self._viewport = viewport or self._window.ctx.viewport + self._projection_matrix: Mat4 = Mat4.orthogonal_projection( + 0.0, self._viewport[2], + 0.0, self._viewport[3], + -100, 100 + ) + + @property + def viewport(self) -> Tuple[int, int, int, int]: + """ + The viewport use to derive projection and view matrix. + """ + return self._viewport + + @viewport.setter + def viewport(self, viewport: Tuple[int, int, int, int]) -> None: + self._viewport = viewport + self._projection_matrix = Mat4.orthogonal_projection( + 0, viewport[2], + 0, viewport[3], + -100, 100) + + def use(self) -> None: + """ + Set the window's projection and view matrix. + Also sets the projector as the windows current camera. + """ + self._window.current_camera = self + + self._window.ctx.viewport = self._viewport + + self._window.ctx.view_matrix = Mat4() + self._window.ctx.projection_matrix = self._projection_matrix + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + The context manager version of the use method. + + usable with the 'with' block. e.g. 'with ViewportProjector.activate() as cam: ...' + """ + previous = self._window.current_camera + try: + self.use() + yield self + finally: + previous.use() + + def map_screen_to_world_coordinate(self, screen_coordinate: Tuple[float, float], depth=0.0) -> Tuple[float, float]: + """ + Map the screen pos to screen_coordinates. + + Due to the nature of viewport projector this does not do anything. + """ + return screen_coordinate + + +# As this class is only supposed to be used internally +# I wanted to place an _ in front, but the linting complains +# about it being a protected class. +class DefaultProjector(ViewportProjector): + """ + An extremely limited projector which lacks any kind of control. This is only here to act as the default camera + used internally by arcade. There should be no instance where a developer would want to use this class. + + :param window: The window to bind the camera to. Defaults to the currently active window. + """ + + def __init__(self, *, window: Optional["Window"] = None): + super().__init__(window=window) + + def use(self) -> None: + """ + Set the window's Projection and View matrices. + + cache's the window viewport to determine the projection matrix. + """ + + if self._window.ctx.viewport != self.viewport: + self.viewport = self._window.ctx.viewport + super().use() diff --git a/arcade/camera/grips/__init__.py b/arcade/camera/grips/__init__.py new file mode 100644 index 000000000..544c904a2 --- /dev/null +++ b/arcade/camera/grips/__init__.py @@ -0,0 +1,12 @@ +from arcade.camera.grips.strafe import strafe +from arcade.camera.grips.rotate import rotate_around_forward, rotate_around_up, rotate_around_right +from arcade.camera.grips.screen_shake_2d import ScreenShake2D + + +__all__ = ( + "strafe", + "rotate_around_right", + "rotate_around_up", + "rotate_around_forward", + "ScreenShake2D" +) diff --git a/arcade/camera/grips/rotate.py b/arcade/camera/grips/rotate.py new file mode 100644 index 000000000..dfa8edad6 --- /dev/null +++ b/arcade/camera/grips/rotate.py @@ -0,0 +1,59 @@ +from typing import Tuple + +from pyglet.math import Vec3 + +from arcade.math import quaternion_rotation +from arcade.camera.data_types import CameraData + + +__all__ = ( + "rotate_around_forward", + "rotate_around_up", + "rotate_around_right" +) + + +def rotate_around_forward(data: CameraData, angle: float) -> Tuple[float, float, float]: + """ + Rotate the CameraData up vector around the CameraData forward vector, perfect for rotating the screen. + This rotation will be around (0.0, 0.0) of the camera projection. + If that is not the center of the screen this method may appear erroneous. + Uses arcade.camera.controllers.quaternion_rotation internally. + + :param data: The camera data to modify. The data's up vector is rotated around its forward vector + :param angle: The angle in degrees to rotate clockwise by + """ + return quaternion_rotation(data.forward, data.up, angle) + + +def rotate_around_up(data: CameraData, angle: float) -> Tuple[float, float, float]: + """ + Rotate the CameraData forward vector around the CameraData up vector. + Generally only useful in 3D games. + Uses arcade.camera.controllers.quaternion_rotation internally. + + :param data: The camera data to modify. The data's forward vector is rotated around its up vector + :param angle: The angle in degrees to rotate clockwise by + """ + return quaternion_rotation(data.up, data.forward, angle) + + +def rotate_around_right( + data: CameraData, + angle: float) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: + """ + Rotate both the CameraData's forward vector and up vector around a calculated right vector. + Generally only useful in 3D games. + Uses arcade.camera.controllers.quaternion_rotation internally. + + :param data: The camera data to modify. The data's forward vector is rotated around its up vector + :param angle: The angle in degrees to rotate clockwise by + """ + _forward = Vec3(data.forward[0], data.forward[1], data.forward[2]) + _up = Vec3(data.up[0], data.up[1], data.up[2]) + _crossed_vec = _forward.cross(_up) + _right: Tuple[float, float, float] = (_crossed_vec.x, _crossed_vec.y, _crossed_vec.z) + new_forward = quaternion_rotation(_right, data.forward, angle) + new_up = quaternion_rotation(_right, data.up, angle) + return new_up, new_forward + diff --git a/arcade/camera/grips/screen_shake_2d.py b/arcade/camera/grips/screen_shake_2d.py new file mode 100644 index 000000000..557ab95be --- /dev/null +++ b/arcade/camera/grips/screen_shake_2d.py @@ -0,0 +1,298 @@ +""" +ScreenShakeController2D: + Provides an easy way to cause a camera to shake. +""" +from typing import Tuple +from math import exp, log, pi, sin, floor +from random import uniform + +from arcade.camera.data_types import CameraData +from arcade.math import quaternion_rotation + + +__all__ = ( + "ScreenShake2D", +) + + +class ScreenShake2D: + """ + Offsets the camera position in a random direction repeatedly over + a set length of time to create a screen shake effect. + + The amplitude of the screen-shaking grows based on two functions. + The first is a simple sin wave whose frequency is adjustable. + This is multiplied with a pair of equations which go from 0-1 smoothly. + the equation rises using a inverse exponential equation, before decreasing + using a modified smooth-step sigmoid. + + Attributes: + max_amplitude: The largest possible world space offset. + falloff_duration: The length of time in seconds it takes the shaking + to reach 0 after reaching the maximum. Can be set + to a negative number to disable falloff. + shake_frequency: The number of peaks per second. Avoid making it + a multiple of half the target frame-rate. + (e.g. at 60 fps avoid 30, 60, 90, 120, etc.) + + :param camera_data: The CameraData PoD that the controller modifies. + Should not be changed once initialized. + :param max_amplitude: The largest possible world space offset. + :param falloff_time: The length of time in seconds it takes the shaking + to reach 0 after reaching the maximum. Can be set + to a negative number to disable falloff. + :param acceleration_duration: The length of time in seconds it takes the + shaking to reach max amplitude. Can be set + to 0.0 to start at max amplitude. + :param shake_frequency: The number of peaks per second. Avoid making it + a multiple of half the target frame-rate. + (e.g. at 60 fps avoid 30, 60, 90, 120, etc.) + """ + + def __init__(self, camera_data: CameraData, *, + max_amplitude: float = 1.0, + falloff_time: float = 1.0, + acceleration_duration: float = 1.0, + shake_frequency: float = 15.0): + self._data: CameraData = camera_data + + self.max_amplitude: float = max_amplitude + self.falloff_duration: float = falloff_time + self.shake_frequency: float = shake_frequency + self._acceleration_duration: float = acceleration_duration + + self._shaking: bool = False + self._length_shaking: float = 0.0 + + self._current_dir: float = 0.0 + self._last_vector: Tuple[float, float, float] = (0.0, 0.0, 0.0) + self._last_update_time: float = 0.0 + + @property + def shaking(self) -> bool: + """Read only property to check if the controller is currently shaking the camera.""" + return self._shaking + + @property + def duration(self) -> float: + """ + The length of the screen shake in seconds. + + If falloff is disabled (by setting falloff_duration to a negative number) only returns the + acceleration duration. + + Setting the duration to a negative number disables falloff. + While the falloff is disabled setting duration will only set the acceleration. + Otherwise, scales both the acceleration and falloff time to match new duration. + """ + if self.falloff_duration < 0.0: + return self._acceleration_duration + return self._acceleration_duration + self.falloff_duration + + @duration.setter + def duration(self, _duration: float) -> None: + if _duration <= 0.0: + self.falloff_duration = -1.0 + + elif self.falloff_duration < 0.0: + self._acceleration_duration = _duration + return + + else: + ratio = _duration / self.duration + self._acceleration_duration = ratio * self._acceleration_duration + self.falloff_duration = ratio * self.falloff_duration + + @property + def current_amplitude(self) -> float: + """Read only property which provides the current shake amplitude.""" + return self._calc_amplitude() * self.max_amplitude + + @property + def acceleration_duration(self) -> float: + """ + The length of time in seconds it takes for the shaking to reach max amplitude. + + Setting to a value less than zero causes the amplitude to start at max. + """ + return self._acceleration_duration + + @acceleration_duration.setter + def acceleration_duration(self, _duration: float) -> None: + if _duration < 0.0: + self._acceleration_duration = 0.0 + else: + self._acceleration_duration = _duration + + @property + def acceleration(self) -> float: + """ + The inverse of acceleration time. + + setting to a value less than zero causes the amplitude to start at max. + """ + if self._acceleration_duration <= 0.0: + return 0.0 + return 1 / self._acceleration_duration + + @acceleration.setter + def acceleration(self, _acceleration: float) -> None: + if _acceleration <= 0.0: + self._acceleration_duration = 0.0 + else: + self._acceleration_duration = 1 / _acceleration + + @property + def falloff(self) -> float: + """ + The maximum gradient of the amplitude falloff, + and is the gradient at the inflection point of the sigmoid equation. + + Is inversely proportional to the falloff duration by a factor of 15/8. + """ + if self.falloff_duration < 0.0: + return -1.0 + return (15 / 8) * (1 / self.falloff_duration) + + @falloff.setter + def falloff(self, _falloff: float) -> None: + if _falloff <= 0.0: + self.falloff_duration = -1.0 + else: + self.falloff_duration = (15 / 8) * (1 / _falloff) + + def _acceleration_amp(self, _t: float) -> float: + """ + The equation for the growing half of the amplitude equation. + It uses 1.0001 so that at _t = 1.0 the amplitude equals 1.0. + + :param _t: The scaled time. Should be between 0.0 and 1.0 + """ + return 1.0001 - 1.0001*exp(log(0.0001/1.0001) * _t) + + def _falloff_amp(self, _t: float) -> float: + """ + The equation for the falloff half of the amplitude equation. + It is based on the 'smootherstep' function. + + :param _t: The scaled time. Should be between 0.0 and 1.0 + """ + return 1 - _t**3 * (_t * (_t * 6.0 - 15.0) + 10.0) + + def _calc_max_amp(self) -> float: + """ + Determine the maximum amplitude by using either _acceleration_amp() or _falloff_amp(). + If falloff duration is less than 0.0 then the falloff never begins and + """ + if self._length_shaking <= self._acceleration_duration: + _t = self._length_shaking / self._acceleration_duration + return self._acceleration_amp(_t) + + if self.falloff_duration < 0.0: + return self.max_amplitude + + if self._length_shaking <= self.duration: + _t = (self._length_shaking - self._acceleration_duration) / self.falloff_duration + return self._falloff_amp(_t) + + return 0.0 + + def _calc_amplitude(self) -> float: + _max_amp = self._calc_max_amp() + _sin_amp = sin(self.shake_frequency * 2.0 * pi * self._length_shaking) + + return _sin_amp * _max_amp + + def reset(self) -> None: + """ + Reset the temporary shaking variables. WILL NOT STOP OR START SCREEN SHAKE. + """ + self._current_dir = 0.0 + self._last_vector = (0.0, 0.0, 0.0) + self._last_update_time = 0.0 + self._length_shaking = 0.0 + + def start(self) -> None: + """ + Start the screen-shake. + """ + self.reset() + self._shaking = True + + def stop(self) -> None: + """ + Instantly stop the screen-shake. + """ + self._data.position = ( + self._data.position[0] - self._last_vector[0], + self._data.position[1] - self._last_vector[1], + self._data.position[2] - self._last_vector[2] + ) + + self.reset() + self._shaking = False + + def update(self, delta_time: float) -> None: + """ + Update the time, and decide if the shaking should stop. + Does not actually set the camera position. + Should not be called more than once an update cycle. + + :param delta_time: the length of time in seconds between update calls. + Generally pass in the delta_time provided by the + arcade.Window's on_update method. + """ + if not self._shaking: + return + + self._length_shaking += delta_time + + if self.falloff_duration > 0.0 and self._length_shaking >= self.duration: + self.stop() + + def update_camera(self) -> None: + """ + Update the position of the camera. Call this just before using the camera. + because the controller is modifying the PoD directly it stores the last + offset and resets the camera's position before adding the next offset. + """ + if not self._shaking: + return + + if (floor(self._last_update_time * 2 * self.shake_frequency) < + floor(self._length_shaking * 2.0 * self.shake_frequency))\ + or self._last_update_time == 0.0: + self._current_dir = uniform(-180, 180) + + _amp = self._calc_amplitude() * self.max_amplitude + _vec = quaternion_rotation(self._data.forward, self._data.up, self._current_dir) + + _last = self._last_vector + _pos = self._data.position + + self._data.position = ( + _pos[0] - _last[0] + _vec[0] * _amp, + _pos[1] - _last[1] + _vec[1] * _amp, + _pos[2] - _last[2] + _vec[2] * _amp + ) + + self._last_vector = ( + _vec[0] * _amp, + _vec[1] * _amp, + _vec[2] * _amp + ) + self._last_update_time = self._length_shaking + + def readjust_camera(self) -> None: + """ + Can be called after the camera has been used revert the screen_shake. + While not strictly necessary it is highly advisable. If you are moving the + camera using an animation or something similar the behavior can start to go + awry if you do not readjust after the screen shake. + """ + self._data.position = ( + self._data.position[0] - self._last_vector[0], + self._data.position[1] - self._last_vector[1], + self._data.position[2] - self._last_vector[2] + ) + self._last_vector = (0.0, 0.0, 0.0) diff --git a/arcade/camera/grips/strafe.py b/arcade/camera/grips/strafe.py new file mode 100644 index 000000000..de9b744f0 --- /dev/null +++ b/arcade/camera/grips/strafe.py @@ -0,0 +1,25 @@ +from typing import Tuple + +from pyglet.math import Vec3 + +from arcade.camera.data_types import CameraData + + +def strafe(data: CameraData, direction: Tuple[float, float]) -> Tuple[float, float, float]: + """ + Move the CameraData in a 2D direction aligned to the up-right plane of the view. + A value of [1, 0] will move the camera sideways while a value of [0, 1] + will move the camera upwards. Works irrespective of which direction the camera is facing. + """ + _forward = Vec3(*data.forward).normalize() + _up = Vec3(*data.up).normalize() + _right = _forward.cross(_up) + + _pos = data.position + + offset = _right * direction[0] + _up * direction[1] + return ( + _pos[0] + offset[0], + _pos[1] + offset[1], + _pos[2] + offset[2] + ) diff --git a/arcade/camera/orthographic.py b/arcade/camera/orthographic.py new file mode 100644 index 000000000..addd16fae --- /dev/null +++ b/arcade/camera/orthographic.py @@ -0,0 +1,187 @@ +from typing import Optional, Tuple, Iterator, TYPE_CHECKING +from contextlib import contextmanager + +from pyglet.math import Mat4, Vec3, Vec4 + +from arcade.camera.data_types import Projector, CameraData, OrthographicProjectionData + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade import Window + + +__all__ = [ + 'OrthographicProjector' +] + + +class OrthographicProjector: + """ + The simplest from of an orthographic camera. + Using ViewData and OrthographicProjectionData PoDs (Pack of Data) + it generates the correct projection and view matrices. It also + provides methods and a context manager for using the matrices in + glsl shaders. + + This class provides no methods for manipulating the PoDs. + + The current implementation will recreate the view and + projection matrices every time the camera is used. + If used every frame or multiple times per frame this may + be inefficient. If you suspect this is causing slowdowns + profile before optimizing with a dirty value check. + + Initialize a Projector which produces an orthographic projection matrix using + a CameraData and PerspectiveProjectionData PoDs. + + :param window: The window to bind the camera to. Defaults to the currently active camera. + :param view: The CameraData PoD. contains the viewport, position, up, forward, and zoom. + :param projection: The OrthographicProjectionData PoD. + contains the left, right, bottom top, near, and far planes. + """ + def __init__(self, *, + window: Optional["Window"] = None, + view: Optional[CameraData] = None, + projection: Optional[OrthographicProjectionData] = None): + self._window: "Window" = window or get_window() + + self._view = view or CameraData( # Viewport + (self._window.width / 2, self._window.height / 2, 0), # Position + (0.0, 1.0, 0.0), # Up + (0.0, 0.0, -1.0), # Forward + 1.0 # Zoom + ) + + self._projection = projection or OrthographicProjectionData( + -0.5 * self._window.width, 0.5 * self._window.width, # Left, Right + -0.5 * self._window.height, 0.5 * self._window.height, # Bottom, Top + -100, 100, # Near, Far + + (0, 0, self._window.width, self._window.height) # Viewport + ) + + @property + def view(self) -> CameraData: + """ + The CameraData. Is a read only property. + """ + return self._view + + @property + def projection(self) -> OrthographicProjectionData: + """ + The OrthographicProjectionData. Is a read only property. + """ + return self._projection + + def _generate_projection_matrix(self) -> Mat4: + """ + Using the OrthographicProjectionData a projection matrix is generated where the size of the + objects is not affected by depth. + + Generally keep the scale value to integers or negative powers of integers (2^-1, 3^-1, 2^-2, etc.) to keep + the pixels uniform in size. Avoid a zoom of 0.0. + """ + + # Find the center of the projection values (often 0,0 or the center of the screen) + _projection_center = ( + (self._projection.left + self._projection.right) / 2, + (self._projection.bottom + self._projection.top) / 2 + ) + + # Find half the width of the projection + _projection_half_size = ( + (self._projection.right - self._projection.left) / 2, + (self._projection.top - self._projection.bottom) / 2 + ) + + # Scale the projection by the zoom value. Both the width and the height + # share a zoom value to avoid ugly stretching. + _true_projection = ( + _projection_center[0] - _projection_half_size[0] / self._view.zoom, + _projection_center[0] + _projection_half_size[0] / self._view.zoom, + _projection_center[1] - _projection_half_size[1] / self._view.zoom, + _projection_center[1] + _projection_half_size[1] / self._view.zoom + ) + return Mat4.orthogonal_projection(*_true_projection, self._projection.near, self._projection.far) + + def _generate_view_matrix(self) -> Mat4: + """ + Using the ViewData it generates a view matrix from the pyglet Mat4 look at function + """ + # Even if forward and up are normalised floating point error means every vector must be normalised. + fo = Vec3(*self._view.forward).normalize() # Forward Vector + up = Vec3(*self._view.up) # Initial Up Vector (Not necessarily perpendicular to forward vector) + ri = fo.cross(up).normalize() # Right Vector + up = ri.cross(fo).normalize() # Up Vector + po = Vec3(*self._view.position) + return Mat4(( + ri.x, up.x, -fo.x, 0, + ri.y, up.y, -fo.y, 0, + ri.z, up.z, -fo.z, 0, + -ri.dot(po), -up.dot(po), fo.dot(po), 1 + )) + + def use(self) -> None: + """ + Sets the active camera to this object. + Then generates the view and projection matrices. + Finally, the gl context viewport is set, as well as the projection and view matrices. + """ + + self._window.current_camera = self + + _projection = self._generate_projection_matrix() + _view = self._generate_view_matrix() + + self._window.ctx.viewport = self._projection.viewport + self._window.projection = _projection + self._window.view = _view + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + A context manager version of OrthographicProjector.use() which allows for the use of + `with` blocks. For example, `with camera.activate() as cam: ...`. + """ + previous_projector = self._window.current_camera + try: + self.use() + yield self + finally: + previous_projector.use() + + def map_screen_to_world_coordinate( + self, + screen_coordinate: Tuple[float, float], + depth: float = 0.0 + ) -> Tuple[float, float, float]: + """ + Take in a pixel coordinate from within + the range of the window size and returns + the world space coordinates. + + Essentially reverses the effects of the projector. + + Args: + screen_coordinate: A 2D position in pixels from the bottom left of the screen. + This should ALWAYS be in the range of 0.0 - screen size. + depth: The depth of the query + Returns: + A 3D vector in world space. + """ + # TODO: Integrate z-depth + screen_x = 2.0 * (screen_coordinate[0] - self._projection.viewport[0]) / self._projection.viewport[2] - 1 + screen_y = 2.0 * (screen_coordinate[1] - self._projection.viewport[1]) / self._projection.viewport[3] - 1 + screen_z = 2.0 * (depth - self._projection.near) / (self._projection.far - self._projection.near) - 1 + + _view = self._generate_view_matrix() + _projection = self._generate_projection_matrix() + + screen_position = Vec4(screen_x, screen_y, screen_z, 1.0) + + _full = ~(_projection @ _view) + + _mapped_position = _full @ screen_position + + return _mapped_position[0], _mapped_position[1], _mapped_position[2] diff --git a/arcade/camera/simple_camera.py b/arcade/camera/simple_camera.py new file mode 100644 index 000000000..3297bdcd1 --- /dev/null +++ b/arcade/camera/simple_camera.py @@ -0,0 +1,410 @@ +from typing import TYPE_CHECKING, Optional, Tuple, Iterator +from warnings import warn +from contextlib import contextmanager +from math import atan2, cos, sin, degrees, radians + +from pyglet.math import Vec3 + +from arcade.camera.data_types import Projector, CameraData, OrthographicProjectionData +from arcade.camera.orthographic import OrthographicProjector + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade import Window + + +__all__ = [ + 'SimpleCamera' +] + + +class SimpleCamera: + """ + A simple camera which uses an orthographic camera and a simple 2D Camera Controller. + It also implements an update method that allows for an interpolation between two points + + Written to be backwards compatible with the old SimpleCamera. + + Initialize a Simple Camera Instance with either Camera PoDs or individual arguments + + .. depreciated:: 3.0 + use :cls:`.Camera2D` instead + + :param window: The Arcade Window to bind the camera to. + Defaults to the currently active window. + :param viewport: A 4-int tuple which defines the pixel bounds which the camera with project to. + :param position: The 2D position of the camera in the XY plane. + :param up: The 2D unit vector which defines the +Y-axis of the camera space. + :param zoom: A scalar value which is inversely proportional to the size of the camera projection. + i.e. a zoom of 2.0 halves the size of the projection, doubling the perceived size of objects. + :param projection: A 4-float tuple which defines the world space + bounds which the camera projects to the viewport. + :param near: The near clipping plane of the camera. + :param far: The far clipping place of the camera. + :param camera_data: A CameraData PoD which describes the viewport, position, up, and zoom + :param projection_data: A OrthographicProjectionData PoD which describes the left, right, top, + bottom, far, near planes for an orthographic projection. + """ + def __init__(self, *, + window: Optional["Window"] = None, + viewport: Optional[Tuple[int, int, int, int]] = None, + projection: Optional[Tuple[float, float, float, float]] = None, + position: Optional[Tuple[float, float]] = None, + up: Optional[Tuple[float, float]] = None, + zoom: Optional[float] = None, + near: Optional[float] = None, + far: Optional[float] = None, + camera_data: Optional[CameraData] = None, + projection_data: Optional[OrthographicProjectionData] = None + ): + warn("arcade.camera.SimpleCamera has been depreciated please use arcade.camera.Camera2D instead", + DeprecationWarning) + + self._window = window or get_window() + + if any((viewport, projection, position, up, zoom, near, far)) and any((camera_data, projection_data)): + raise ValueError("Provided both data structures and raw values." + "Only supply one or the other") + + if any((viewport, projection, position, up, zoom, near, far)): + _pos = position or (0.0, 0.0) + _up = up or (0.0, 1.0) + self._view = CameraData( + (_pos[0], _pos[1], 0.0), + (_up[0], _up[1], 0.0), + (0.0, 0.0, -1.0), + zoom or 1.0 + ) + _projection = projection or ( + 0.0, self._window.width, + 0.0, self._window.height + ) + self._projection = OrthographicProjectionData( + _projection[0] or 0.0, _projection[1] or self._window.width, # Left, Right + _projection[2] or 0.0, _projection[3] or self._window.height, # Bottom, Top + near or -100, far or 100, # Near, Far + viewport or (0, 0, self._window.width, self._window.height), # Viewport + ) + else: + self._view = camera_data or CameraData( + (0.0, 0.0, 0.0), # Position + (0, 1.0, 0.0), # Up + (0.0, 0.0, -1.0), # Forward + 1.0 # Zoom + ) + self._projection = projection_data or OrthographicProjectionData( + 0.0, self._window.width, # Left, Right + 0.0, self._window.height, # Bottom, Top + -100, 100, # Near, Far + (0, 0, self._window.width, self._window.height), # Viewport + ) + + self._camera = OrthographicProjector( + window=self._window, + view=self._view, + projection=self._projection + ) + + self._easing_speed: float = 0.0 + self._position_goal: Tuple[float, float] = self.position + + # Basic properties for modifying the viewport and orthographic projection + + @property + def viewport_width(self) -> int: + """ Returns the width of the viewport """ + return self._projection.viewport[2] + + @property + def viewport_height(self) -> int: + """ Returns the height of the viewport """ + return self._projection.viewport[3] + + @property + def viewport(self) -> Tuple[int, int, int, int]: + """ The pixel area that will be drawn to while this camera is active (left, bottom, width, height) """ + return self._projection.viewport + + @viewport.setter + def viewport(self, viewport: Tuple[int, int, int, int]) -> None: + """ Set the viewport (left, bottom, width, height) """ + self.set_viewport(viewport) + + def set_viewport(self, viewport: Tuple[int, int, int, int]) -> None: + self._projection.viewport = viewport + + @property + def projection(self) -> Tuple[float, float, float, float]: + """ + The dimensions that will be projected to the viewport. (left, right, bottom, top). + """ + return self._projection.left, self._projection.right, self._projection.bottom, self._projection.top + + @projection.setter + def projection(self, projection: Tuple[float, float, float, float]) -> None: + """ + Update the orthographic projection of the camera. (left, right, bottom, top). + """ + self._projection.left = projection[0] + self._projection.right = projection[1] + self._projection.bottom = projection[2] + self._projection.top = projection[3] + + # Methods for retrieving the viewport - projection ratios. Originally written by Alejandro Casanovas. + @property + def viewport_to_projection_width_ratio(self) -> float: + """ + The ratio of viewport width to projection width. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to more than one unit (Zoom out). + """ + return (self.viewport_width * self.zoom) / (self._projection.left - self._projection.right) + + @property + def viewport_to_projection_height_ratio(self) -> float: + """ + The ratio of viewport height to projection height. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to more than one unit (Zoom out). + """ + return (self.viewport_height * self.zoom) / (self._projection.bottom - self._projection.top) + + @property + def projection_to_viewport_width_ratio(self) -> float: + """ + The ratio of projection width to viewport width. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to less than one unit (Zoom in). + """ + return (self._projection.left - self._projection.right) / (self.zoom * self.viewport_width) + + @property + def projection_to_viewport_height_ratio(self) -> float: + """ + The ratio of projection height to viewport height. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to less than one unit (Zoom in). + """ + return (self._projection.bottom - self._projection.top) / (self.zoom * self.viewport_height) + + # Control methods (movement, zooming, rotation) + @property + def position(self) -> Tuple[float, float]: + """ + The position of the camera based on the bottom left coordinate. + """ + return self._view.position[0], self._view.position[1] + + @position.setter + def position(self, pos: Tuple[float, float]) -> None: + """ + Set the position of the camera based on the bottom left coordinate. + """ + self._view.position = (pos[0], pos[1], self._view.position[2]) + + @property + def zoom(self) -> float: + """ + A scaler which adjusts the size of the orthographic projection. + A higher zoom value means larger pixels. + For best results keep the zoom value an integer to an integer or an integer to the power of -1. + """ + return self._view.zoom + + @zoom.setter + def zoom(self, zoom: float) -> None: + """ + A scaler which adjusts the size of the orthographic projection. + A higher zoom value means larger pixels. + For best results keep the zoom value an integer to an integer or an integer to the power of -1. + """ + self._view.zoom = zoom + + @property + def up(self) -> Tuple[float, float]: + """ + A 2D normalised vector which defines which direction corresponds to the +Y axis. + """ + return self._view.up[0], self._view.up[1] + + @up.setter + def up(self, up: Tuple[float, float]) -> None: + """ + A 2D normalised vector which defines which direction corresponds to the +Y axis. + generally easier to use the `rotate` and `rotate_to` methods as they use an angle value. + """ + _up = Vec3(up[0], up[1], 0.0).normalize() + self._view.up = (_up[0], _up[1], _up[2]) + + @property + def angle(self) -> float: + """ + An alternative way of setting the up vector of the camera. + The angle value goes clock-wise starting from (0.0, 1.0). + """ + return degrees(atan2(self.up[0], self.up[1])) + + @angle.setter + def angle(self, angle: float) -> None: + """ + An alternative way of setting the up vector of the camera. + The angle value goes clock-wise starting from (0.0, 1.0). + """ + rad = radians(angle) + self.up = ( + sin(rad), + cos(rad) + ) + + def move_to(self, vector: Tuple[float, float], speed: float = 1.0) -> None: + """ + Sets the goal position of the camera. + + The camera will lerp towards this position based on the provided speed, + updating its position every time the use() function is called. + + Args: + vector: The 2D vector position to move the camera towards (in the XY plane) + speed: How fast to move the camera, 1.0 is instant, 0.1 moves slowly + """ + self._position_goal = vector + self._easing_speed = speed + + def move(self, vector: Tuple[float, float]) -> None: + """ + Moves the camera with a speed of 1.0, aka instant move + + This is equivalent to calling move_to(my_pos, 1.0) + """ + self.move_to(vector, 1.0) + + def center(self, vector: Tuple[float, float], speed: float = 1.0) -> None: + """ + Centers the camera. Allows for a linear lerp like the move_to() method. + """ + viewport_center = self.viewport_width / 2, self.viewport_height / 2 + + adjusted_vector = ( + vector[0] * self.viewport_to_projection_width_ratio, + vector[1] * self.viewport_to_projection_height_ratio + ) + + target = ( + adjusted_vector[0] - viewport_center[0], + adjusted_vector[1] - viewport_center[1] + ) + + self.move_to(target, speed) + + # General Methods + + def update(self): + """ + Update the camera's position. + """ + if self._easing_speed > 0.0: + x_a = self.position[0] + x_b = self._position_goal[0] + + y_a = self.position[1] + y_b = self._position_goal[1] + + self.position = ( + x_a + (x_b - x_a) * self._easing_speed, # Linear Lerp X position + y_a + (y_b - y_a) * self._easing_speed # Linear Lerp Y position + ) + if self.position == self._position_goal: + self._easing_speed = 0.0 + + def use(self) -> None: + """ + Sets the active camera to this object. + Then generates the view and projection matrices. + Finally, the gl context viewport is set, as well as the projection and view matrices. + This method also calls the update method. This can cause the camera to move faster than expected + if the camera is used multiple times in a single frame. + """ + + # Updated the position + self.update() + + # set matrices + self._camera.use() + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + A context manager version of Camera2DOrthographic.use() which allows for the use of + `with` blocks. For example, `with camera.activate() as cam: ...`. + """ + previous_projector = self._window.current_camera + try: + self.use() + yield self + finally: + previous_projector.use() + + def map_coordinate(self, screen_coordinate: Tuple[float, float], depth: float = 0.0) -> Tuple[float, float]: + """ + Take in a pixel coordinate from within + the range of the viewport and returns + the world space coordinates. + + .. deprecated:: 3.0 + Use :meth:`.map_screen_to_world_coordinate` instead. + + Essentially reverses the effects of the projector. + + Args: + screen_coordinate: A 2D position in pixels from the bottom left of the screen. + This should ALWAYS be in the range of 0.0 - screen size. + depth: The depth value which is mapped along with the screen coordinates. Because of how + Orthographic perspectives work this does not impact how the screen_coordinates are mapped. + Returns: + A 2D vector (Along the XY plane) in world space (same as sprites). + perfect for finding if the mouse overlaps with a sprite or ui element irrespective + of the camera. + """ + + return self._camera.map_screen_to_world_coordinate(screen_coordinate, depth)[:2] + + def map_screen_to_world_coordinate( + self, + screen_coordinate: Tuple[float, float], + depth: float = 0.0 + ) -> Tuple[float, float]: + """ + Take in a pixel coordinate from within + the range of the window size and returns + the world space coordinates. + + Essentially reverses the effects of the projector. + + Args: + screen_coordinate: A 2D position in pixels from the bottom left of the screen. + This should ALWAYS be in the range of 0.0 - screen size. + depth: The depth value which is mapped along with the screen coordinates. Because of how + Orthographic perspectives work this does not impact how the screen_coordinates are mapped. + Returns: + A 2D vector (Along the XY plane) in world space (same as sprites). + perfect for finding if the mouse overlaps with a sprite or ui element irrespective + of the camera. + """ + + return self._camera.map_screen_to_world_coordinate(screen_coordinate, depth)[:2] + + def resize(self, viewport_width: int, viewport_height: int, *, + resize_projection: bool = True) -> None: + """ + Resize the camera's viewport. Call this when the window resizes. + + Args: + viewport_width: Width of the viewport. + viewport_height: Height of the viewport. + resize_projection: If True the projection will also be resized. + """ + new_viewport = (self.viewport[0], self.viewport[1], viewport_width, viewport_height) + self.set_viewport(new_viewport) + if resize_projection: + self.projection = (self._projection.left, viewport_width, + self._projection.bottom, viewport_height) diff --git a/arcade/context.py b/arcade/context.py index cbe908fd4..eba878b36 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -5,7 +5,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Iterable, Dict, Optional, Tuple, Union, Sequence +from typing import Any, Iterable, Dict, Optional, Union, Sequence, Tuple from contextlib import contextmanager import pyglet @@ -57,12 +57,14 @@ def __init__(self, window: pyglet.window.Window, gc_mode: str = "context_gc", gl # Set up a default orthogonal projection for sprites and shapes self._window_block: UniformBufferObject = window.ubo self.bind_window_block() - self.projection_2d = ( + self.viewport = ( 0, - self.screen.width, 0, + self.screen.width, self.screen.height, ) + self.projection_matrix = Mat4.orthogonal_projection(0, self.screen.width, 0, self.screen.height, + -1, 1) # --- Pre-load system shaders here --- # FIXME: These pre-created resources needs to be packaged nicely @@ -135,6 +137,14 @@ def __init__(self, window: pyglet.window.Window, gc_mode: str = "context_gc", gl self.collision_buffer = self.buffer(reserve=1024 * 4) self.collision_query = self.query(samples=False, time=False, primitives=True) + # General Utility + + # renders a quad (without projection) with a single 4-component texture. + self.utility_textured_quad_program: Program = self.load_program( + vertex_shader=":system:shaders/util/textured_quad_vs.glsl", + fragment_shader=":system:shaders/util/textured_quad_fs.glsl", + ) + # --- Pre-created geometry and buffers for unbuffered draw calls ---- # FIXME: These pre-created resources needs to be packaged nicely # Just having them globally in the context is probably not a good idea @@ -203,7 +213,9 @@ def reset(self) -> None: self.screen.use(force=True) self.bind_window_block() # self.active_program = None - arcade.set_viewport(0, self.window.width, 0, self.window.height) + self.viewport = 0, 0, self.window.width, self.window.height + self.view_matrix = Mat4() + self.projection_matrix = Mat4.orthogonal_projection(0, self.window.width, 0, self.window.height, -100, 100) self.enable_only(self.BLEND) self.blend_func = self.BLEND_DEFAULT self.point_size = 1.0 @@ -243,54 +255,10 @@ def default_atlas(self) -> TextureAtlas: return self._atlas @property - def projection_2d(self) -> Tuple[float, float, float, float]: - """Get or set the global orthogonal projection for arcade. - - This projection is used by sprites and shapes and is represented - by four floats: ``(left, right, bottom, top)`` - - When reading this property we reconstruct the projection parameters - from pyglet's projection matrix. When setting this property - we construct an orthogonal projection matrix and set it in pyglet. - - :type: Tuple[float, float, float, float] - """ - mat = self.window.projection - - # Reconstruct the projection values from the matrix - # TODO: Take scale into account - width = 2.0 / mat[0] - height = 2.0 / mat[5] - a = width * mat[12] - b = height * mat[13] - left = -(width + a) / 2 - right = left + width - bottom = -(height + b) / 2 - top = bottom + height - - return left, right, bottom, top - - @projection_2d.setter - def projection_2d(self, value: Tuple[float, float, float, float]): - if not isinstance(value, tuple) or len(value) != 4: - raise ValueError( - f"projection must be a 4-component tuple, not {type(value)}: {value}" - ) - - # Don't try to set zero projection leading to division by zero - width, height = self.window.get_size() - if width == 0 or height == 0: - return - - self.window.projection = Mat4.orthogonal_projection( - value[0], value[1], value[2], value[3], -100, 100, - ) - - @property - def projection_2d_matrix(self) -> Mat4: + def projection_matrix(self) -> Mat4: """ Get the current projection matrix. - This 4x4 float32 matrix is calculated when setting :py:attr:`~arcade.ArcadeContext.projection_2d`. + This 4x4 float32 matrix is calculated by cameras. This property simply gets and sets pyglet's projection matrix. @@ -298,15 +266,15 @@ def projection_2d_matrix(self) -> Mat4: """ return self.window.projection - @projection_2d_matrix.setter - def projection_2d_matrix(self, value: Mat4): + @projection_matrix.setter + def projection_matrix(self, value: Mat4): if not isinstance(value, Mat4): raise ValueError("projection_matrix must be a Mat4 object") self.window.projection = value @property - def view_matrix_2d(self) -> Mat4: + def view_matrix(self) -> Mat4: """ Get the current view matrix. This 4x4 float32 matrix is calculated when setting :py:attr:`~arcade.ArcadeContext.view_matrix_2d`. @@ -317,13 +285,12 @@ def view_matrix_2d(self) -> Mat4: """ return self.window.view - @view_matrix_2d.setter - def view_matrix_2d(self, value: Mat4): + @view_matrix.setter + def view_matrix(self, value: Mat4): if not isinstance(value, Mat4): raise ValueError("view_matrix must be a Mat4 object") - self._view_matrix_2d = value - self.window.view = self._view_matrix_2d + self.window.view = value @contextmanager def pyglet_rendering(self): diff --git a/arcade/examples/astar_pathfinding.py b/arcade/examples/astar_pathfinding.py index 9d799ab79..cd0e05222 100644 --- a/arcade/examples/astar_pathfinding.py +++ b/arcade/examples/astar_pathfinding.py @@ -8,6 +8,7 @@ """ import arcade +from arcade import camera import random SPRITE_IMAGE_SIZE = 128 @@ -58,13 +59,12 @@ def __init__(self, width, height, title): # List of points we checked to see if there is a barrier there self.barrier_list = None - # Used in scrolling - self.view_bottom = 0 - self.view_left = 0 - # Set the window background color self.background_color = arcade.color.AMAZON + # Camera + self.cam = None + def setup(self): """ Set up the game and initialize the variables. """ @@ -140,10 +140,14 @@ def setup(self): playing_field_bottom_boundary, playing_field_top_boundary) + self.cam = camera.Camera2D() + def on_draw(self): """ Render the screen. """ + self.cam.use() + # This command has to happen before we start drawing self.clear() @@ -186,47 +190,30 @@ def on_update(self, delta_time): # --- Manage Scrolling --- - # Keep track of if we changed the boundary. We don't want to call the - # set_viewport command if we didn't change the view port. - changed = False - # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.cam.left + VIEWPORT_MARGIN if self.player.left < left_boundary: - self.view_left -= left_boundary - self.player.left - changed = True + self.cam.left -= left_boundary - self.player.left # Scroll right - right_boundary = self.view_left + SCREEN_WIDTH - VIEWPORT_MARGIN + right_boundary = self.cam.right - VIEWPORT_MARGIN if self.player.right > right_boundary: - self.view_left += self.player.right - right_boundary - changed = True + self.cam.right += self.player.right - right_boundary # Scroll up - top_boundary = self.view_bottom + SCREEN_HEIGHT - VIEWPORT_MARGIN + top_boundary = self.cam.top - VIEWPORT_MARGIN if self.player.top > top_boundary: - self.view_bottom += self.player.top - top_boundary - changed = True + self.cam.top += self.player.top - top_boundary # Scroll down - bottom_boundary = self.view_bottom + VIEWPORT_MARGIN + bottom_boundary = self.cam.bottom + VIEWPORT_MARGIN if self.player.bottom < bottom_boundary: - self.view_bottom -= bottom_boundary - self.player.bottom - changed = True + self.cam.bottom -= bottom_boundary - self.player.bottom # Make sure our boundaries are integer values. While the view port does # support floating point numbers, for this application we want every pixel # in the view port to map directly onto a pixel on the screen. We don't want # any rounding errors. - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) - - # If we changed the boundary values, update the view port to match - if changed: - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) def on_key_press(self, key, modifiers): """Called whenever a key is pressed. """ diff --git a/arcade/examples/background_blending.py b/arcade/examples/background_blending.py index dfb4c1c14..30e7117eb 100644 --- a/arcade/examples/background_blending.py +++ b/arcade/examples/background_blending.py @@ -23,7 +23,7 @@ class MyGame(arcade.Window): def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=True) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.Camera2D() # Load the first background from file. Sized to match the screen self.background_1 = background.Background.from_file( @@ -51,21 +51,21 @@ def __init__(self): def pan_camera_to_player(self): # This will center the camera on the player. - target_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - target_y = self.player_sprite.center_y - (self.camera.viewport_height / 2) + target_x = self.player_sprite.center_x + target_y = self.player_sprite.center_y # This limits where the player can see. Ensuring they never go too far from the transition. - if -self.camera.viewport_width / 2 > target_x: - target_x = -self.camera.viewport_width / 2 - elif target_x > self.background_1.size[0] * 2 - self.camera.viewport_width / 2: - target_x = self.background_1.size[0] * 2 - self.camera.viewport_width / 2 + if 0.0 > target_x: + target_x = 0.0 + elif target_x > self.background_1.size[0] * 2: + target_x = self.background_1.size[0] * 2 - if -self.camera.viewport_height / 2 > target_y: - target_y = -self.camera.viewport_height / 2 - elif target_y > self.background_1.size[1] - self.camera.viewport_height / 2: - target_y = self.background_1.size[1] - self.camera.viewport_height / 2 + if 0.0 > target_y: + target_y = 0.0 + elif target_y > self.background_1.size[1]: + target_y = self.background_1.size[1] - self.camera.move_to((target_x, target_y), 0.1) + self.camera.position = arcade.math.lerp_2d(self.camera.position, (target_x, target_y), 0.1) def on_update(self, delta_time: float): new_position = ( @@ -87,8 +87,8 @@ def on_draw(self): self.camera.use() # Ensure the background aligns with the camera - self.background_1.pos = self.camera.position - self.background_2.pos = self.camera.position + self.background_1.pos = self.camera.left, self.camera.bottom + self.background_2.pos = self.camera.left, self.camera.bottom # Offset the background texture. self.background_1.texture.offset = self.camera.position @@ -120,7 +120,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.resize(width, height) + self.camera.match_screen(and_projection=True) # This is to ensure the background covers the entire screen. self.background_1.size = (width, height) diff --git a/arcade/examples/background_groups.py b/arcade/examples/background_groups.py index fbf488a0d..e0353e2af 100644 --- a/arcade/examples/background_groups.py +++ b/arcade/examples/background_groups.py @@ -28,7 +28,7 @@ def __init__(self): # Set the background color to equal to that of the first background. self.background_color = (5, 44, 70) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.Camera2D() # create a background group which will hold all the backgrounds. self.backgrounds = background.BackgroundGroup() @@ -66,21 +66,21 @@ def __init__(self): def pan_camera_to_player(self): # This will center the camera on the player. - target_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - target_y = self.player_sprite.center_y - (self.camera.viewport_height / 2) + target_x = self.player_sprite.center_x + target_y = self.player_sprite.center_y # This ensures the background is almost always at least partially visible. - if -self.camera.viewport_width / 2 > target_x: - target_x = -self.camera.viewport_width / 2 - elif target_x > 1.5 * self.camera.viewport_width: - target_x = 1.5 * self.camera.viewport_width + if 0.0 > target_x: + target_x = 0.0 + elif target_x > 2.0 * self.camera.viewport_width: + target_x = 2.0 * self.camera.viewport_width - if -self.camera.viewport_height / 2 > target_y: - target_y = -self.camera.viewport_height / 2 - elif target_y > 1.5 * self.camera.viewport_height: - target_y = 1.5 * self.camera.viewport_height + if 0.0 > target_y: + target_y = 0.0 + elif target_y > 2.0 * self.camera.viewport_height: + target_y = 2.0 * self.camera.viewport_height - self.camera.move_to((target_x, target_y), 0.1) + self.camera.position = arcade.math.lerp_2d(self.camera.position, (target_x, target_y), 0.5) def on_update(self, delta_time: float): new_position = ( @@ -121,7 +121,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.resize(width, height) + self.camera.match_screen(and_projection=True) def main(): diff --git a/arcade/examples/background_parallax.py b/arcade/examples/background_parallax.py index 1ba412699..f542dff79 100644 --- a/arcade/examples/background_parallax.py +++ b/arcade/examples/background_parallax.py @@ -38,7 +38,7 @@ def __init__(self): # Set the background color to match the sky in the background images self.background_color = (162, 84, 162, 255) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.Camera2D() # Create a background group to hold all the landscape's layers self.backgrounds = background.ParallaxGroup() @@ -80,7 +80,7 @@ def __init__(self): # Create & position the player sprite in the center of the camera's view self.player_sprite = arcade.Sprite( ":resources:/images/miami_synth_parallax/car/car-idle.png", - center_x=self.camera.viewport_width // 2, scale=PIXEL_SCALE + center_x=self.camera.viewport_width // 2, center_y=-200.0, scale=PIXEL_SCALE ) self.player_sprite.bottom = 0 @@ -89,8 +89,8 @@ def __init__(self): def pan_camera_to_player(self): # Move the camera toward the center of the player's sprite - target_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - self.camera.move_to((target_x, 0.0), 0.1) + target_x = self.player_sprite.center_x + self.camera.position = arcade.math.lerp_2d(self.camera.position, (target_x, self.height//2), 0.1) def on_update(self, delta_time: float): # Move the player in our infinite world @@ -109,8 +109,8 @@ def on_draw(self): # Fake an endless world with scrolling terrain # Try experimenting with commenting out 1 or both of the 2 lines # below to get an intuitive understanding of what each does! - bg.offset = self.camera.position # Fake depth by moving layers - bg.pos = self.camera.position # Follow the car to fake infinity + bg.offset = self.camera.left, self.camera.bottom # Fake depth by moving layers + bg.pos = self.camera.left, self.camera.bottom # Follow the car to fake infinity # Draw the background & the player's car bg.draw() @@ -147,7 +147,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.resize(width, height) + self.camera.match_screen(and_projection=True) full_width_size = (width, SCALED_BG_LAYER_HEIGHT_PX) # We can iterate through a background group, diff --git a/arcade/examples/background_scrolling.py b/arcade/examples/background_scrolling.py index 3f52de4ea..abac9adff 100644 --- a/arcade/examples/background_scrolling.py +++ b/arcade/examples/background_scrolling.py @@ -23,7 +23,7 @@ class MyGame(arcade.Window): def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=True) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.Camera2D() # Load the background from file. Sized to match the screen self.background = background.Background.from_file( @@ -42,10 +42,9 @@ def __init__(self): def pan_camera_to_player(self): # This will center the camera on the player. - target_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - target_y = self.player_sprite.center_y - (self.camera.viewport_height / 2) - - self.camera.move_to((target_x, target_y), 0.05) + target_x = self.player_sprite.center_x + target_y = self.player_sprite.center_y + self.camera.position = arcade.math.lerp_2d(self.camera.position, (target_x, target_y), 0.5) def on_update(self, delta_time: float): new_position = ( @@ -62,10 +61,10 @@ def on_draw(self): self.camera.use() # Ensure the background aligns with the camera - self.background.pos = self.camera.position + self.background.pos = self.camera.left, self.camera.bottom # Offset the background texture. - self.background.texture.offset = self.camera.position + self.background.texture.offset = self.camera.left, self.camera.bottom self.background.draw() self.player_sprite.draw() @@ -92,7 +91,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.resize(width, height) + self.camera.match_screen(and_projection=True) # This is to ensure the background covers the entire screen. self.background.size = (width, height) diff --git a/arcade/examples/background_stationary.py b/arcade/examples/background_stationary.py index 1519b747a..cc5930000 100644 --- a/arcade/examples/background_stationary.py +++ b/arcade/examples/background_stationary.py @@ -22,7 +22,7 @@ class MyGame(arcade.Window): def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=True) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.Camera2D() # Load the background from file. It defaults to the size of the texture with the bottom left corner at (0, 0). # Image from: @@ -42,21 +42,20 @@ def __init__(self): def pan_camera_to_player(self): # This will center the camera on the player. - target_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - target_y = self.player_sprite.center_y - (self.camera.viewport_height / 2) + target_x = self.player_sprite.center_x + target_y = self.player_sprite.center_y # This ensures the background is always at least partially visible. - if -self.camera.viewport_width / 2 > target_x: - target_x = -self.camera.viewport_width / 2 - elif target_x > self.background.size[0] - self.camera.viewport_width / 2: - target_x = self.background.size[0] - self.camera.viewport_width / 2 + if 0.0 > target_x: + target_x = 0.0 + elif target_x > self.background.size[0]: + target_x = self.background.size[0] - if -self.camera.viewport_height / 2 > target_y: - target_y = -self.camera.viewport_height / 2 - elif target_y > self.background.size[1] - self.camera.viewport_height / 2: - target_y = self.background.size[1] - self.camera.viewport_height / 2 - - self.camera.move_to((target_x, target_y), 0.1) + if 0.0 > target_y: + target_y = 0.0 + elif target_y > self.background.size[1]: + target_y = self.background.size[1] + self.camera.position = arcade.math.lerp_2d(self.camera.position, (target_x, target_y), 0.1) def on_update(self, delta_time: float): new_position = ( @@ -97,7 +96,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.resize(width, height) + self.camera.match_screen(and_projection=True) def main(): diff --git a/arcade/examples/camera_platform.py b/arcade/examples/camera_platform.py index 34116ce5b..a83b42d1c 100644 --- a/arcade/examples/camera_platform.py +++ b/arcade/examples/camera_platform.py @@ -68,9 +68,11 @@ def __init__(self): self.fps_message = None # Cameras - self.camera = None + self.camera: arcade.camera.Camera2D = None self.gui_camera = None + self.camera_shake = None + self.shake_offset_1 = 0 self.shake_offset_2 = 0 self.shake_vel_1 = 0 @@ -130,8 +132,14 @@ def setup(self): self.scene.add_sprite("Player", self.player_sprite) viewport = (0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) - self.camera = arcade.Camera(viewport=viewport) - self.gui_camera = arcade.Camera(viewport=viewport) + self.camera = arcade.camera.Camera2D.from_raw_data(viewport=viewport) + self.gui_camera = arcade.camera.Camera2D.from_raw_data(viewport=viewport) + + self.camera_shake = arcade.camera.grips.ScreenShake2D(self.camera.view_data, + max_amplitude=12.5, + acceleration_duration=0.05, + falloff_time=0.20, + shake_frequency=15.0) # Center camera on user self.pan_camera_to_user() @@ -156,18 +164,23 @@ def setup(self): def on_resize(self, width, height): """Resize window""" - self.camera.resize(width, height) - self.gui_camera.resize(width, height) + self.camera.projection = self.gui_camera.projection = (-width/2, width/2, -height/2, height/2) + self.camera.viewport = self.gui_camera.viewport = (0, 0, width, height) def on_draw(self): """Render the screen.""" self.clear() self.camera.use() + self.camera_shake.update_camera() # Draw our Scene self.scene.draw() + # Readjust the camera so the screen shake doesn't affect + # the camera following algorithm. + self.camera_shake.readjust_camera() + self.gui_camera.use() # Update fps text periodically @@ -185,9 +198,8 @@ def on_draw(self): # Draw game over if self.game_over: - x = 200 + self.camera.position[0] - y = 200 + self.camera.position[1] - arcade.draw_text("Game Over", x, y, arcade.color.BLACK, 30) + arcade.draw_text("Game Over", self.width/2, self.height/2, arcade.color.BLACK, + 30) self.frame_count += 1 @@ -219,17 +231,14 @@ def pan_camera_to_user(self, panning_fraction: float = 1.0): """ # This spot would center on the user - screen_center_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - screen_center_y = self.player_sprite.center_y - ( - self.camera.viewport_height / 2 - ) - if screen_center_x < 0: - screen_center_x = 0 - if screen_center_y < 0: - screen_center_y = 0 + screen_center_x, screen_center_y = self.player_sprite.position + if screen_center_x < self.camera.viewport_width/2: + screen_center_x = self.camera.viewport_width/2 + if screen_center_y < self.camera.viewport_height/2: + screen_center_y = self.camera.viewport_height/2 user_centered = screen_center_x, screen_center_y - self.camera.move_to(user_centered, panning_fraction) + self.camera.position = arcade.math.lerp_2d(self.camera.position, user_centered, panning_fraction) def on_update(self, delta_time): """Movement and game logic""" @@ -240,6 +249,7 @@ def on_update(self, delta_time): # Call update on all sprites if not self.game_over: self.physics_engine.update() + self.camera_shake.update(delta_time) coins_hit = arcade.check_for_collision_with_list( self.player_sprite, self.scene.get_sprite_list("Coins") @@ -254,8 +264,7 @@ def on_update(self, delta_time): ) for bomb in bombs_hit: bomb.remove_from_sprite_lists() - print("Pow") - self.camera.shake((4, 7)) + self.camera_shake.start() # Pan to the user self.pan_camera_to_user(panning_fraction=0.12) diff --git a/arcade/examples/full_screen_example.py b/arcade/examples/full_screen_example.py index 94b674059..f1cf28478 100644 --- a/arcade/examples/full_screen_example.py +++ b/arcade/examples/full_screen_example.py @@ -38,11 +38,13 @@ def __init__(self): # This will get the size of the window, and set the viewport to match. # So if the window is 1000x1000, then so will our viewport. If # you want something different, then use those coordinates instead. - width, height = self.get_size() - self.set_viewport(0, width, 0, height) self.background_color = arcade.color.AMAZON self.example_image = arcade.load_texture(":resources:images/tiles/boxCrate_double.png") + # The camera used to update the viewport and projection on screen resize. + # The position needs to be set to the bottom left corner. + self.cam = arcade.camera.Camera2D.from_raw_data(position=(0.0, 0.0)) + def on_draw(self): """ Render the screen. @@ -51,7 +53,7 @@ def on_draw(self): self.clear() # Get viewport dimensions - left, screen_width, bottom, screen_height = self.get_viewport() + screen_width, screen_height = int(self.cam.projection_width), int(self.cam.projection_height) text_size = 18 # Draw text on the screen so the user has an idea of what is happening @@ -77,8 +79,9 @@ def on_key_press(self, key, modifiers): # Get the window coordinates. Match viewport to window coordinates # so there is a one-to-one mapping. - width, height = self.get_size() - self.set_viewport(0, width, 0, height) + self.cam.projection = 0, self.width, 0, self.height + self.cam.viewport = 0, 0, self.width, self.height + self.cam.use() if key == arcade.key.S: # User hits s. Flip between full and not full screen. @@ -87,7 +90,9 @@ def on_key_press(self, key, modifiers): # Instead of a one-to-one mapping, stretch/squash window to match the # constants. This does NOT respect aspect ratio. You'd need to # do a bit of math for that. - self.set_viewport(0, SCREEN_WIDTH, 0, SCREEN_HEIGHT) + self.cam.projection = 0, SCREEN_WIDTH, 0, SCREEN_HEIGHT + self.cam.viewport = 0, 0, self.width, self.height + self.cam.use() def main(): diff --git a/arcade/examples/gl/custom_sprite.py b/arcade/examples/gl/custom_sprite.py index 161802bc5..6b7501b8c 100644 --- a/arcade/examples/gl/custom_sprite.py +++ b/arcade/examples/gl/custom_sprite.py @@ -25,6 +25,7 @@ from random import randint from array import array import arcade +from arcade.camera import Camera2D from arcade.gl.types import BufferDescription @@ -32,6 +33,7 @@ class GeoSprites(arcade.Window): def __init__(self): super().__init__(800, 600, "Custom Sprites", resizable=True) + self.cam = Camera2D() self.program = self.ctx.program( vertex_shader=""" #version 330 @@ -147,6 +149,7 @@ def __init__(self): def on_draw(self): self.clear() + self.cam.use() # Bind our sprite texture to channel 0 self.texture.use(unit=0) # Render the sprite data with our shader @@ -154,13 +157,7 @@ def on_draw(self): def on_mouse_drag(self, x: float, y: float, dx: float, dy: float, buttons: int, modifiers: int): """Make it easier to explore the geometry by scrolling""" - proj = self.ctx.projection_2d - self.ctx.projection_2d = ( - proj[0] - dx, - proj[1] - dx, - proj[2] - dy, - proj[3] - dy, - ) + self.cam.pos = self.cam.pos[0] - dx, self.cam.pos[1] - dy def gen_sprites(self, count: int): """Quickly generate some random sprite data""" diff --git a/arcade/examples/gui_flat_button.py b/arcade/examples/gui_flat_button.py index 133fe5af3..c8c0ef8cc 100644 --- a/arcade/examples/gui_flat_button.py +++ b/arcade/examples/gui_flat_button.py @@ -22,7 +22,6 @@ def on_click(self, event: arcade.gui.UIOnClickEvent): class MyView(arcade.View): def __init__(self): super().__init__() - # --- Required for all code that uses UI element, # a UIManager to handle the UI. self.ui = arcade.gui.UIManager() diff --git a/arcade/examples/light_demo.py b/arcade/examples/light_demo.py index 3692cae29..cedd091a1 100644 --- a/arcade/examples/light_demo.py +++ b/arcade/examples/light_demo.py @@ -36,9 +36,8 @@ def __init__(self, width, height, title): # Physics engine self.physics_engine = None - # Used for scrolling - self.view_left = 0 - self.view_bottom = 0 + # Camera + self.cam: arcade.camera.Camera2D = None # --- Light related --- # List of all the lights @@ -49,6 +48,9 @@ def __init__(self, width, height, title): def setup(self): """ Create everything """ + # Create camera + self.cam = arcade.camera.Camera2D() + # Create sprite lists self.background_sprite_list = arcade.SpriteList() self.player_list = arcade.SpriteList() @@ -187,11 +189,6 @@ def setup(self): # Create the physics engine self.physics_engine = arcade.PhysicsEngineSimple(self.player_sprite, self.wall_list) - # Set the viewport boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 - def on_draw(self): """ Draw everything. """ self.clear() @@ -211,12 +208,14 @@ def on_draw(self): # Now draw anything that should NOT be affected by lighting. arcade.draw_text("Press SPACE to turn character light on/off.", - 10 + self.view_left, 10 + self.view_bottom, + 10 + int(self.cam.left), 10 + int(self.cam.bottom), arcade.color.WHITE, 20) def on_resize(self, width, height): """ User resizes the screen. """ + self.cam.viewport = 0, 0, width, height + # --- Light related --- # We need to resize the light layer to self.light_layer.resize(width, height) @@ -252,40 +251,38 @@ def on_key_release(self, key, _): elif key == arcade.key.LEFT or key == arcade.key.RIGHT: self.player_sprite.change_x = 0 + def scroll_screen(self): """ Manage Scrolling """ # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.cam.left + VIEWPORT_MARGIN if self.player_sprite.left < left_boundary: - self.view_left -= left_boundary - self.player_sprite.left + self.cam.left -= left_boundary - self.player_sprite.left # Scroll right - right_boundary = self.view_left + self.width - VIEWPORT_MARGIN + right_boundary = self.cam.right - VIEWPORT_MARGIN if self.player_sprite.right > right_boundary: - self.view_left += self.player_sprite.right - right_boundary + self.cam.right += self.player_sprite.right - right_boundary # Scroll up - top_boundary = self.view_bottom + self.height - VIEWPORT_MARGIN + top_boundary = self.cam.top - VIEWPORT_MARGIN if self.player_sprite.top > top_boundary: - self.view_bottom += self.player_sprite.top - top_boundary + self.cam.top += self.player_sprite.top - top_boundary # Scroll down - bottom_boundary = self.view_bottom + VIEWPORT_MARGIN + bottom_boundary = self.cam.bottom + VIEWPORT_MARGIN if self.player_sprite.bottom < bottom_boundary: - self.view_bottom -= bottom_boundary - self.player_sprite.bottom + self.cam.bottom -= bottom_boundary - self.player_sprite.bottom # Make sure our boundaries are integer values. While the viewport does # support floating point numbers, for this application we want every pixel # in the view port to map directly onto a pixel on the screen. We don't want # any rounding errors. - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) + self.cam.left = int(self.cam.left) + self.cam.bottom = int(self.cam.bottom) - arcade.set_viewport(self.view_left, - self.width + self.view_left, - self.view_bottom, - self.height + self.view_bottom) + self.cam.use() def on_update(self, delta_time): """ Movement and game logic """ diff --git a/arcade/examples/line_of_sight.py b/arcade/examples/line_of_sight.py index 249ba7e5e..9f777ddaa 100644 --- a/arcade/examples/line_of_sight.py +++ b/arcade/examples/line_of_sight.py @@ -50,9 +50,8 @@ def __init__(self, width, height, title): self.physics_engine = None - # Used in scrolling - self.view_bottom = 0 - self.view_left = 0 + # Camera for scrolling + self.cam = None # Set the background color self.background_color = arcade.color.AMAZON @@ -60,6 +59,9 @@ def __init__(self, width, height, title): def setup(self): """ Set up the game and initialize the variables. """ + # Camera + self.cam = arcade.camera.Camera2D() + # Sprite lists self.player_list = arcade.SpriteList() self.wall_list = arcade.SpriteList(use_spatial_hash=True) @@ -144,47 +146,44 @@ def on_update(self, delta_time): # --- Manage Scrolling --- - # Keep track of if we changed the boundary. We don't want to call the - # set_viewport command if we didn't change the view port. + # Keep track of if we changed the boundary. We don't want to + # update the camera if we don't need to. changed = False # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.cam.left + VIEWPORT_MARGIN if self.player.left < left_boundary: - self.view_left -= left_boundary - self.player.left + self.cam.left -= left_boundary - self.player.left changed = True # Scroll right - right_boundary = self.view_left + SCREEN_WIDTH - VIEWPORT_MARGIN + right_boundary = self.cam.right - VIEWPORT_MARGIN if self.player.right > right_boundary: - self.view_left += self.player.right - right_boundary + self.cam.right += self.player.right - right_boundary changed = True # Scroll up - top_boundary = self.view_bottom + SCREEN_HEIGHT - VIEWPORT_MARGIN + top_boundary = self.cam.top - VIEWPORT_MARGIN if self.player.top > top_boundary: - self.view_bottom += self.player.top - top_boundary + self.cam.top += self.player.top - top_boundary changed = True # Scroll down - bottom_boundary = self.view_bottom + VIEWPORT_MARGIN + bottom_boundary = self.cam.bottom + VIEWPORT_MARGIN if self.player.bottom < bottom_boundary: - self.view_bottom -= bottom_boundary - self.player.bottom + self.cam.bottom -= bottom_boundary - self.player.bottom changed = True - # Make sure our boundaries are integer values. While the view port does - # support floating point numbers, for this application we want every pixel - # in the view port to map directly onto a pixel on the screen. We don't want - # any rounding errors. - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) - # If we changed the boundary values, update the view port to match if changed: - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) + # Make sure our boundaries are integer values. While the view port does + # support floating point numbers, for this application we want every pixel + # in the view port to map directly onto a pixel on the screen. We don't want + # any rounding errors. + self.cam.left = int(self.cam.left) + self.cam.bottom = int(self.cam.bottom) + + self.cam.use() def on_key_press(self, key, modifiers): """Called whenever a key is pressed. """ diff --git a/arcade/examples/maze_depth_first.py b/arcade/examples/maze_depth_first.py index 0d09feaf1..8dd0f9b21 100644 --- a/arcade/examples/maze_depth_first.py +++ b/arcade/examples/maze_depth_first.py @@ -101,9 +101,8 @@ def __init__(self, width, height, title): # Physics engine self.physics_engine = None - # Used to scroll - self.view_bottom = 0 - self.view_left = 0 + # Camera for scrolling + self.cam = None # Time to process self.processing_time = 0 @@ -181,10 +180,8 @@ def setup(self): # Set the background color self.background_color = arcade.color.AMAZON - # Set the viewport boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 + # Setup Camera + self.cam = arcade.camera.Camera2D() def on_draw(self): """ @@ -206,20 +203,20 @@ def on_draw(self): output = f"Sprite Count: {sprite_count}" arcade.draw_text(output, - self.view_left + 20, - SCREEN_HEIGHT - 20 + self.view_bottom, + self.cam.left + 20, + SCREEN_HEIGHT - 20 + self.cam.bottom, arcade.color.WHITE, 16) output = f"Drawing time: {self.draw_time:.3f}" arcade.draw_text(output, - self.view_left + 20, - SCREEN_HEIGHT - 40 + self.view_bottom, + self.cam.left + 20, + SCREEN_HEIGHT - 40 + self.cam.bottom, arcade.color.WHITE, 16) output = f"Processing time: {self.processing_time:.3f}" arcade.draw_text(output, - self.view_left + 20, - SCREEN_HEIGHT - 60 + self.view_bottom, + self.cam.left + 20, + SCREEN_HEIGHT - 60 + self.cam.bottom, arcade.color.WHITE, 16) self.draw_time = timeit.default_timer() - draw_start_time @@ -255,39 +252,44 @@ def on_update(self, delta_time): # --- Manage Scrolling --- - # Track if we need to change the viewport - + # Keep track of if we changed the boundary. We don't want to + # update the camera if we don't need to. changed = False # Scroll left - left_bndry = self.view_left + VIEWPORT_MARGIN - if self.player_sprite.left < left_bndry: - self.view_left -= left_bndry - self.player_sprite.left + left_boundary = self.cam.left + VIEWPORT_MARGIN + if self.player_sprite.left < left_boundary: + self.cam.left -= left_boundary - self.player_sprite.left changed = True # Scroll right - right_bndry = self.view_left + SCREEN_WIDTH - VIEWPORT_MARGIN - if self.player_sprite.right > right_bndry: - self.view_left += self.player_sprite.right - right_bndry + right_boundary = self.cam.right - VIEWPORT_MARGIN + if self.player_sprite.right > right_boundary: + self.cam.right += self.player_sprite.right - right_boundary changed = True # Scroll up - top_bndry = self.view_bottom + SCREEN_HEIGHT - VIEWPORT_MARGIN - if self.player_sprite.top > top_bndry: - self.view_bottom += self.player_sprite.top - top_bndry + top_boundary = self.cam.top - VIEWPORT_MARGIN + if self.player_sprite.top > top_boundary: + self.cam.top += self.player_sprite.top - top_boundary changed = True # Scroll down - bottom_bndry = self.view_bottom + VIEWPORT_MARGIN - if self.player_sprite.bottom < bottom_bndry: - self.view_bottom -= bottom_bndry - self.player_sprite.bottom + bottom_boundary = self.cam.bottom + VIEWPORT_MARGIN + if self.player_sprite.bottom < bottom_boundary: + self.cam.bottom -= bottom_boundary - self.player_sprite.bottom changed = True + # If we changed the boundary values, update the view port to match if changed: - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) + # Make sure our boundaries are integer values. While the view port does + # support floating point numbers, for this application we want every pixel + # in the view port to map directly onto a pixel on the screen. We don't want + # any rounding errors. + self.cam.left = int(self.cam.left) + self.cam.bottom = int(self.cam.bottom) + + self.cam.use() # Save the time it took to do this. self.processing_time = timeit.default_timer() - start_time diff --git a/arcade/examples/maze_recursive.py b/arcade/examples/maze_recursive.py index 610d302a1..f2e735432 100644 --- a/arcade/examples/maze_recursive.py +++ b/arcade/examples/maze_recursive.py @@ -155,9 +155,8 @@ def __init__(self, width, height, title): # Physics engine self.physics_engine = None - # Used to scroll - self.view_bottom = 0 - self.view_left = 0 + # camera for scrolling + self.cam = None # Time to process self.processing_time = 0 @@ -236,10 +235,8 @@ def setup(self): # Set the background color self.background_color = arcade.color.AMAZON - # Set the viewport boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 + # setup camera + self.cam = arcade.camera.Camera2D() def on_draw(self): """ Render the screen. """ @@ -259,20 +256,20 @@ def on_draw(self): output = f"Sprite Count: {sprite_count}" arcade.draw_text(output, - self.view_left + 20, - SCREEN_HEIGHT - 20 + self.view_bottom, + self.cam.left + 20, + SCREEN_HEIGHT - 20 + self.cam.bottom, arcade.color.WHITE, 16) output = f"Drawing time: {self.draw_time:.3f}" arcade.draw_text(output, - self.view_left + 20, - SCREEN_HEIGHT - 40 + self.view_bottom, + self.cam.left + 20, + SCREEN_HEIGHT - 40 + self.cam.bottom, arcade.color.WHITE, 16) output = f"Processing time: {self.processing_time:.3f}" arcade.draw_text(output, - self.view_left + 20, - SCREEN_HEIGHT - 60 + self.view_bottom, + self.cam.left + 20, + SCREEN_HEIGHT - 60 + self.cam.bottom, arcade.color.WHITE, 16) self.draw_time = timeit.default_timer() - draw_start_time @@ -313,34 +310,31 @@ def on_update(self, delta_time): changed = False # Scroll left - left_bndry = self.view_left + VIEWPORT_MARGIN + left_bndry = self.cam.left + VIEWPORT_MARGIN if self.player_sprite.left < left_bndry: - self.view_left -= left_bndry - self.player_sprite.left + self.cam.left -= left_bndry - self.player_sprite.left changed = True # Scroll right - right_bndry = self.view_left + SCREEN_WIDTH - VIEWPORT_MARGIN + right_bndry = self.cam.right - VIEWPORT_MARGIN if self.player_sprite.right > right_bndry: - self.view_left += self.player_sprite.right - right_bndry + self.cam.left += self.player_sprite.right - right_bndry changed = True # Scroll up - top_bndry = self.view_bottom + SCREEN_HEIGHT - VIEWPORT_MARGIN + top_bndry = self.cam.top - VIEWPORT_MARGIN if self.player_sprite.top > top_bndry: - self.view_bottom += self.player_sprite.top - top_bndry + self.cam.bottom += self.player_sprite.top - top_bndry changed = True # Scroll down - bottom_bndry = self.view_bottom + VIEWPORT_MARGIN + bottom_bndry = self.cam.bottom + VIEWPORT_MARGIN if self.player_sprite.bottom < bottom_bndry: - self.view_bottom -= bottom_bndry - self.player_sprite.bottom + self.cam.bottom -= bottom_bndry - self.player_sprite.bottom changed = True if changed: - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) + self.cam.use() # Save the time it took to do this. self.processing_time = timeit.default_timer() - start_time diff --git a/arcade/examples/minimap.py b/arcade/examples/minimap.py index a0e7f3990..baf2628b3 100644 --- a/arcade/examples/minimap.py +++ b/arcade/examples/minimap.py @@ -11,7 +11,6 @@ from uuid import uuid4 import arcade -from pyglet.math import Vec2 SPRITE_SCALING = 0.5 @@ -63,8 +62,8 @@ def __init__(self, width, height, title): # Camera for sprites, and one for our GUI viewport = (0, 0, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT) - self.camera_sprites = arcade.SimpleCamera(viewport=viewport) - self.camera_gui = arcade.SimpleCamera(viewport=viewport) + self.camera_sprites = arcade.camera.Camera2D.from_raw_data(viewport=viewport) + self.camera_gui = arcade.camera.Camera2D.from_raw_data(viewport=viewport) def setup(self): """ Set up the game and initialize the variables. """ @@ -180,17 +179,17 @@ def scroll_to_player(self): """ # Scroll to the proper location - position = Vec2(self.player_sprite.center_x - self.width / 2, - self.player_sprite.center_y - self.height / 2) - self.camera_sprites.move_to(position, CAMERA_SPEED) + position = (self.player_sprite.center_x, self.player_sprite.center_y) + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, position, CAMERA_SPEED) def on_resize(self, width: int, height: int): """ Resize window Handle the user grabbing the edge and resizing the window. """ - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) def main(): diff --git a/arcade/examples/minimap_camera.py b/arcade/examples/minimap_camera.py index da484a1fa..806034f24 100644 --- a/arcade/examples/minimap_camera.py +++ b/arcade/examples/minimap_camera.py @@ -55,8 +55,11 @@ def __init__(self, width, height, title): minimap_viewport = (DEFAULT_SCREEN_WIDTH - MINIMAP_WIDTH, DEFAULT_SCREEN_HEIGHT - MINIMAP_HEIGHT, MINIMAP_WIDTH, MINIMAP_HEIGHT) - minimap_projection = (0, MAP_PROJECTION_WIDTH, 0, MAP_PROJECTION_HEIGHT) - self.camera_minimap = arcade.Camera(viewport=minimap_viewport, projection=minimap_projection) + minimap_projection = (-MAP_PROJECTION_WIDTH/2, MAP_PROJECTION_WIDTH/2, + -MAP_PROJECTION_HEIGHT/2, MAP_PROJECTION_HEIGHT/2) + self.camera_minimap = arcade.camera.Camera2D.from_raw_data( + viewport=minimap_viewport, projection=minimap_projection + ) # Set up the player self.player_sprite = None @@ -65,9 +68,8 @@ def __init__(self, width, height, title): # Camera for sprites, and one for our GUI viewport = (0, 0, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT) - projection = (0, DEFAULT_SCREEN_WIDTH, 0, DEFAULT_SCREEN_HEIGHT) - self.camera_sprites = arcade.Camera(viewport=viewport, projection=projection) - self.camera_gui = arcade.SimpleCamera(viewport=viewport) + self.camera_sprites = arcade.camera.Camera2D.from_raw_data(viewport=viewport) + self.camera_gui = arcade.camera.Camera2D.from_raw_data(viewport=viewport) self.selected_camera = self.camera_minimap @@ -178,18 +180,23 @@ def on_update(self, delta_time): self.physics_engine.update() # Center the screen to the player - self.camera_sprites.center(self.player_sprite.position, CAMERA_SPEED) + self.camera_sprites.position = arcade.math.lerp_2d( + self.camera_sprites.position, self.player_sprite.position, CAMERA_SPEED + ) # Center the minimap viewport to the player in the minimap - self.camera_minimap.center(self.player_sprite.position, CAMERA_SPEED) + self.camera_minimap.position = arcade.math.lerp_2d( + self.player_sprite.position, self.player_sprite.position, CAMERA_SPEED + ) def on_resize(self, width: int, height: int): """ Resize window Handle the user grabbing the edge and resizing the window. """ - self.camera_sprites.resize(width, height, resize_projection=False) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen() + self.camera_gui.match_screen() self.camera_minimap.viewport = (width - self.camera_minimap.viewport_width, height - self.camera_minimap.viewport_height, self.camera_minimap.viewport_width, diff --git a/arcade/examples/perspective.py b/arcade/examples/perspective.py index 5f705a7eb..32a04f581 100644 --- a/arcade/examples/perspective.py +++ b/arcade/examples/perspective.py @@ -106,6 +106,12 @@ def __init__(self): ) self.time = 0 + self.offscreen_cam = arcade.camera.Camera2D.from_raw_data( + position=(0.0, 0.0), + viewport=(0, 0, self.fbo.width, self.fbo.height), + projection=(0, self.fbo.width, 0, self.fbo.height) + ) + def on_draw(self): # Every frame we can update the offscreen texture if needed self.draw_offscreen() @@ -134,7 +140,7 @@ def draw_offscreen(self): # Activate the offscreen framebuffer and draw the sprites into it with self.fbo.activate() as fbo: fbo.clear() - arcade.set_viewport(0, self.fbo.width, 0, self.fbo.height) + self.offscreen_cam.use() self.spritelist.draw() def on_resize(self, width: int, height: int): diff --git a/arcade/examples/procedural_caves_bsp.py b/arcade/examples/procedural_caves_bsp.py index 847782430..3677e4743 100644 --- a/arcade/examples/procedural_caves_bsp.py +++ b/arcade/examples/procedural_caves_bsp.py @@ -270,8 +270,7 @@ def __init__(self, width, height, title): self.wall_list = None self.player_list = None self.player_sprite = None - self.view_bottom = 0 - self.view_left = 0 + self.cam = None self.physics_engine = None self.processing_time = 0 @@ -348,6 +347,8 @@ def setup(self): self.physics_engine = arcade.PhysicsEngineSimple(self.player_sprite, self.wall_list) + self.cam = arcade.camera.Camera2D() + def on_draw(self): """ Render the screen. """ @@ -367,20 +368,20 @@ def on_draw(self): output = f"Sprite Count: {sprite_count}" arcade.draw_text(output, - self.view_left + 20, - WINDOW_HEIGHT - 20 + self.view_bottom, + self.cam.left + 20, + WINDOW_HEIGHT - 20 + self.cam.bottom, arcade.color.WHITE, 16) output = f"Drawing time: {self.draw_time:.3f}" arcade.draw_text(output, - self.view_left + 20, - WINDOW_HEIGHT - 40 + self.view_bottom, + self.cam.left + 20, + WINDOW_HEIGHT - 40 + self.cam.bottom, arcade.color.WHITE, 16) output = f"Processing time: {self.processing_time:.3f}" arcade.draw_text(output, - self.view_left + 20, - WINDOW_HEIGHT - 60 + self.view_bottom, + self.cam.left + 20, + WINDOW_HEIGHT - 60 + self.cam.bottom, arcade.color.WHITE, 16) self.draw_time = timeit.default_timer() - draw_start_time @@ -420,34 +421,31 @@ def on_update(self, delta_time): changed = False # Scroll left - left_bndry = self.view_left + VIEWPORT_MARGIN + left_bndry = self.cam.left + VIEWPORT_MARGIN if self.player_sprite.left < left_bndry: - self.view_left -= left_bndry - self.player_sprite.left + self.cam.left -= left_bndry - self.player_sprite.left changed = True # Scroll right - right_bndry = self.view_left + WINDOW_WIDTH - VIEWPORT_MARGIN + right_bndry = self.cam.right - VIEWPORT_MARGIN if self.player_sprite.right > right_bndry: - self.view_left += self.player_sprite.right - right_bndry + self.cam.left += self.player_sprite.right - right_bndry changed = True # Scroll up - top_bndry = self.view_bottom + WINDOW_HEIGHT - VIEWPORT_MARGIN + top_bndry = self.cam.top - VIEWPORT_MARGIN if self.player_sprite.top > top_bndry: - self.view_bottom += self.player_sprite.top - top_bndry + self.cam.bottom += self.player_sprite.top - top_bndry changed = True # Scroll down - bottom_bndry = self.view_bottom + VIEWPORT_MARGIN + bottom_bndry = self.cam.bottom + VIEWPORT_MARGIN if self.player_sprite.bottom < bottom_bndry: - self.view_bottom -= bottom_bndry - self.player_sprite.bottom + self.cam.bottom -= bottom_bndry - self.player_sprite.bottom changed = True if changed: - arcade.set_viewport(self.view_left, - WINDOW_WIDTH + self.view_left, - self.view_bottom, - WINDOW_HEIGHT + self.view_bottom) + self.cam.use() # Save the time it took to do this. self.processing_time = timeit.default_timer() - start_time diff --git a/arcade/examples/procedural_caves_cellular.py b/arcade/examples/procedural_caves_cellular.py index dcdaa4793..3d1012c46 100644 --- a/arcade/examples/procedural_caves_cellular.py +++ b/arcade/examples/procedural_caves_cellular.py @@ -11,7 +11,6 @@ import random import arcade import timeit -from pyglet.math import Vec2 # Sprite scaling. Make this larger, like 0.5 to zoom in and add # 'mystery' to what you can see. Make it smaller, like 0.1 to see @@ -112,7 +111,7 @@ def on_show_view(self): # Reset the viewport, necessary if we have a scrolling game and we need # to reset the viewport back to the start so we can see what we draw. - arcade.set_viewport(0, self.window.width, 0, self.window.height) + self.window.default_camera.use() def on_draw(self): """ Draw this view """ @@ -155,8 +154,8 @@ def __init__(self): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() self.window.background_color = arcade.color.BLACK @@ -311,18 +310,17 @@ def scroll_to_player(self, speed=CAMERA_SPEED): pan. """ - position = Vec2(self.player_sprite.center_x - self.window.width / 2, - self.player_sprite.center_y - self.window.height / 2) - self.camera_sprites.move_to(position, speed) - self.camera_sprites.update() + position = (self.player_sprite.center_x, self.player_sprite.center_y) + arcade.camera.controllers.simple_follow_2D(speed, position, self.camera_sprites.view_data) def on_resize(self, width: int, height: int): """ Resize window Handle the user grabbing the edge and resizing the window. """ - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) def on_update(self, delta_time): """ Movement and game logic """ diff --git a/arcade/examples/sprite_move_scrolling.py b/arcade/examples/sprite_move_scrolling.py index 4f661e6c1..8f9894836 100644 --- a/arcade/examples/sprite_move_scrolling.py +++ b/arcade/examples/sprite_move_scrolling.py @@ -9,7 +9,6 @@ import random import arcade -from pyglet.math import Vec2 SPRITE_SCALING = 0.5 @@ -55,8 +54,8 @@ def __init__(self, width, height, title): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() def setup(self): """ Set up the game and initialize the variables. """ @@ -169,17 +168,17 @@ def scroll_to_player(self): pan. """ - position = Vec2(self.player_sprite.center_x - self.width / 2, - self.player_sprite.center_y - self.height / 2) - self.camera_sprites.move_to(position, CAMERA_SPEED) + position = (self.player_sprite.center_x, self.player_sprite.center_y) + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, position, CAMERA_SPEED) def on_resize(self, width: int, height: int): """ Resize window Handle the user grabbing the edge and resizing the window. """ - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) def main(): diff --git a/arcade/examples/sprite_move_scrolling_box.py b/arcade/examples/sprite_move_scrolling_box.py index 1baf848ad..99d6712ba 100644 --- a/arcade/examples/sprite_move_scrolling_box.py +++ b/arcade/examples/sprite_move_scrolling_box.py @@ -45,18 +45,14 @@ def __init__(self, width, height, title): self.physics_engine = None - # Used in scrolling - self.view_bottom = 0 - self.view_left = 0 - # Track the current state of what key is pressed self.left_pressed = False self.right_pressed = False self.up_pressed = False self.down_pressed = False - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() def setup(self): """ Set up the game and initialize the variables. """ @@ -87,11 +83,6 @@ def setup(self): # Set the background color self.background_color = arcade.color.AMAZON - # Set the viewport boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 - def on_draw(self): """ Render the screen. @@ -185,37 +176,40 @@ def scroll_to_player(self): # --- Manage Scrolling --- + _target_x, _target_y = self.camera_sprites.position + # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.camera_sprites.left + VIEWPORT_MARGIN if self.player_sprite.left < left_boundary: - self.view_left -= left_boundary - self.player_sprite.left + _target_x -= left_boundary - self.player_sprite.left # Scroll right - right_boundary = self.view_left + self.width - VIEWPORT_MARGIN + right_boundary = self.camera_sprites.right - VIEWPORT_MARGIN if self.player_sprite.right > right_boundary: - self.view_left += self.player_sprite.right - right_boundary + _target_x += self.player_sprite.right - right_boundary # Scroll up - top_boundary = self.view_bottom + self.height - VIEWPORT_MARGIN + top_boundary = self.camera_sprites.top - VIEWPORT_MARGIN if self.player_sprite.top > top_boundary: - self.view_bottom += self.player_sprite.top - top_boundary + _target_y += self.player_sprite.top - top_boundary # Scroll down - bottom_boundary = self.view_bottom + VIEWPORT_MARGIN + bottom_boundary = self.camera_sprites.bottom + VIEWPORT_MARGIN if self.player_sprite.bottom < bottom_boundary: - self.view_bottom -= bottom_boundary - self.player_sprite.bottom + _target_y -= bottom_boundary - self.player_sprite.bottom # Scroll to the proper location - position = self.view_left, self.view_bottom - self.camera_sprites.move_to(position, CAMERA_SPEED) + position = _target_x, _target_y + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, position, CAMERA_SPEED) def on_resize(self, width: int, height: int): """ Resize window Handle the user grabbing the edge and resizing the window. """ - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) def main(): diff --git a/arcade/examples/sprite_move_scrolling_shake.py b/arcade/examples/sprite_move_scrolling_shake.py index d1778fd2d..a3ea08a78 100644 --- a/arcade/examples/sprite_move_scrolling_shake.py +++ b/arcade/examples/sprite_move_scrolling_shake.py @@ -8,7 +8,7 @@ """ import random -import math +# import math import arcade SPRITE_SCALING = 0.5 @@ -54,8 +54,14 @@ def __init__(self, width, height, title): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.Camera() - self.camera_gui = arcade.Camera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() + + self.camera_shake = arcade.camera.grips.ScreenShake2D(self.camera_sprites.view_data, + max_amplitude=15.0, + acceleration_duration=0.1, + falloff_time=0.5, + shake_frequency=10.0) self.explosion_sound = arcade.load_sound(":resources:sounds/explosion1.wav") @@ -108,6 +114,7 @@ def on_draw(self): self.clear() # Select the camera we'll use to draw all our sprites + self.camera_shake.update_camera() self.camera_sprites.use() # Draw all the sprites. @@ -115,6 +122,9 @@ def on_draw(self): self.bomb_list.draw() self.player_list.draw() + # Readjust the camera's screen_shake + self.camera_shake.readjust_camera() + def on_key_press(self, key, modifiers): """Called whenever a key is pressed. """ @@ -141,6 +151,7 @@ def on_update(self, delta_time): # Call update on all sprites (The sprites don't do much in this # example though.) self.physics_engine.update() + self.camera_shake.update(delta_time) # Scroll the screen to the player self.scroll_to_player() @@ -151,24 +162,7 @@ def on_update(self, delta_time): bomb.remove_from_sprite_lists() self.explosion_sound.play() - # --- Shake the camera --- - # Pick a random direction - shake_direction = random.random() * 2 * math.pi - # How 'far' to shake - shake_amplitude = 10 - # Calculate a vector based on that - shake_vector = ( - math.cos(shake_direction) * shake_amplitude, - math.sin(shake_direction) * shake_amplitude - ) - # Frequency of the shake - shake_speed = 1.5 - # How fast to damp the shake - shake_damping = 0.9 - # Do the shake - self.camera_sprites.shake(shake_vector, - speed=shake_speed, - damping=shake_damping) + self.camera_shake.start() def scroll_to_player(self): """ @@ -180,18 +174,19 @@ def scroll_to_player(self): """ position = ( - self.player_sprite.center_x - self.width / 2, - self.player_sprite.center_y - self.height / 2 + self.player_sprite.center_x, + self.player_sprite.center_y ) - self.camera_sprites.move_to(position, CAMERA_SPEED) + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, position, CAMERA_SPEED) def on_resize(self, width: int, height: int): """ Resize window Handle the user grabbing the edge and resizing the window. """ - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) def main(): diff --git a/arcade/examples/sprite_moving_platforms.py b/arcade/examples/sprite_moving_platforms.py index 11606f8f6..5232a15ce 100644 --- a/arcade/examples/sprite_moving_platforms.py +++ b/arcade/examples/sprite_moving_platforms.py @@ -7,7 +7,6 @@ python -m arcade.examples.sprite_moving_platforms """ import arcade -from pyglet.math import Vec2 SPRITE_SCALING = 0.5 @@ -55,8 +54,8 @@ def __init__(self, width, height, title): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() self.left_down = False self.right_down = False @@ -205,9 +204,8 @@ def scroll_to_player(self): pan. """ - position = Vec2(self.player_sprite.center_x - self.width / 2, - self.player_sprite.center_y - self.height / 2) - self.camera_sprites.move_to(position, CAMERA_SPEED) + position = (self.player_sprite.center_x, self.player_sprite.center_y) + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, position, CAMERA_SPEED) def main(): diff --git a/arcade/examples/sprite_tiled_map.py b/arcade/examples/sprite_tiled_map.py index 9fd62e8cd..d0dc85e39 100644 --- a/arcade/examples/sprite_tiled_map.py +++ b/arcade/examples/sprite_tiled_map.py @@ -126,8 +126,8 @@ def setup(self): self.player_sprite, walls, gravity_constant=GRAVITY ) - self.camera = arcade.SimpleCamera() - self.gui_camera = arcade.SimpleCamera() + self.camera = arcade.camera.Camera2D() + self.gui_camera = arcade.camera.Camera2D() # Center camera on user self.pan_camera_to_user() @@ -229,17 +229,15 @@ def pan_camera_to_user(self, panning_fraction: float = 1.0): """ Manage Scrolling """ # This spot would center on the user - screen_center_x = self.player_sprite.center_x - (self.camera.viewport_width / 2) - screen_center_y = self.player_sprite.center_y - ( - self.camera.viewport_height / 2 - ) - if screen_center_x < 0: - screen_center_x = 0 - if screen_center_y < 0: - screen_center_y = 0 + screen_center_x = self.player_sprite.center_x + screen_center_y = self.player_sprite.center_y + if screen_center_x - self.width/2 < 0: + screen_center_x = self.width/2 + if screen_center_y - self.height/2 < 0: + screen_center_y = self.height/2 user_centered = screen_center_x, screen_center_y - self.camera.move_to(user_centered, panning_fraction) + self.camera.position = arcade.math.lerp_2d(self.camera.position, user_centered, panning_fraction) def main(): diff --git a/arcade/examples/sprite_tiled_map_with_levels.py b/arcade/examples/sprite_tiled_map_with_levels.py index be6a34d00..58af809a2 100644 --- a/arcade/examples/sprite_tiled_map_with_levels.py +++ b/arcade/examples/sprite_tiled_map_with_levels.py @@ -24,8 +24,8 @@ # and the edge of the screen. VIEWPORT_MARGIN_TOP = 60 VIEWPORT_MARGIN_BOTTOM = 60 -VIEWPORT_RIGHT_MARGIN = 270 -VIEWPORT_LEFT_MARGIN = 270 +VIEWPORT_MARGIN_RIGHT = 270 +VIEWPORT_MARGIN_LEFT = 270 # Physics MOVEMENT_SPEED = 5 @@ -53,8 +53,7 @@ def __init__(self): self.player_sprite = None self.physics_engine = None - self.view_left = 0 - self.view_bottom = 0 + self.cam = None self.end_of_map = 0 self.game_over = False self.last_time = None @@ -109,10 +108,8 @@ def load_level(self, level): if self.tile_map.background_color: self.background_color = self.tile_map.background_color - # Set the view port boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 + # Reset cam + self.cam = arcade.camera.Camera2D() def on_draw(self): """ @@ -135,8 +132,8 @@ def on_draw(self): if self.fps_message: arcade.draw_text( self.fps_message, - self.view_left + 10, - self.view_bottom + 40, + self.cam.left + 10, + self.cam.bottom + 40, arcade.color.BLACK, 14, ) @@ -150,14 +147,14 @@ def on_draw(self): distance = self.player_sprite.right output = f"Distance: {distance:.0f}" arcade.draw_text( - output, self.view_left + 10, self.view_bottom + 20, arcade.color.BLACK, 14 + output, self.cam.left + 10, self.cam.bottom + 20, arcade.color.BLACK, 14 ) if self.game_over: arcade.draw_text( "Game Over", - self.view_left + 200, - self.view_bottom + 200, + self.cam.left + 200, + self.cam.bottom + 200, arcade.color.BLACK, 30, ) @@ -202,44 +199,36 @@ def on_update(self, delta_time): # --- Manage Scrolling --- - # Track if we need to change the view port + # Track if we need to change the viewport changed = False # Scroll left - left_bndry = self.view_left + VIEWPORT_LEFT_MARGIN + left_bndry = self.cam.left + VIEWPORT_MARGIN_LEFT if self.player_sprite.left < left_bndry: - self.view_left -= left_bndry - self.player_sprite.left + self.cam.left -= left_bndry - self.player_sprite.left changed = True # Scroll right - right_bndry = self.view_left + SCREEN_WIDTH - VIEWPORT_RIGHT_MARGIN + right_bndry = self.cam.right - VIEWPORT_MARGIN_RIGHT if self.player_sprite.right > right_bndry: - self.view_left += self.player_sprite.right - right_bndry + self.cam.left += self.player_sprite.right - right_bndry changed = True # Scroll up - top_bndry = self.view_bottom + SCREEN_HEIGHT - VIEWPORT_MARGIN_TOP + top_bndry = self.cam.top - VIEWPORT_MARGIN_TOP if self.player_sprite.top > top_bndry: - self.view_bottom += self.player_sprite.top - top_bndry + self.cam.bottom += self.player_sprite.top - top_bndry changed = True # Scroll down - bottom_bndry = self.view_bottom + VIEWPORT_MARGIN_BOTTOM + bottom_bndry = self.cam.bottom + VIEWPORT_MARGIN_BOTTOM if self.player_sprite.bottom < bottom_bndry: - self.view_bottom -= bottom_bndry - self.player_sprite.bottom + self.cam.bottom -= bottom_bndry - self.player_sprite.bottom changed = True - # If we need to scroll, go ahead and do it. if changed: - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) - arcade.set_viewport( - self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom, - ) + self.cam.use() def main(): diff --git a/arcade/examples/template_platformer.py b/arcade/examples/template_platformer.py index 448be04c9..0b7e73019 100644 --- a/arcade/examples/template_platformer.py +++ b/arcade/examples/template_platformer.py @@ -65,8 +65,8 @@ def setup(self): """Set up the game here. Call this function to restart the game.""" # Setup the Cameras - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() # Name of map file to load map_name = ":resources:tiled_maps/map.json" @@ -170,18 +170,18 @@ def on_key_release(self, key, modifiers): def center_camera_to_player(self): # Find where player is, then calculate lower left corner from that - screen_center_x = self.player_sprite.center_x - (self.camera_sprites.viewport_width / 2) - screen_center_y = self.player_sprite.center_y - (self.camera_sprites.viewport_height / 2) + screen_center_x = self.player_sprite.center_x + screen_center_y = self.player_sprite.center_y # Set some limits on how far we scroll - if screen_center_x < 0: - screen_center_x = 0 - if screen_center_y < 0: - screen_center_y = 0 + if screen_center_x - self.width/2 < 0: + screen_center_x = self.width/2 + if screen_center_y - self.height/2 < 0: + screen_center_y = self.height/2 # Here's our center, move to it player_centered = screen_center_x, screen_center_y - self.camera_sprites.move_to(player_centered) + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, player_centered, 0.1) def on_update(self, delta_time): """Movement and game logic""" @@ -206,8 +206,9 @@ def on_update(self, delta_time): def on_resize(self, width: int, height: int): """ Resize window """ - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + super().on_resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) def main(): diff --git a/arcade/experimental/actor_map.py b/arcade/experimental/actor_map.py index fec2dcd10..c91d8b05d 100644 --- a/arcade/experimental/actor_map.py +++ b/arcade/experimental/actor_map.py @@ -13,6 +13,7 @@ import arcade from arcade.gl import geometry from arcade import hitbox +from pyglet.math import Mat4 class ActorMap(arcade.Window): @@ -27,7 +28,7 @@ def __init__(self): def on_draw(self): self.clear() - self.ctx.projection_2d = 0, self.width, 0, self.height + self.ctx.projection_matrix = Mat4.orthogonal_projection(0, self.width, 0, self.height, -100, 100) self.actor.draw(self.time) def on_update(self, delta_time: float): @@ -104,7 +105,7 @@ def __init__(self, ctx, *, size: Tuple[int, int]): self.fbo = self.ctx.framebuffer( color_attachments=[ self.ctx.texture( - (self.size), + self.size, components=4, wrap_x=self.ctx.CLAMP_TO_EDGE, wrap_y=self.ctx.CLAMP_TO_EDGE, @@ -166,7 +167,7 @@ def draw(self): with self.fbo.activate() as fbo: fbo.clear() # Change projection to match the contents - self.ctx.projection_2d = 0, self.width, 0, self.height + self.ctx.projection = Mat4.orthogonal_projection(0, self.width, 0, self.height, -100, 100) self.sprites.draw() diff --git a/arcade/experimental/bloom_multilayer_defender.py b/arcade/experimental/bloom_multilayer_defender.py index 8e4f2284e..5124eedc6 100644 --- a/arcade/experimental/bloom_multilayer_defender.py +++ b/arcade/experimental/bloom_multilayer_defender.py @@ -174,8 +174,8 @@ def __init__(self, width, height, title): self.up_pressed = False self.down_pressed = False - self.view_bottom = 0 - self.view_left = 0 + # Camera + self.cam = None # Set the background color self.background_color = arcade.color.BLACK @@ -243,6 +243,8 @@ def setup(self): sprite.center_y = random.randrange(600) self.enemy_sprite_list.append(sprite) + self.cam = arcade.camera.Camera2D() + def on_draw(self): """ Render the screen. """ # This command has to happen before we start drawing @@ -254,10 +256,7 @@ def on_draw(self): self.slight_bloom_screen.use() self.slight_bloom_screen.clear(arcade.color.TRANSPARENT_BLACK) - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) + self.cam.use() # Draw all the sprites on the screen that should have a 'slight' bloom self.star_sprite_list.draw() @@ -266,10 +265,7 @@ def on_draw(self): self.intense_bloom_screen.use() self.intense_bloom_screen.clear(arcade.color.TRANSPARENT_BLACK) - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) + self.cam.use() # Draw all the sprites on the screen that should have a 'intense' bloom self.bullet_sprite_list.draw() @@ -277,10 +273,7 @@ def on_draw(self): # Now draw to the actual screen self.use() - arcade.set_viewport(self.view_left, - SCREEN_WIDTH + self.view_left, - self.view_bottom, - SCREEN_HEIGHT + self.view_bottom) + self.cam.use() # --- Bloom related --- @@ -329,23 +322,23 @@ def on_update(self, delta_time): self.bullet_sprite_list.append(particle) # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.cam.left + VIEWPORT_MARGIN if self.player_sprite.left < left_boundary: - self.view_left -= left_boundary - self.player_sprite.left + self.cam.left -= left_boundary - self.player_sprite.left # Scroll right - right_boundary = self.view_left + SCREEN_WIDTH - VIEWPORT_MARGIN + right_boundary = self.cam.right - VIEWPORT_MARGIN if self.player_sprite.right > right_boundary: - self.view_left += self.player_sprite.right - right_boundary + self.cam.right += self.player_sprite.right - right_boundary # Scroll up - self.view_bottom = DEFAULT_BOTTOM_VIEWPORT - top_boundary = self.view_bottom + SCREEN_HEIGHT - TOP_VIEWPORT_MARGIN + self.cam.bottom = DEFAULT_BOTTOM_VIEWPORT + top_boundary = self.cam.top - TOP_VIEWPORT_MARGIN if self.player_sprite.top > top_boundary: - self.view_bottom += self.player_sprite.top - top_boundary + self.cam.top += self.player_sprite.top - top_boundary - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) + self.cam.left = int(self.cam.left) + self.cam.bottom = int(self.cam.bottom) def on_key_press(self, key, modifiers): """Called whenever a key is pressed. """ diff --git a/arcade/experimental/clock/clock_window.py b/arcade/experimental/clock/clock_window.py index 22c36926e..3b2dd061c 100644 --- a/arcade/experimental/clock/clock_window.py +++ b/arcade/experimental/clock/clock_window.py @@ -1,3 +1,4 @@ +# mypy: ignore-errors """Clock variants of :py:class:`arcade.Window` and :py:class:`arcade.View`. Unlike the main versions, they add support for using a clock and fixed diff --git a/arcade/experimental/geo_culling_check.py b/arcade/experimental/geo_culling_check.py index b7d9fabc9..6d500c384 100644 --- a/arcade/experimental/geo_culling_check.py +++ b/arcade/experimental/geo_culling_check.py @@ -9,6 +9,7 @@ from __future__ import annotations from arcade.sprite import Sprite +from pyglet.math import Mat4 import PIL import arcade @@ -17,7 +18,7 @@ class GeoCullingTest(arcade.Window): def __init__(self): super().__init__(800, 400, "Cull test", resizable=True) - self.proj = self.ctx.projection_2d + self.proj = 0, self.width, 0, self.height self.texture = arcade.Texture( PIL.Image.new("RGBA", (2048, 2), (255, 255, 255, 255)), hash="weird_texture", @@ -37,12 +38,12 @@ def __init__(self): def on_draw(self): self.clear() - self.ctx.projection_2d = self.proj + self.ctx.projection_matrix = Mat4.orthogonal_projection(*self.proj, -100, 100) self.spritelist.draw() def on_resize(self, width, height): super().on_resize(width, height) - self.proj = self.ctx.projection_2d + self.proj = 0, width, 0, height def on_mouse_drag(self, x: float, y: float, dx: float, dy: float, buttons: int, modifiers: int): self.proj = ( diff --git a/arcade/experimental/light_demo.py b/arcade/experimental/light_demo.py index 38478d60d..d580d6e70 100644 --- a/arcade/experimental/light_demo.py +++ b/arcade/experimental/light_demo.py @@ -76,7 +76,7 @@ def on_update(self, dt): self.moving_light.radius = 300 + math.sin(self.time * 2.34) * 150 def on_resize(self, width, height): - arcade.set_viewport(0, width, 0, height) + self.default_camera.use() self.light_layer.resize(width, height) diff --git a/arcade/experimental/light_demo_perf.py b/arcade/experimental/light_demo_perf.py index 82f691c22..8f9d48c11 100644 --- a/arcade/experimental/light_demo_perf.py +++ b/arcade/experimental/light_demo_perf.py @@ -61,7 +61,7 @@ def on_update(self, dt): print(e) def on_resize(self, width, height): - arcade.set_viewport(0, width, 0, height) + self.default_camera.use() self.light_layer.resize(width, height) diff --git a/arcade/experimental/shapes_perf.py b/arcade/experimental/shapes_perf.py index e4ba9be77..f29b35bc7 100644 --- a/arcade/experimental/shapes_perf.py +++ b/arcade/experimental/shapes_perf.py @@ -9,6 +9,7 @@ import arcade from arcade.types import NamedPoint +from pyglet.math import Mat4 SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 @@ -215,7 +216,7 @@ def on_update(self, dt): def on_resize(self, width: float, height: float): w, h = self.get_framebuffer_size() self.ctx.viewport = 0, 0, w, h - self.ctx.projection_2d = 0, 800, 0, 600 + self.ctx.projection_matrix = Mat4.orthogonal_projection(0, 800, 0, 600, -100, 100) if __name__ == '__main__': diff --git a/arcade/experimental/subpixel_experiment.py b/arcade/experimental/subpixel_experiment.py index 5e957d465..d07a85cc7 100644 --- a/arcade/experimental/subpixel_experiment.py +++ b/arcade/experimental/subpixel_experiment.py @@ -57,8 +57,7 @@ def on_update(self, dt): def on_resize(self, width, height): print("Resize", width, height) - arcade.set_viewport(0, SCREEN_WIDTH, 0, SCREEN_HEIGHT) - # arcade.set_viewport(100, 200, 100, 200) + self.default_camera.use() if __name__ == "__main__": diff --git a/arcade/gui/surface.py b/arcade/gui/surface.py index 1141a3fc0..e74d95d2d 100644 --- a/arcade/gui/surface.py +++ b/arcade/gui/surface.py @@ -6,6 +6,7 @@ import arcade from arcade import Texture from arcade.color import TRANSPARENT_BLACK +from arcade.camera import OrthographicProjector, OrthographicProjectionData, CameraData from arcade.gl import Framebuffer from arcade.gui.nine_patch import NinePatchTexture from arcade.types import RGBA255, FloatRect, Point @@ -51,6 +52,16 @@ def __init__( fragment_shader=":system:shaders/gui/surface_fs.glsl", ) + self._cam = OrthographicProjector( + view=CameraData(), + projection=OrthographicProjectionData( + 0.0, self.width, + 0.0, self.height, + -100, 100, + (0, 0, self.width, self.height) + ) + ) + @property def position(self) -> Point: """Get or set the surface position""" @@ -132,31 +143,36 @@ def activate(self): Also resets the limit of the surface (viewport). """ # Set viewport and projection - proj = self.ctx.projection_2d self.limit(0, 0, *self.size) # Set blend function blend_func = self.ctx.blend_func - self.ctx.blend_func = self.blend_func_render_into - - with self.fbo.activate(): - yield self - # Restore projection and blend function - self.ctx.projection_2d = proj - self.ctx.blend_func = blend_func + try: + self.ctx.blend_func = self.blend_func_render_into + with self.fbo.activate(): + yield self + finally: + # Restore blend function. + self.ctx.blend_func = blend_func def limit(self, x, y, width, height): """Reduces the draw area to the given rect""" - self.fbo.viewport = ( + + viewport = ( int(x * self._pixel_ratio), int(y * self._pixel_ratio), int(width * self._pixel_ratio), int(height * self._pixel_ratio), ) + self.fbo.viewport = viewport width = max(width, 1) height = max(height, 1) - self.ctx.projection_2d = 0, width, 0, height + _p = self._cam.projection + _p.left, _p.right, _p.bottom, _p.top = 0, width, 0, height + self._cam.projection.viewport = viewport + + self._cam.use() def draw( self, @@ -199,3 +215,4 @@ def resize(self, *, size: Tuple[int, int], pixel_ratio: float) -> None: self.texture = self.ctx.texture(self.size_scaled, components=4) self.fbo = self.ctx.framebuffer(color_attachments=[self.texture]) self.fbo.clear() + diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index fb578a93c..e8384f593 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -11,30 +11,21 @@ from __future__ import annotations from collections import defaultdict -from typing import List, Dict, TypeVar, Iterable, Optional, Type, Union +from typing import Dict, Iterable, List, Optional, Type, TypeVar, Union, Tuple -from arcade.types import Point +from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from typing_extensions import TypeGuard -from pyglet.event import EventDispatcher, EVENT_HANDLED, EVENT_UNHANDLED - import arcade -from arcade.gui.events import ( - UIMouseMovementEvent, - UIMousePressEvent, - UIMouseReleaseEvent, - UIMouseScrollEvent, - UITextEvent, - UIMouseDragEvent, - UITextMotionEvent, - UITextMotionSelectEvent, - UIKeyPressEvent, - UIKeyReleaseEvent, - UIOnUpdateEvent, -) +from arcade.gui.events import (UIKeyPressEvent, UIKeyReleaseEvent, + UIMouseDragEvent, UIMouseMovementEvent, + UIMousePressEvent, UIMouseReleaseEvent, + UIMouseScrollEvent, UIOnUpdateEvent, + UITextEvent, UITextMotionEvent, + UITextMotionSelectEvent) from arcade.gui.surface import Surface -from arcade.gui.widgets import UIWidget, Rect -from arcade.camera import SimpleCamera +from arcade.gui.widgets import Rect, UIWidget +from arcade.types import Point W = TypeVar("W", bound=UIWidget) @@ -93,8 +84,6 @@ def __init__(self, window: Optional[arcade.Window] = None): self._surfaces: Dict[int, Surface] = {} self.children: Dict[int, List[UIWidget]] = defaultdict(list) self._requires_render = True - #: Camera used when drawing the UI - self.camera = SimpleCamera() self.register_event_type("on_event") def add(self, widget: W, *, index=None, layer=0) -> W: @@ -310,6 +299,7 @@ def on_update(self, time_delta): return self.dispatch_ui_event(UIOnUpdateEvent(self, time_delta)) def draw(self) -> None: + current_cam = self.window.current_camera """ Will draw all widgets to the window. @@ -330,28 +320,25 @@ def draw(self) -> None: with ctx.enabled(ctx.BLEND): self._do_render() + # Correct that the ui changes the currently active camera. + current_cam.use() + # Draw layers - self.camera.use() with ctx.enabled(ctx.BLEND): layers = sorted(self.children.keys()) for layer in layers: self._get_surface(layer).draw() - def adjust_mouse_coordinates(self, x, y): + def adjust_mouse_coordinates(self, x: float, y: float) -> Tuple[float, float]: """ This method is used, to translate mouse coordinates to coordinates respecting the viewport and projection of cameras. - The implementation should work in most common cases. - - If you use scrolling in the :py:class:`arcade.Camera` you have to reset scrolling - or overwrite this method using the camera conversion:: - ui_manager.adjust_mouse_coordinates = camera.mouse_coordinates_to_world + It uses the internal camera's map_coordinate methods, and should work with + all transformations possible with the basic orthographic camera. """ - # NOTE: Only support scrolling until cameras support transforming - # mouse coordinates - px, py = self.camera.position - return x + px, y + py + x_, y_, *c = self.window.current_camera.map_screen_to_world_coordinate((x, y)) + return x_, y_ def on_event(self, event) -> Union[bool, None]: layers = sorted(self.children.keys(), reverse=True) @@ -366,27 +353,27 @@ def dispatch_ui_event(self, event): return self.dispatch_event("on_event", event) def on_mouse_motion(self, x: int, y: int, dx: int, dy: int): - x, y = self.adjust_mouse_coordinates(x, y) - return self.dispatch_ui_event(UIMouseMovementEvent(self, x, y, dx, dy)) # type: ignore + x_, y_ = self.adjust_mouse_coordinates(x, y) + return self.dispatch_ui_event(UIMouseMovementEvent(self, int(x_), int(y), dx, dy)) def on_mouse_press(self, x: int, y: int, button: int, modifiers: int): - x, y = self.adjust_mouse_coordinates(x, y) - return self.dispatch_ui_event(UIMousePressEvent(self, x, y, button, modifiers)) # type: ignore + x_, y_ = self.adjust_mouse_coordinates(x, y) + return self.dispatch_ui_event(UIMousePressEvent(self, int(x_), int(y_), button, modifiers)) def on_mouse_drag( self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int ): - x, y = self.adjust_mouse_coordinates(x, y) - return self.dispatch_ui_event(UIMouseDragEvent(self, x, y, dx, dy, buttons, modifiers)) # type: ignore + x_, y_ = self.adjust_mouse_coordinates(x, y) + return self.dispatch_ui_event(UIMouseDragEvent(self, int(x_), int(y_), dx, dy, buttons, modifiers)) def on_mouse_release(self, x: int, y: int, button: int, modifiers: int): - x, y = self.adjust_mouse_coordinates(x, y) - return self.dispatch_ui_event(UIMouseReleaseEvent(self, x, y, button, modifiers)) # type: ignore + x_, y_ = self.adjust_mouse_coordinates(x, y) + return self.dispatch_ui_event(UIMouseReleaseEvent(self, int(x_), int(y_), button, modifiers)) def on_mouse_scroll(self, x, y, scroll_x, scroll_y): - x, y = self.adjust_mouse_coordinates(x, y) + x_, y_ = self.adjust_mouse_coordinates(x, y) return self.dispatch_ui_event( - UIMouseScrollEvent(self, x, y, scroll_x, scroll_y) + UIMouseScrollEvent(self, int(x_), int(y_), scroll_x, scroll_y) ) def on_key_press(self, symbol: int, modifiers: int): @@ -406,8 +393,6 @@ def on_text_motion_select(self, motion): def on_resize(self, width, height): scale = self.window.get_pixel_ratio() - self.camera.resize(width, height) - for surface in self._surfaces.values(): surface.resize(size=(width, height), pixel_ratio=scale) diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 69541f91e..0ba261332 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -226,7 +226,6 @@ def _apply_style(self, style: UITextureButtonStyle): self.content_width, self.content_height ) - class UIFlatButton(UIInteractiveWidget, UIStyledWidget, UITextWidget): """ A text button, with support for background color and a border. diff --git a/arcade/resources/system/shaders/util/textured_quad_fs.glsl b/arcade/resources/system/shaders/util/textured_quad_fs.glsl new file mode 100644 index 000000000..6693a83d9 --- /dev/null +++ b/arcade/resources/system/shaders/util/textured_quad_fs.glsl @@ -0,0 +1,11 @@ +#version 330 + +uniform sampler2D texture0; + +in vec2 out_uv; + +out vec4 out_colour; + +void main(){ + out_colour = texture(texture0, out_uv); +} diff --git a/arcade/resources/system/shaders/util/textured_quad_vs.glsl b/arcade/resources/system/shaders/util/textured_quad_vs.glsl new file mode 100644 index 000000000..9b7e8eb72 --- /dev/null +++ b/arcade/resources/system/shaders/util/textured_quad_vs.glsl @@ -0,0 +1,11 @@ +#version 330 + +in vec2 in_uv; +in vec2 in_vert; + +out vec2 out_uv; + +void main(){ + out_uv = in_uv; + gl_Position = vec4(in_vert, 0.0, 1.0); +} \ No newline at end of file diff --git a/arcade/sections.py b/arcade/sections.py index 263c1a6ef..02e8a0c67 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -5,9 +5,11 @@ from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED -from arcade import SimpleCamera, get_window +from arcade import get_window +from arcade.camera.default import DefaultProjector if TYPE_CHECKING: + from arcade.camera import Projector from arcade import View __all__ = [ @@ -101,7 +103,7 @@ def __init__(self, left: int, bottom: int, width: int, height: int, self._ec_top: int = self.window.height if self._modal else self._top # optional section camera - self.camera: Optional[SimpleCamera] = None + self.camera: Optional[Projector] = None def __repr__(self): name = f'Section {self.name}' if self.name else 'Section' @@ -327,9 +329,7 @@ def __init__(self, view: "View"): # generic camera to reset after a custom camera is use # this camera is set to the whole viewport - self.camera: SimpleCamera = SimpleCamera(viewport=(0, 0, - self.view.window.width, - self.view.window.height)) + self.camera: DefaultProjector = DefaultProjector() # Holds the section the mouse is currently on top self.mouse_over_sections: List[Section] = [] @@ -504,7 +504,7 @@ def on_resize(self, width: int, height: int) -> None: :param width: the new width of the screen :param height: the new height of the screen """ - self.camera.resize(width, height) # resize the default camera + # The Default camera auto-resizes. if self.view_resize_first is True: self.view.on_resize(width, height) # call resize on the view for section in self.sections: diff --git a/arcade/texture_atlas/atlas_2d.py b/arcade/texture_atlas/atlas_2d.py index a4935e241..08ef76687 100644 --- a/arcade/texture_atlas/atlas_2d.py +++ b/arcade/texture_atlas/atlas_2d.py @@ -951,12 +951,19 @@ def render_into( The tuple values are: (left, right, button, top) """ region = self._texture_regions[texture.atlas_name] - proj_prev = self._ctx.projection_2d + proj_prev = self._ctx.projection_matrix # Use provided projection or default projection = projection or (0, region.width, 0, region.height) # Flip the top and bottom because we need to render things upside down projection = projection[0], projection[1], projection[3], projection[2] - self._ctx.projection_2d = projection + self._ctx.projection_matrix = Mat4.orthogonal_projection( + left=projection[0], + right=projection[1], + bottom=projection[2], + top=projection[3], + z_near=-1, + z_far=1, + ) with self._fbo.activate() as fbo: fbo.viewport = region.x, region.y, region.width, region.height @@ -965,7 +972,7 @@ def render_into( finally: fbo.viewport = 0, 0, *self._fbo.size - self._ctx.projection_2d = proj_prev + self._ctx.projection_matrix = proj_prev @classmethod def create_from_texture_sequence(cls, textures: Sequence["Texture"], border: int = 1) -> "TextureAtlas": diff --git a/arcade/window_commands.py b/arcade/window_commands.py index caa62d5f7..a233c2d25 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -28,7 +28,6 @@ "get_display_size", "get_window", "set_window", - "set_viewport", "close_window", "run", "exit", @@ -82,65 +81,6 @@ def set_window(window: Optional["Window"]) -> None: _window = window -def set_viewport(left: float, right: float, bottom: float, top: float) -> None: - """ - This sets what coordinates the window will cover. - - .. tip:: Beginners will want to use :py:class:`~arcade.Camera`. - It provides easy to use support for common tasks - such as screen shake and movement to a destination. - - If you are making a game with complex control over the viewport, - this function can help. - - By default, the lower left coordinate will be ``(0, 0)``, the top y - coordinate will be the height of the window in pixels, and the right x - coordinate will be the width of the window in pixels. - - .. warning:: Be careful of fractional or non-multiple values! - - It is recommended to only set the viewport to integer values that - line up with the pixels on the screen. Otherwise, tiled pixel art - may not line up well during render, creating rectangle artifacts. - - .. note:: :py:meth:`Window.on_resize ` - calls ``set_viewport`` by default. If you want to set your - own custom viewport during the game, you may need to - override the - :py:meth:`Window.on_resize ` - method. - - .. note:: For more advanced users - - This functions sets the orthogonal projection - used by shapes and sprites. It also updates the viewport to match the current - screen resolution. - ``window.ctx.projection_2d`` (:py:meth:`~arcade.ArcadeContext.projection_2d`) - and ``window.ctx.viewport`` (:py:meth:`~arcade.gl.Context.viewport`) - can be used to set viewport and projection separately. - - :param left: Left-most (smallest) x value. - :param right: Right-most (largest) x value. - :param bottom: Bottom (smallest) y value. - :param top: Top (largest) y value. - """ - window = get_window() - # Get the active framebuffer - fbo = window.ctx.fbo - # If the framebuffer is the default one (aka. window framebuffer) - # we can't trust its size and need to get that from the window. - # This is because the default framebuffer is only introspected - # during context creation and it doesn't update size internally - # when the window is resizing. - if fbo.is_default: - fbo.viewport = 0, 0, window.width, window.height - # Otherwise it's an offscreen framebuffer and we can trust the size - else: - fbo.viewport = 0, 0, *fbo.size - - window.ctx.projection_2d = left, right, bottom, top - - def close_window() -> None: """ Closes the current window, and then runs garbage collection. The garbage collection diff --git a/doc/api_docs/arcade.rst b/doc/api_docs/arcade.rst index ac4ddf9ad..a1e822a03 100644 --- a/doc/api_docs/arcade.rst +++ b/doc/api_docs/arcade.rst @@ -20,7 +20,6 @@ for the Python Arcade library. See also: api/sprites api/sprite_list api/sprite_scenes - api/camera api/text api/tilemap api/texture diff --git a/doc/tutorials/lights/01_light_demo.py b/doc/tutorials/lights/01_light_demo.py index 0ea4ffd92..fad8fec1e 100644 --- a/doc/tutorials/lights/01_light_demo.py +++ b/doc/tutorials/lights/01_light_demo.py @@ -25,9 +25,8 @@ def __init__(self, width, height, title): # Physics engine self.physics_engine = None - # Used for scrolling - self.view_left = 0 - self.view_bottom = 0 + # camera for scrolling + self.camera = None def setup(self): """ Create everything """ @@ -52,10 +51,8 @@ def setup(self): # Create the physics engine self.physics_engine = arcade.PhysicsEngineSimple(self.player_sprite, self.wall_list) - # Set the viewport boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 + # setup camera + self.camera = arcade.camera.Camera2D() def on_draw(self): """ Draw everything. """ @@ -88,36 +85,33 @@ def scroll_screen(self): """ Manage Scrolling """ # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.camera.left + VIEWPORT_MARGIN if self.player_sprite.left < left_boundary: - self.view_left -= left_boundary - self.player_sprite.left + self.camera.left -= left_boundary - self.player_sprite.left # Scroll right - right_boundary = self.view_left + self.width - VIEWPORT_MARGIN + right_boundary = self.camera.right - VIEWPORT_MARGIN if self.player_sprite.right > right_boundary: - self.view_left += self.player_sprite.right - right_boundary + self.camera.right += self.player_sprite.right - right_boundary # Scroll up - top_boundary = self.view_bottom + self.height - VIEWPORT_MARGIN + top_boundary = self.camera.top - VIEWPORT_MARGIN if self.player_sprite.top > top_boundary: - self.view_bottom += self.player_sprite.top - top_boundary + self.camera.top += self.player_sprite.top - top_boundary # Scroll down - bottom_boundary = self.view_bottom + VIEWPORT_MARGIN + bottom_boundary = self.camera.bottom + VIEWPORT_MARGIN if self.player_sprite.bottom < bottom_boundary: - self.view_bottom -= bottom_boundary - self.player_sprite.bottom + self.camera.bottom -= bottom_boundary - self.player_sprite.bottom # Make sure our boundaries are integer values. While the viewport does # support floating point numbers, for this application we want every pixel # in the view port to map directly onto a pixel on the screen. We don't want # any rounding errors. - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) + self.camera.left = int(self.camera.left) + self.camera.bottom = int(self.camera.bottom) - arcade.set_viewport(self.view_left, - self.width + self.view_left, - self.view_bottom, - self.height + self.view_bottom) + self.camera.use() def on_update(self, delta_time): """ Movement and game logic """ diff --git a/doc/tutorials/lights/light_demo.py b/doc/tutorials/lights/light_demo.py index 5efce83c9..18a8b1e1c 100644 --- a/doc/tutorials/lights/light_demo.py +++ b/doc/tutorials/lights/light_demo.py @@ -37,9 +37,8 @@ def __init__(self, width, height, title): # Physics engine self.physics_engine = None - # Used for scrolling - self.view_left = 0 - self.view_bottom = 0 + # camera for scrolling + self.camera = None # --- Light related --- # List of all the lights @@ -186,10 +185,8 @@ def setup(self): # Create the physics engine self.physics_engine = arcade.PhysicsEngineSimple(self.player_sprite, self.wall_list) - # Set the viewport boundaries - # These numbers set where we have 'scrolled' to. - self.view_left = 0 - self.view_bottom = 0 + # setup camera + self.camera = arcade.camera.Camera2D() def on_draw(self): """ Draw everything. """ @@ -210,7 +207,7 @@ def on_draw(self): # Now draw anything that should NOT be affected by lighting. arcade.draw_text("Press SPACE to turn character light on/off.", - 10 + self.view_left, 10 + self.view_bottom, + 10 + self.camera.left, 10 + self.camera.bottom, arcade.color.WHITE, 20) def on_resize(self, width, height): @@ -255,36 +252,33 @@ def scroll_screen(self): """ Manage Scrolling """ # Scroll left - left_boundary = self.view_left + VIEWPORT_MARGIN + left_boundary = self.camera.left + VIEWPORT_MARGIN if self.player_sprite.left < left_boundary: - self.view_left -= left_boundary - self.player_sprite.left + self.camera.left -= left_boundary - self.player_sprite.left # Scroll right - right_boundary = self.view_left + self.width - VIEWPORT_MARGIN + right_boundary = self.camera.right - VIEWPORT_MARGIN if self.player_sprite.right > right_boundary: - self.view_left += self.player_sprite.right - right_boundary + self.camera.right += self.player_sprite.right - right_boundary # Scroll up - top_boundary = self.view_bottom + self.height - VIEWPORT_MARGIN + top_boundary = self.camera.top - VIEWPORT_MARGIN if self.player_sprite.top > top_boundary: - self.view_bottom += self.player_sprite.top - top_boundary + self.camera.top += self.player_sprite.top - top_boundary # Scroll down - bottom_boundary = self.view_bottom + VIEWPORT_MARGIN + bottom_boundary = self.camera.bottom + VIEWPORT_MARGIN if self.player_sprite.bottom < bottom_boundary: - self.view_bottom -= bottom_boundary - self.player_sprite.bottom + self.camera.bottom -= bottom_boundary - self.player_sprite.bottom # Make sure our boundaries are integer values. While the viewport does # support floating point numbers, for this application we want every pixel # in the view port to map directly onto a pixel on the screen. We don't want # any rounding errors. - self.view_left = int(self.view_left) - self.view_bottom = int(self.view_bottom) + self.camera.left = int(self.camera.left) + self.camera.bottom = int(self.camera.bottom) - arcade.set_viewport(self.view_left, - self.width + self.view_left, - self.view_bottom, - self.height + self.view_bottom) + self.camera.use() def on_update(self, delta_time): """ Movement and game logic """ diff --git a/doc/tutorials/raycasting/step_08.py b/doc/tutorials/raycasting/step_08.py index cb425ad85..cb1e998ea 100644 --- a/doc/tutorials/raycasting/step_08.py +++ b/doc/tutorials/raycasting/step_08.py @@ -39,8 +39,8 @@ def __init__(self, width, height, title): self.physics_engine = None # Create cameras used for scrolling - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.Camera2D() + self.camera_gui = arcade.camera.Camera2D() self.generate_sprites() @@ -103,8 +103,6 @@ def generate_sprites(self): # Start centered on the player self.scroll_to_player(1.0) - self.camera_sprites.update() - def on_draw(self): # Use our scrolled camera @@ -128,8 +126,8 @@ def on_draw(self): # Calculate the light position. We have to subtract the camera position # from the player position to get screen-relative coordinates. - p = (self.player_sprite.position[0] - self.camera_sprites.position[0], - self.player_sprite.position[1] - self.camera_sprites.position[1]) + p = (self.player_sprite.position[0] - self.camera_sprites.left, + self.player_sprite.position[1] - self.camera_sprites.bottom) # Set the uniform data self.shadertoy.program['lightPosition'] = p @@ -187,14 +185,13 @@ def scroll_to_player(self, speed=CAMERA_SPEED): pan. """ - position = Vec2(self.player_sprite.center_x - self.width / 2, - self.player_sprite.center_y - self.height / 2) - self.camera_sprites.move_to(position, speed) + position = (self.player_sprite.center_x, self.player_sprite.center_y) + self.camera_sprites.position = arcade.math.lerp_2d(self.camera_sprites.position, position, CAMERA_SPEED) def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera_sprites.resize(width, height) - self.camera_gui.resize(width, height) + self.camera_sprites.match_screen(and_projection=True) + self.camera_gui.match_screen(and_projection=True) self.shadertoy.resize((width, height)) diff --git a/doc/tutorials/views/03_views.py b/doc/tutorials/views/03_views.py index 71a33b4e2..86e5948c8 100644 --- a/doc/tutorials/views/03_views.py +++ b/doc/tutorials/views/03_views.py @@ -20,7 +20,7 @@ def on_show_view(self): # Reset the viewport, necessary if we have a scrolling game and we need # to reset the viewport back to the start so we can see what we draw. - arcade.set_viewport(0, self.window.width, 0, self.window.height) + self.window.default_camera.use() def on_draw(self): """ Draw this view """ diff --git a/doc/tutorials/views/04_views.py b/doc/tutorials/views/04_views.py index 48eb145fd..ddb0f15c4 100644 --- a/doc/tutorials/views/04_views.py +++ b/doc/tutorials/views/04_views.py @@ -20,7 +20,7 @@ def on_show_view(self): # Reset the viewport, necessary if we have a scrolling game and we need # to reset the viewport back to the start so we can see what we draw. - arcade.set_viewport(0, self.window.width, 0, self.window.height) + self.window.default_camera.use() def on_draw(self): """ Draw this view """ @@ -47,7 +47,7 @@ def __init__(self): # Reset the viewport, necessary if we have a scrolling game and we need # to reset the viewport back to the start so we can see what we draw. - arcade.set_viewport(0, SCREEN_WIDTH - 1, 0, SCREEN_HEIGHT - 1) + self.window.default_camera.use() def on_draw(self): """ Draw this view """ diff --git a/tests/conftest.py b/tests/conftest.py index 46b581477..5e17e4f4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,9 @@ def prepare_window(window: arcade.Window): raise RuntimeError("Please do not close the global test window :D") window.switch_to() + if window.get_size() < (800, 600): + window.set_size(800, 600) + ctx = window.ctx ctx._atlas = None # Clear the global atlas arcade.cleanup_texture_cache() # Clear the global texture cache @@ -51,6 +54,7 @@ def prepare_window(window: arcade.Window): window.set_vsync(False) window.flip() window.clear() + window.default_camera.use() ctx.gc_mode = "context_gc" ctx.gc() gc.collect() @@ -113,7 +117,7 @@ def __init__(self, width=800, height=600, caption="Test Window", *args, **kwargs self.window.set_caption(caption) if width and height: self.window.set_size(width, height) - self.window.set_viewport(0, width, 0, height) + self.window.default_camera.use() self._update_rate = 60 @@ -194,11 +198,14 @@ def center_window(self): def set_vsync(self, vsync): self.window.set_vsync(vsync) - def get_viewport(self): - return self.window.get_viewport() - - def set_viewport(self, left, right, bottom, top): - self.window.set_viewport(left, right, bottom, top) + @property + def default_camera(self): + """ + Provides a reference to the default arcade camera. + Automatically sets projection and view to the size + of the screen. Good for resetting the screen. + """ + return self.window.default_camera def use(self): self.window.use() diff --git a/tests/unit/camera/test_camera.py b/tests/unit/camera/test_camera.py deleted file mode 100644 index 795c2ecde..000000000 --- a/tests/unit/camera/test_camera.py +++ /dev/null @@ -1,11 +0,0 @@ -import arcade - - -def test_camera(window): - c1 = arcade.SimpleCamera() - assert c1.viewport == (0, 0, *window.size) - assert c1.projection == (0, window.width, 0, window.height) - - c2 = arcade.Camera() - assert c2.viewport == (0, 0, *window.size) - assert c2.projection == (0, window.width, 0, window.height) diff --git a/tests/unit/camera/test_camera_controller_methods.py b/tests/unit/camera/test_camera_controller_methods.py new file mode 100644 index 000000000..c937d08da --- /dev/null +++ b/tests/unit/camera/test_camera_controller_methods.py @@ -0,0 +1,63 @@ +import pytest as pytest + +from arcade import camera, Window +import arcade.camera.grips as grips + + +def test_strafe(): + # Given + camera_data = camera.CameraData() + directions = ((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0), (0.5, 0.5)) + + # When + camera_data.forward = (0.0, 0.0, -1.0) + camera_data.up = (0.0, 1.0, 0.0) + + # Then + for dirs in directions: + camera_data.position = grips.strafe(camera_data, dirs) + assert camera_data.position == (dirs[0], dirs[1], 0.0), f"Strafe failed to move the camera data correctly, {dirs}" + camera_data.position = (0.0, 0.0, 0.0) + + # Given + camera_data.forward = (1.0, 0.0, 0.0) + camera_data.up = (0.0, 1.0, 0.0) + + for dirs in directions: + camera_data.position = grips.strafe(camera_data, dirs) + assert camera_data.position == (0.0, dirs[1], dirs[0]), f"Strafe failed to move the camera data correctly, {dirs}" + camera_data.position = (0.0, 0.0, 0.0) + + +def test_rotate_around_forward(): + # Given + camera_data = camera.CameraData() + + # When + camera_data.up = grips.rotate_around_forward(camera_data, 90) + + # Then + assert camera_data.up == pytest.approx((-1.0, 0.0, 0.0)) + + +def test_rotate_around_up(window: Window): + # Given + camera_data = camera.CameraData() + + # When + camera_data.forward = grips.rotate_around_up(camera_data, 90) + + # Then + assert camera_data.forward == pytest.approx((1.0, 0.0, 0.0)) + + +def test_rotate_around_right(window: Window): + # Given + camera_data = camera.CameraData() + + # When + camera_data.up, camera_data.forward = grips.rotate_around_right(camera_data, 90) + + # Then + assert camera_data.up == pytest.approx((0.0, 0.0, -1.0)) + assert camera_data.forward == pytest.approx((0.0, -1.0, 0.0)) diff --git a/tests/unit/camera/test_camera_shake.py b/tests/unit/camera/test_camera_shake.py new file mode 100644 index 000000000..6e431bbfb --- /dev/null +++ b/tests/unit/camera/test_camera_shake.py @@ -0,0 +1,94 @@ +from arcade import camera, Window +from arcade.camera.grips import ScreenShake2D + + +def test_reset(window: Window): + # Given + camera_view = camera.CameraData() + screen_shaker = ScreenShake2D(camera_view) + + # When + screen_shaker.start() + screen_shaker.update(1.0) + screen_shaker.update_camera() + screen_shaker.readjust_camera() + screen_shaker.reset() + + # Then + assert screen_shaker._current_dir == 0.0, "ScreenShakeController failed to reset properly [current_dir]" + assert screen_shaker._last_vector == (0.0, 0.0, 0.0), "ScreenShakeController failed to reset properly [last_vector]" + assert screen_shaker._length_shaking == 0.0, "ScreenShakeController failed to reset properly [length_shaking]" + assert screen_shaker._last_update_time == 0.0, "ScreenShakeController failed to reset properly [last_update_time]" + + +def test_update(window: Window): + # Given + camera_view = camera.CameraData() + screen_shaker = ScreenShake2D(camera_view) + + # When + screen_shaker.update(1/60) + + # Then + assert screen_shaker._length_shaking == 0.0, "ScreenShakeController updated when it had not started" + + # When + screen_shaker.start() + screen_shaker.update(1/60) + + # Then + assert screen_shaker._length_shaking == 1/60, "ScreenShakeController failed to update by the correct dt" + + # When + screen_shaker.stop() + screen_shaker.update(1/60) + + # Then + assert screen_shaker._length_shaking == 0.0, "ScreenShakeController failed to stop updating" + + # When + screen_shaker.start() + screen_shaker.update(2.0) + + # Then + assert not screen_shaker.shaking, "ScreenShakeController failed to stop when shaking for too long" + + +def test_update_camera(window: Window): + # Given + camera_view = camera.CameraData() + screen_shaker = ScreenShake2D(camera_view) + + cam_pos = camera_view.position + + # When + screen_shaker.start() + screen_shaker.update(1/60) + screen_shaker.update_camera() + + # Then + assert camera_view.position != cam_pos, "ScreenShakeController failed to change the camera's position" + assert screen_shaker._last_vector != (0.0, 0.0, 0.0), "ScreenShakeController failed to store the last vector" + _adjust_test = ( + camera_view.position[0] - screen_shaker._last_vector[0], + camera_view.position[1] - screen_shaker._last_vector[1], + camera_view.position[2] - screen_shaker._last_vector[2], + ) + assert _adjust_test == cam_pos, "ScreenShakeController failed to store the correct last vector" + + +def test_readjust_camera(window: Window): + camera_view = camera.CameraData() + screen_shaker = ScreenShake2D(camera_view) + + cam_pos = camera_view.position + + # When + screen_shaker.start() + screen_shaker.update(1 / 60) + screen_shaker.update_camera() + screen_shaker.readjust_camera() + + # Then + assert camera_view.position == cam_pos, "ScreenShakeController failed to readjust the camera position" + assert screen_shaker._last_vector == (0.0, 0.0, 0.0), "ScreenShakeController failed to reset the last vector" diff --git a/tests/unit/camera/test_orthographic_camera.py b/tests/unit/camera/test_orthographic_camera.py new file mode 100644 index 000000000..3ccf8e968 --- /dev/null +++ b/tests/unit/camera/test_orthographic_camera.py @@ -0,0 +1,187 @@ +import pytest as pytest + +from arcade import camera, Window + + +def test_orthographic_projector_use(window: Window): + # Given + from pyglet.math import Mat4 + ortho_camera = camera.OrthographicProjector() + + view_matrix = ortho_camera._generate_view_matrix() + proj_matrix = ortho_camera._generate_projection_matrix() + + # When + ortho_camera.use() + + # Then + assert window.current_camera is ortho_camera + assert window.ctx.view_matrix == view_matrix + assert window.ctx.projection_matrix == proj_matrix + + # Reset the window for later tests + window.default_camera.use() + + +def test_orthographic_projector_activate(window: Window): + # Given + ortho_camera: camera.OrthographicProjector = camera.OrthographicProjector() + + view_matrix = ortho_camera._generate_view_matrix() + proj_matrix = ortho_camera._generate_projection_matrix() + + # When + with ortho_camera.activate() as cam: + # Initially + assert window.current_camera is cam is ortho_camera + assert window.ctx.view_matrix == view_matrix + assert window.ctx.projection_matrix == proj_matrix + + # Finally + assert window.current_camera is window.default_camera + + # Reset the window for later tests + window.default_camera.use() + + +@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) +def test_orthographic_projector_map_coordinates(window: Window, width, height): + # Given + window.set_size(width, height) + ortho_camera = camera.OrthographicProjector() + + # When + mouse_pos_a = (100.0, 100.0) + mouse_pos_b = (100.0, 0.0) + mouse_pos_c = (230.0, 800.0) + + # Then + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) == pytest.approx((100.0, 100.0, 0.0)) + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) == pytest.approx((100.0, 0.0, 0.0)) + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_c) == pytest.approx((230.0, 800.0, 0.0)) + + +@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) +def test_orthographic_projector_map_coordinates_move(window: Window, width, height): + # Given + window.set_size(width, height) + ortho_camera = camera.OrthographicProjector() + default_view = ortho_camera.view + + half_width, half_height = window.width//2, window.height//2 + + mouse_pos_a = (half_width, half_height) + mouse_pos_b = (100.0, 100.0) + + # When + default_view.position = (0.0, 0.0, 0.0) + + # Then + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) == pytest.approx((0.0, 0.0, 0.0)) + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) + == + pytest.approx((-half_width+100.0, -half_height+100, 0.0)) + ) + + # And + + # When + default_view.position = (100.0, 100.0, 0.0) + + # Then + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) == pytest.approx((100.0, 100.0, 0.0)) + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) + == + pytest.approx((-half_width+200.0, -half_height+200.0, 0.0)) + ) + + +@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) +def test_orthographic_projector_map_coordinates_rotate(window: Window, width, height): + # Given + window.set_size(width, height) + ortho_camera = camera.OrthographicProjector() + default_view = ortho_camera.view + + half_width, half_height = window.width//2, window.height//2 + + mouse_pos_a = (half_width, half_height) + mouse_pos_b = (100.0, 100.0) + + # When + default_view.up = (1.0, 0.0, 0.0) + default_view.position = (0.0, 0.0, 0.0) + + # Then + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) == pytest.approx((0.0, 0.0, 0.0)) + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) + == + pytest.approx((-half_height+100.0, half_width-100.0, 0.0)) + ) + + # And + + # When + default_view.up = (2.0**-0.5, 2.0**-0.5, 0.0) + default_view.position = (100.0, 100.0, 0.0) + + b_shift_x = -half_width + 100.0 + b_shift_y = -half_height + 100.0 + b_rotated_x = b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 + b_rotated_y = -b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 + # Then + assert ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) == pytest.approx((100.0, 100.0, 0.0)) + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) + == + pytest.approx((b_rotated_x, b_rotated_y, 0.0)) + ) + + +@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) +def test_orthographic_projector_map_coordinates_zoom(window: Window, width, height): + # Given + window.set_size(width, height) + ortho_camera = camera.OrthographicProjector() + default_view = ortho_camera.view + + half_width, half_height = window.width//2, window.height//2 + + mouse_pos_a = (window.width, window.height) + mouse_pos_b = (100.0, 100.0) + + # When + default_view.zoom = 2.0 + + # Then + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) + == + pytest.approx((window.width*0.75, window.height*0.75, 0.0)) + ) + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) + == + pytest.approx((half_width + (100 - half_width)*0.5, half_height + (100 - half_height)*0.5, 0.0)) + ) + + # And + + # When + default_view.position = (0.0, 0.0, 0.0) + default_view.zoom = 0.25 + + # Then + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_a) + == + pytest.approx((window.width*2.0, window.height*2.0, 0.0)) + ) + assert ( + ortho_camera.map_screen_to_world_coordinate(mouse_pos_b) + == + pytest.approx(((100 - half_width)*4.0, (100 - half_height)*4.0, 0.0)) + ) \ No newline at end of file diff --git a/tests/unit/gl/test_opengl_context.py b/tests/unit/gl/test_opengl_context.py index 92069846a..ae87af0ba 100644 --- a/tests/unit/gl/test_opengl_context.py +++ b/tests/unit/gl/test_opengl_context.py @@ -27,34 +27,19 @@ def test_viewport(ctx): assert ctx.viewport == vp -def test_projection(window): - ctx = window.ctx - assert ctx.projection_2d == (0, window.width, 0, window.height) - ctx.projection_2d = (1, 10, 2, 11) - assert ctx.projection_2d == (1, 10, 2, 11) +def test_view_matrix(window): + """Test setting the view matrix directly""" + window.ctx.view_matrix = Mat4() - # Attempt to assign illegal values with pytest.raises(ValueError): - ctx.projection_2d = "moo" - - with pytest.raises(ValueError): - ctx.projection_2d = 1, 2, 3, 4, 5 - - # Set matrices directly checking projection - # parameter reconstruction - ctx.projection_2d_matrix = Mat4.orthogonal_projection(0, 100, 0, 200, -100, 100) - assert ctx.projection_2d == (0, 100, 0, 200) - ctx.projection_2d_matrix = Mat4.orthogonal_projection(100, 200, 200, 400, -100, 100) - assert ctx.projection_2d == (100, 200, 200, 400) - ctx.projection_2d_matrix = Mat4.orthogonal_projection(200, 800, 300, 900, -100, 100) - assert ctx.projection_2d == (200, 800, 300, 900) + window.ctx.view_matrix = "moo" def test_projection_matrix(window): """Test setting projection matrix directly""" - window.ctx.projection_2d_matrix = Mat4() + window.ctx.projection_matrix = Mat4() with pytest.raises(ValueError): - window.ctx.projection_2d_matrix = "moo" + window.ctx.projection_matrix = "moo" def test_point_size(ctx): diff --git a/tests/unit/gl/test_opengl_query.py b/tests/unit/gl/test_opengl_query.py index bd225c8ef..e024b266c 100644 --- a/tests/unit/gl/test_opengl_query.py +++ b/tests/unit/gl/test_opengl_query.py @@ -6,7 +6,6 @@ def test_create(window: arcade.Window): ctx = window.ctx SCREEN_WIDTH, SCREEN_HEIGHT = window.get_size() - # print(ctx.viewport, ctx.projection_2d) program = ctx.program( vertex_shader=""" diff --git a/tests/unit/gui/test_ninepatch_draw.py b/tests/unit/gui/test_ninepatch_draw.py index 58656bca1..e2db8e0b0 100644 --- a/tests/unit/gui/test_ninepatch_draw.py +++ b/tests/unit/gui/test_ninepatch_draw.py @@ -2,6 +2,7 @@ from typing import Tuple import pytest import arcade +from pyglet.math import Mat4 from arcade.gui import NinePatchTexture from PIL import Image, ImageDraw @@ -67,7 +68,7 @@ def test_draw(ctx, fbo, left, right, bottom, top): ) with fbo.activate(): fbo.clear() - ctx.projection_2d = (0, PATCH_SIZE[0], 0, PATCH_SIZE[1]) + ctx.projection_matrix = Mat4.orthogonal_projection(0, PATCH_SIZE[0], 0, PATCH_SIZE[1], -100, 100) patch.draw_sized( size=PATCH_SIZE, position=(0, 0), diff --git a/tests/unit/gui/test_uimanager_camera.py b/tests/unit/gui/test_uimanager_camera.py index d2ecb8b65..c073100cb 100644 --- a/tests/unit/gui/test_uimanager_camera.py +++ b/tests/unit/gui/test_uimanager_camera.py @@ -9,31 +9,33 @@ def test_ui_manager_respects_camera_viewport(uimanager, window): # GIVEN uimanager.use_super_mouse_adjustment = True - camera = arcade.Camera(viewport=(0, 0, window.width, window.height), window=window) + camera = arcade.camera.Camera2D(position=(0.0, 0.0), projection=(0.0, window.width, 0.0, window.height), + window=window) # WHEN - camera.viewport = 0, 0, 300, 200 + camera.viewport = 0, 0, 400, 200 camera.use() uimanager.click(100, 100) # THEN assert isinstance(uimanager.last_event, UIMouseReleaseEvent) - assert uimanager.last_event.pos == (200, 200) + assert uimanager.last_event.pos == (pytest.approx(200), pytest.approx(300)) + @pytest.mark.xfail def test_ui_manager_respects_camera_pos(uimanager, window): # GIVEN uimanager.use_super_mouse_adjustment = True - camera = arcade.Camera(viewport=(0, 0, window.width, window.height), window=window) + camera = arcade.camera.Camera2D(position=(0.0, 0.0), projection=(0.0, window.width, 0.0, window.height), + window=window) # WHEN - camera.position = Vec2(-100, -100) - camera.update() + camera.position = (100, 100) camera.use() uimanager.click(100, 100) # THEN assert isinstance(uimanager.last_event, UIMouseReleaseEvent) - assert uimanager.last_event.pos == (200, 200) + assert uimanager.last_event.pos == (pytest.approx(200), pytest.approx(200)) diff --git a/tests/unit/texture/test_texture_transform_render.py b/tests/unit/texture/test_texture_transform_render.py index 1a72ccc3f..0de33d663 100644 --- a/tests/unit/texture/test_texture_transform_render.py +++ b/tests/unit/texture/test_texture_transform_render.py @@ -3,6 +3,7 @@ """ import arcade import pytest +from pyglet.math import Mat4 from PIL import Image, ImageDraw from arcade.texture.transforms import ( Transform, @@ -47,7 +48,7 @@ def test_rotate90_transform(ctx: arcade.ArcadeContext, image, transform, pil_tra sprite = arcade.Sprite(texture, center_x=image.width // 2, center_y=image.height // 2) with fbo.activate(): fbo.clear() - ctx.projection_2d = (0, image.width, 0, image.height) + ctx.projection_matrix = Mat4.orthogonal_projection(0, image.width, 0, image.height, -100, 100) sprite.draw(pixelated=True) expected_image = image.transpose(pil_transform) diff --git a/tests/unit/window/test_window.py b/tests/unit/window/test_window.py index 000375b5d..ff5c3774c 100644 --- a/tests/unit/window/test_window.py +++ b/tests/unit/window/test_window.py @@ -37,10 +37,10 @@ def test_window(window: arcade.Window): w.set_mouse_visible(True) w.set_size(width, height) - v = window.get_viewport() + v = window.ctx.viewport assert v[0] == 0 - assert v[1] == width - assert v[2] == 0 + assert v[1] == 0 + assert v[2] == width assert v[3] == height factor = window.get_pixel_ratio() diff --git a/util/update_quick_index.py b/util/update_quick_index.py index 807cd4b90..351995a1f 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -15,7 +15,6 @@ titles = { 'application.py': ['Window and View', 'window.rst'], 'shape_list.py': ['Shape Lists', 'drawing_batch.rst'], - 'camera.py': ['Camera', 'camera.rst'], 'context.py': ['OpenGL Context', 'open_gl.rst'], 'drawing_support.py': ['Drawing - Utility', 'drawing_utilities.rst'], 'draw_commands.py': ['Drawing - Primitives', 'drawing_primitives.rst'], @@ -51,6 +50,7 @@ 'texture/spritesheet.py': ['Texture Management', 'texture.rst'], 'texture/tools.py': ['Texture Management', 'texture.rst'], 'texture/transforms.py': ['Texture Transforms', 'texture_transforms.rst'], + 'camera/camera_2d.py': ['Camera 2D', 'camera_2d.rst'], 'texture_atlas/__init__.py': ['Texture Atlas', 'texture_atlas.rst'], 'texture_atlas/base.py': ['Texture Atlas', 'texture_atlas.rst'], 'texture_atlas/atlas_2d.py': ['Texture Atlas', 'texture_atlas.rst'], @@ -216,6 +216,7 @@ def process_directory(directory: Path, quick_index_file): "math.py": "arcade.math", "earclip.py": "arcade.earclip", "shape_list.py": "arcade.shape_list", + "camera": "arcade.camera" } package = mapping.get(path.name, None) or mapping.get(directory.name, None)