"""
Camera class
"""
import math
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from pyglet.math import Mat4, Vec2, Vec3
import arcade
if TYPE_CHECKING:
from arcade import Point, Sprite, SpriteList
# type aliases
FourIntTuple = Tuple[int, int, int, int]
FourFloatTuple = Tuple[float, float, float, float]
[docs]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])
# Matrixes
# 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 precompute 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)
[docs] 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])
def _set_projection_matrix(self, *, update_combined_matrix: bool = True) -> None:
"""
Helper method. This will just precompute the projection and combined matrix
:param bool update_combined_matrix: if True will also update the combined matrix (projection @ view)
"""
self._projection_matrix = Mat4.orthogonal_projection(*self._projection, -1, 1)
if update_combined_matrix:
self._set_combined_matrix()
def _set_view_matrix(self, *, update_combined_matrix: bool = True) -> None:
"""
Helper method. This will just precompute the view and combined matrix
:param bool 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 precompute the combined matrix"""
self._combined_matrix = self._view_matrix @ self._projection_matrix
[docs] 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 Vec2 vector: Vector to move the camera towards.
:param Vec2 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
[docs] 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)
[docs] def center(self, vector: Union[Vec2, tuple], speed: float = 1.0) -> None:
"""
Centers the camera on coordinates
"""
if not isinstance(vector, Vec2):
vector = Vec2(*vector)
# get the center of the camera viewport
center = Vec2(self.viewport_width / 2, self.viewport_height / 2)
# move to the vector substracting the center
target = vector - center
self.move_to(target, speed)
[docs] 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 Vec2 camera_vector: Vector captured from the camera viewport
"""
return Vec2(*self.position) + Vec2(*camera_vector)
[docs] 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 int viewport_width: Width of the viewport
:param int viewport_height: Height of the viewport
:param bool 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)
[docs] 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 alse set the combined matrix
[docs] 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.ctx.projection_2d_matrix = self._combined_matrix # sets projection position and zoom
[docs]class Camera(SimpleCamera):
"""
The Camera class is used for controlling the visible viewport, 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 tuple viewport: (left, bottom, width, height) size of the viewport. If None the window size will be used.
:param tuple projection: (left, right, bottom, top) size of the projection. If None the window size will be used.
:param float zoom: the zoom to apply to the projection
:param float rotation: the angle in degrees to rotate the projection
:param tuple anchor: the x, y point where the camera rotation will anchor. Default is the center of the viewport.
:param Window 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,
):
# Zoom
self._zoom: float = 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 precompute the rotaion matrix
self._set_rotation_matrix()
[docs] 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 precompute the projection and combined matrix
:param bool update_combined_matrix: if True will also update the combined matrix (projection @ view)
"""
# apply zoom
left, right, bottom, top = self._projection
if self._zoom != 1.0:
right *= self._zoom
top *= self._zoom
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 precompute the view and combined matrix
:param bool 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._zoom) / 2)),
(result_position[1] / ((self.viewport_height * self._zoom) / 2)),
0
)
self._view_matrix = ~(Mat4.from_translation(result_position) @ Mat4().scale(
Vec3(self._zoom, self._zoom, 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 zoom(self) -> float:
""" The zoom applied to the projection"""
return self._zoom
@zoom.setter
def zoom(self, zoom: float) -> None:
"""
Update the zoom 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._zoom = zoom
# Changing the 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 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()
[docs] 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 = arcade.get_distance(0, 0, ox, oy)
velocity_mag = arcade.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
[docs] def shake(self, velocity: Union[Vec2, tuple], speed: float = 1.5, damping: float = 0.9) -> None:
"""
Add a camera shake.
:param Vec2 velocity: Vector to start moving the camera
:param float speed: How fast to shake
:param float 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
[docs] def use(self) -> None:
"""
Select this camera for use. Do this right before you draw.
"""
super().use()
# set rotation matrix
self._window.ctx.view_matrix_2d = self._rotation_matrix # sets rotation and rotation anchor
[docs] 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: Point to check
:param SpriteList sprite_list: SpriteList to check against
:returns: List of sprites colliding, or an empty list.
:rtype: list
"""
raise NotImplementedError()