Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions doc/builtins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,39 @@ Remember that angles loop round, so 0 degrees == 360 degrees == 720 degrees.
Likewise -180 degrees == 180 degrees.


Scaling
'''''''

.. versionadded:: 1.3

The ``.dimensions`` attribute of an Actor controls the size of the sprite, in pixels.

The centre of scaling is the Actor's :ref:`anchor point <anchor>`.

Note that the dimensions are not identical to the ``width`` and ``height`` of
the Actor; if the Actor is rotated, ``actor.dimensions`` will be the size of
the image itself, along its "natural" axes, not the size of the bounding box
after rotation.

Flipping
''''''''

.. versionadded:: 1.3

The ``.flip_x()`` and ``.flip_y()`` methods of an Actor mirror the image in the
x and y directions respectively.

The image is flipped in both directions around the Actor's :ref:`anchor point <anchor>`.

Note that ``flip_x`` and ``flip_y`` toggle the current flipped state; calling::

actor.flip_x()
actor.flip_x()

will restore the original image. The flipped state can be accessed or set
directly via the ``.xflip`` and ``.yflip`` attributes.


Distance and angle to
'''''''''''''''''''''

Expand Down
31 changes: 31 additions & 0 deletions examples/basic/transforms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
WIDTH = 1024
HEIGHT = 800

ship = Actor('ship')
ship.pos = 200, 200


def draw():
screen.fill((255, 255, 255))
ship.draw()

def on_mouse_move(pos):
ship.pos = pos

def on_key_down(key):
if key == keys.LEFT:
ship.angle -= 10
elif key == keys.RIGHT:
ship.angle += 10
elif key == keys.UP:
x, y = ship.dimensions
ship.dimensions = x + 5, y + 5
elif key == keys.DOWN:
x, y = ship.dimensions
# pygamezero will raise an exception if we set negative dimensions.
if x > 5 and y > 5:
ship.dimensions = x - 5, y - 5
elif key == keys.SPACE:
ship.flip_x()
elif key == keys.TAB:
ship.flip_y()
196 changes: 146 additions & 50 deletions pgzero/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,29 +48,80 @@ def calculate_anchor(value, dim, total):
MAX_ALPHA = 255 # Based on pygame's max alpha.


def transform_anchor(ax, ay, w, h, angle):
"""Transform anchor based upon a rotation of a surface of size w x h."""
theta = -radians(angle)

sintheta = sin(theta)
costheta = cos(theta)
class BoundingBox:
"""Calculate bounding box and anchor transformations.

An Actor stores a sprite as a pygame surface with an anchor point and a
stack of transformations. This class pulls the code for the purely
geometric transformations out of the Actor class - the idea is to
initialize a BoundingBox with the surface's original dimensions, set an
anchor point, and then call pygame.transform.* to update the surface and
call the corresponding methods on the bounding box to update the Actor's
internal geometrical details.
"""

def __init__(self, width, height, anchor):
self.width = width
self.height = height
self.anchor = anchor

# Dims of the transformed rect
tw = abs(w * costheta) + abs(h * sintheta)
th = abs(w * sintheta) + abs(h * costheta)
def set_angle(self, angle):
"""Rotate the box and calculate the new height, width and anchor."""
theta = -radians(angle)
w, h = self.width, self.height
ax, ay = self.anchor

sintheta = sin(theta)
costheta = cos(theta)

# Dims of the transformed rect
tw = abs(w * costheta) + abs(h * sintheta)
th = abs(w * sintheta) + abs(h * costheta)

# Offset of the anchor from the center
cax = ax - w * 0.5
cay = ay - h * 0.5

# Rotated offset of the anchor from the center
rax = cax * costheta - cay * sintheta
ray = cax * sintheta + cay * costheta

# Update the bounding box
self.width = tw
self.height = th
self.anchor = (
tw * 0.5 + rax,
th * 0.5 + ray
)

# Offset of the anchor from the center
cax = ax - w * 0.5
cay = ay - h * 0.5
def set_dimensions(self, dimensions):
w, h = dimensions
ax, ay = self.anchor
xscale = (1.0 * w) / self.width
yscale = (1.0 * h) / self.height
self.width = w
self.height = h
self.anchor = (ax * xscale, ay * yscale)

def set_flip(self, xflip, yflip):
ax, ay = self.anchor
if xflip:
ax = self.width - ax
if yflip:
ay = self.height - ay
self.anchor = ax, ay


def _set_dimensions(actor, current_surface):
if actor.dimensions == (actor._orig_width, actor._orig_height):
return current_surface
return pygame.transform.scale(current_surface, actor._dimensions)

# Rotated offset of the anchor from the center
rax = cax * costheta - cay * sintheta
ray = cax * sintheta + cay * costheta

return (
tw * 0.5 + rax,
th * 0.5 + ray
)
def _set_flip(actor, current_surface):
if not (actor._xflip or actor._yflip):
return current_surface
return pygame.transform.flip(current_surface, actor._xflip, actor._yflip)


def _set_angle(actor, current_surface):
Expand Down Expand Up @@ -104,10 +155,12 @@ class Actor:
a for a in dir(rect.ZRect) if not a.startswith("_")
]

function_order = [_set_opacity, _set_angle]
function_order = [_set_opacity, _set_dimensions, _set_flip, _set_angle]
_anchor = _anchor_value = (0, 0)
_angle = 0.0
_opacity = 1.0
_xflip = False
_yflip = False

def _build_transformed_surf(self):
cache_len = len(self._surface_cache)
Expand All @@ -125,9 +178,10 @@ def __init__(self, image, pos=POS_TOPLEFT, anchor=ANCHOR_CENTER, **kwargs):
self._handle_unexpected_kwargs(kwargs)

self._surface_cache = []
self.__dict__["_rect"] = rect.ZRect((0, 0), (0, 0))
# Initialise it at (0, 0) for size (0, 0).

# Initialise rect at (0, 0) for size (0, 0).
# We'll move it to the right place and resize it later
self.__dict__["_rect"] = rect.ZRect((0, 0), (0, 0))

self.image = image
self._init_position(pos, anchor, **kwargs)
Expand Down Expand Up @@ -227,18 +281,33 @@ def anchor(self):
@anchor.setter
def anchor(self, val):
self._anchor_value = val
self._calc_anchor()
self._update_orig_anchor()
self._update_box()

def _calc_anchor(self):
def _update_orig_anchor(self):
ax, ay = self._anchor_value
ow, oh = self._orig_surf.get_size()
ax = calculate_anchor(ax, 'x', ow)
ay = calculate_anchor(ay, 'y', oh)
self._untransformed_anchor = ax, ay
if self._angle == 0.0:
self._anchor = self._untransformed_anchor
else:
self._anchor = transform_anchor(ax, ay, ow, oh, self._angle)
ax = calculate_anchor(ax, 'x', self._orig_width)
ay = calculate_anchor(ay, 'y', self._orig_height)
self._orig_anchor = ax, ay

def _update_box(self):
b = BoundingBox(
self._orig_width,
self._orig_height,
self._orig_anchor)
# This order of operations must be preserved
b.set_dimensions(self._dimensions)
b.set_flip(self._xflip, self._yflip)
b.set_angle(self._angle)

# Copy the transformed height and width
self.height = b.height
self.width = b.width

# Now move the topleft so that the anchor stays in position
p = self.pos
self._anchor = b.anchor
self.pos = p

@property
def angle(self):
Expand All @@ -247,19 +316,45 @@ def angle(self):
@angle.setter
def angle(self, angle):
self._angle = angle
w, h = self._orig_surf.get_size()

ra = radians(angle)
sin_a = sin(ra)
cos_a = cos(ra)
self.height = abs(w * sin_a) + abs(h * cos_a)
self.width = abs(w * cos_a) + abs(h * sin_a)
ax, ay = self._untransformed_anchor
p = self.pos
self._anchor = transform_anchor(ax, ay, w, h, angle)
self.pos = p
self._update_box()
self._update_transform(_set_angle)

@property
def dimensions(self):
return self._dimensions

@dimensions.setter
def dimensions(self, dimensions):
self._dimensions = dimensions
self._update_box()
self._update_transform(_set_dimensions)

@property
def xflip(self):
return self._xflip

@xflip.setter
def xflip(self, flipped):
self._xflip = flipped
self._update_box()
self._update_transform(_set_flip)

@property
def yflip(self):
return self._yflip

@yflip.setter
def yflip(self, flipped):
self._yflip = flipped
self._update_box()
self._update_transform(_set_flip)

def flip_x(self):
self.xflip = not self.xflip

def flip_y(self):
self.yflip = not self.yflip

@property
def opacity(self):
"""Get/set the current opacity value.
Expand Down Expand Up @@ -319,13 +414,14 @@ def image(self, image):
self._image_name = image
self._orig_surf = loaders.images.load(image)
self._surface_cache.clear() # Clear out old image's cache.
self._update_pos()

def _update_pos(self):
p = self.pos
self.width, self.height = self._orig_surf.get_size()
self._calc_anchor()
self.pos = p
self._update_orig()
self._dimensions = self._orig_surf.get_size()
self._update_box()

def _update_orig(self):
"""Set original properties based on the image dimensions."""
self._orig_width, self._orig_height = self._orig_surf.get_size()
self._update_orig_anchor()

def draw(self):
s = self._build_transformed_surf()
Expand Down
23 changes: 23 additions & 0 deletions test/test_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,26 @@ def test_dir_correct(self):
a = Actor("alien")
for attribute in dir(a):
a.__getattr__(attribute)

def test_intitial_dimensions(self):
a = Actor("alien")
self.assertEqual(a.width, 66)
self.assertEqual(a.height, 92)
self.assertEqual(a.dimensions, (66, 92))

def test_multiple_transforms(self):
a = Actor("alien")
self.assertEqual(a.image, "alien")
self.assertEqual(a.topleft, (0, 0))
self.assertEqual(a.pos, (33.0, 46.0))
self.assertEqual(a.width, 66)
self.assertEqual(a.height, 92)
a.angle += 90.0
x, y = a.dimensions
a.dimensions = (x * 2, y * 2)
a.flip_y()
self.assertEqual(a.angle, 90.0)
self.assertEqual(a.topleft, (-59.0, -20.0))
self.assertEqual(a.pos, (33.0, 46.0))
self.assertEqual(a.width, 184)
self.assertEqual(a.height, 132)
Loading