Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Camera Redesign #439

Merged
merged 13 commits into from
Apr 27, 2020
18 changes: 18 additions & 0 deletions docs/reference/camera.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.. py:currentmodule:: ppb.camera

Camera
=============

.. automodule:: ppb.camera


.. autoclass:: Camera
:members:


.. warning::
Setting the game unit dimensions of a camera (whether via
:attr:`Camera.width`, :attr:`Camera.height`, or the
``target_game_unit_width`` of the :class:`Camera` constructor) will
affect both :attr:`Camera.width` and :attr:`Camera.height`. Their ratio
is determined by the defined window.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might need more prose to discuss camera initialization and when scenes can expect to have the camera.

1 change: 1 addition & 0 deletions docs/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ decisions are made, see the discussion section.
sprites
engine
sound
camera
features/index
240 changes: 153 additions & 87 deletions ppb/camera.py
Original file line number Diff line number Diff line change
@@ -1,115 +1,181 @@
from typing import Sequence
from numbers import Number
"""
:class:`Cameras <Camera>` are objects that straddle the line between game space
and screen space. The renderer uses the position of the camera to translate
:class:`Sprite's <ppb.Sprite>` positions to the screen in order to make them
visible.

The :class:`~ppb.systems.Renderer` inserts a :class:`Camera` into the current
scene in response to the :class:`~ppb.events.SceneStarted`.
"""
from typing import Tuple
from numbers import Real

from ppb_vector import Vector
from ppb.sprites import Sprite
from ppb.flags import DoNotRender


class Camera(Sprite):
class Camera:
"""
A simple Camera.

image = DoNotRender
Intentionally tightly coupled to the renderer to allow information flow
back and forth.

def __init__(self, viewport: Sequence[int] = (0, 0, 800, 600),
pixel_ratio: float = 64):
"""
There is a one-to-one relationship between cameras and scenes.

You can subclass Camera to add event handlers. If you do so, set the
camera_class class attribute of your scene to your subclass. The renderer
will instantiate the correct type.
"""
position = Vector(0, 0)
size = 0 # Cameras never render, so their logical game unit size is 0

viewport: A container of origin x, origin y, width, and
height. The origin is the top left point of the viewport
measured from the top left point of the window or screen.
The width and height are the raw pixel measurements of the
viewport.
pixel_ratio: A number defining the pixel to game unit ratio. Divide
the viewport dimensions by the pixel ratio to get the
frame in game unit terms.
def __init__(self, renderer, target_game_unit_width: Real,
viewport_dimensions: Tuple[int, int]):
"""
You shouldn't instantiate your own camera in general. If you want to
override the Camera, see above.

:param renderer: The renderer associated with the camera.
:type renderer: ~ppb.systems.renderer.Renderer
:param target_game_unit_width: The number of game units wide you
would like to display. The actual width may be less than this
depending on the ratio to the viewport (as it can only be as wide
as there are pixels.)
:type target_game_unit_width: Real
:param viewport_dimensions: The pixel dimensions of the rendered
viewport in (width, height) form.
:type viewport_dimensions: Tuple[int, int]
"""
super().__init__(size=0)
# Cameras don't take up game space, thus size 0.
self.position = Vector(0, 0)
self.viewport_origin = Vector(viewport[0], viewport[1])
self._viewport_width = viewport[2]
self._viewport_height = viewport[3]
self.viewport_offset = Vector(self.viewport_width / 2,
self.viewport_height / 2)
self.pixel_ratio = pixel_ratio
self.renderer = renderer
self.target_game_unit_width = target_game_unit_width
self.viewport_dimensions = viewport_dimensions
self.pixel_ratio = None
self._width = None
self._height = None
self._set_dimensions(target_width=target_game_unit_width)

@property
def frame_top(self) -> Number:
return self.position.y + self.half_height
def width(self) -> Real:
"""
The game unit width of the viewport.

@property
def frame_bottom(self) -> Number:
return self.position.y - self.half_height
See :mod:`ppb.sprites` for details about game units.

@property
def frame_left(self) -> Number:
return self.position.x - self.half_width
When setting this property, the resulting width may be slightly
different from the value provided based on the ratio between the width
of the window in screen pixels and desired number of game units to
represent.

When you set the width, the height will change as well.
"""
return self._width

@width.setter
def width(self, target_width):
self._set_dimensions(target_width=target_width)

@property
def frame_right(self) -> Number:
return self.position.x + self.half_width
def height(self) -> Real:
"""
The game unit height of the viewport.

See :mod:`ppb.sprites` for details about game units.

When setting this property, the resulting height may be slightly
different from the value provided based on the ratio between the height
of the window in screen pixels and desired number of game units to
represent.

When you set the height, the width will change as well.
"""
return self._height

@height.setter
def height(self, target_height):
self._set_dimensions(target_height=target_height)

def point_is_visible(self, point: Vector) -> bool:
"""
Determine if a given point is in view of the camera.

:param point: A vector representation of a point in game units.
:type point: Vector
:return: Whether the point is in view or not.
:rtype: bool
"""
return (
self.left <= point.x <= self.right
and self.bottom <= point.y <= self.top
)

def translate_point_to_screen(self, point: Vector) -> Vector:
"""
Convert a vector from game position to screen position.

:param point: A vector in game units
:type point: Vector
:return: A vector in pixels.
:rtype: Vector
"""
return Vector(point.x - self.left, self.top - point.y) * self.pixel_ratio

def translate_point_to_game_space(self, point: Vector) -> Vector:
"""
Convert a vector from screen position to game position.

:param point: A vector in pixels
:type point: Vector
:return: A vector in game units.
:rtype: Vector
"""
scaled = point / self.pixel_ratio
return Vector(self.left + scaled.x, self.top - scaled.y)

@property
def frame_height(self) -> float:
return self.viewport_height / self.pixel_ratio
def bottom(self):
return self.position.y - (self.height / 2)

@property
def frame_width(self) -> float:
return self.viewport_width / self.pixel_ratio
def left(self):
return self.position.x - (self.width / 2)

@property
def half_height(self) -> float:
return self.frame_height / 2
def right(self):
return self.position.x + (self.width / 2)

@property
def half_width(self) -> float:
return self.frame_width / 2
def top(self):
return self.position.y + (self.height / 2)

@property
def viewport_width(self) -> int:
return self._viewport_width
def top_left(self):
return Vector(self.left, self.top)

@viewport_width.setter
def viewport_width(self, value: int):
self._viewport_width = value
self.viewport_offset = Vector(value / 2, self.viewport_height / 2)
@property
def top_right(self):
return Vector(self.right, self.top)

@property
def viewport_height(self) -> int:
return self._viewport_height

@viewport_height.setter
def viewport_height(self, value: int):
self._viewport_height = value
self.viewport_offset = Vector(self.viewport_width / 2, value / 2)

def point_in_viewport(self, point: Vector) -> bool:
px, py = point
vpx, vpy = self.viewport_origin
vpw = self.viewport_width
vph = self.viewport_height
return vpx <= px <= (vpw+vpx) and vpy <= py <= (vph+vpy)

def in_frame(self, sprite: Sprite) -> bool:
return (self.frame_left <= sprite.right and
self.frame_right >= sprite.left and
self.frame_top >= sprite.bottom and
self.frame_bottom <= sprite.top
)

def translate_to_frame(self, point: Vector) -> Vector:
"""
Converts a vector from pixel-based window to in-game coordinate space
"""
# 1. Scale from pixels to game unites
scaled = point / self.pixel_ratio
# 2. Reposition relative to frame edges
return Vector(self.frame_left + scaled.x, self.frame_top - scaled.y)
def bottom_left(self):
return Vector(self.left, self.bottom)

def translate_to_viewport(self, point: Vector) -> Vector:
"""
Converts a vector from in-game to pixel-based window coordinate space
"""
# 1. Reposition based on frame edges
# 2. Scale from game units to pixels
return Vector(point.x - self.frame_left, self.frame_top - point.y) * self.pixel_ratio
@property
def bottom_right(self):
return Vector(self.right, self.bottom)

def _set_dimensions(self, target_width=None, target_height=None):
# Set new pixel ratio
viewport_width, viewport_height = self.viewport_dimensions
if target_width is not None and target_height is not None:
raise ValueError("Can only set one dimension at a time.")
elif target_width is not None:
game_unit_target = target_width
pixel_value = viewport_width
elif target_height is not None:
game_unit_target = target_height
pixel_value = viewport_height
else:
raise ValueError("Must set target_width or target_height")
self.pixel_ratio = int(pixel_value / game_unit_target)
self._width = viewport_width / self.pixel_ratio
self._height = viewport_height / self.pixel_ratio
11 changes: 7 additions & 4 deletions ppb/scenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,15 @@ class BaseScene:
# Background color, in RGB, each channel is 0-255
background_color: Sequence[int] = (0, 0, 100)
container_class: Type = GameObjectCollection
camera_class = Camera

def __init__(self, *,
set_up: Callable = None, pixel_ratio: Number = 64,
**kwargs):
set_up: Callable = None, **kwargs):
super().__init__()
for k, v in kwargs.items():
setattr(self, k, v)

self.game_objects = self.container_class()
self.main_camera = Camera(pixel_ratio=pixel_ratio)

if set_up is not None:
set_up(self)
Expand All @@ -129,7 +128,11 @@ def tags(self):

@property
def main_camera(self) -> Camera:
return next(self.game_objects.get(tag="main_camera"))
try:
camera = next(self.game_objects.get(tag="main_camera"))
except StopIteration:
camera = None
return camera

@main_camera.setter
def main_camera(self, value: Camera):
Expand Down
6 changes: 3 additions & 3 deletions ppb/systems/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def mouse_motion(self, event, scene):
motion = event.motion
screen_position = Vector(motion.x, motion.y)
camera = scene.main_camera
scene_position = camera.translate_to_frame(screen_position)
scene_position = camera.translate_point_to_game_space(screen_position)
delta = Vector(motion.xrel, motion.yrel) * (1/camera.pixel_ratio)
buttons = {
value
Expand All @@ -195,7 +195,7 @@ def button_pressed(self, event, scene):
button = event.button
screen_position = Vector(button.x, button.y)
camera = scene.main_camera
scene_position = camera.translate_to_frame(screen_position)
scene_position = camera.translate_point_to_game_space(screen_position)
btn = self.button_map.get(button.button)
if btn is not None:
return events.ButtonPressed(
Expand All @@ -208,7 +208,7 @@ def button_released(self, event, scene):
button = event.button
screen_position = Vector(button.x, button.y)
camera = scene.main_camera
scene_position = camera.translate_to_frame(screen_position)
scene_position = camera.translate_point_to_game_space(screen_position)
btn = self.button_map.get(button.button)
if btn is not None:
return events.ButtonReleased(
Expand Down
Loading