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
56 changes: 56 additions & 0 deletions doc/builtins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,62 @@ Actors or ``(x, y)`` coordinate pairs.
* Down is -90 degrees.


Velocity and interception
'''''''''''''''''''''''''

In many games, there are lots of things only ever moving along straight lines.
This means they always move with a constant velocity in the X and Y axes, for
which Actors have properties:

* ``.vx`` and ``.vy`` represent the velocity in either axis direction.
* ``.vel`` represents the velocity in both axes as a tuple.

Just like ``.x``, ``.y`` and ``.pos``, these can be read or set individually
or together.

Once an Actor has a velocity, moving them along it is easy.

.. method:: Actor.move_by_vel([scale])

Moves the Actors position by its velocity once. Calling this every
``update()`` will smoothly move the actor along its velocity trajectory.

If the Actor should be moved at the same angle but different speed, the
function can be given an optional parameter. ``2.0`` would move twice as
fast, whereas ``0.1`` would move at 10% of the speed.

Almost as often, we might also want to have an object lead its trajectory
to intercept some other game object which is also moving. There is also a
dedicated function for this.

.. method:: Actor.intercept_velocity(target, speed)

Returns a velocity tuple that will move from the position of the Actor
to intercept the target Actor with the given speed, as long as the
target does not change its velocity at some point.

The target Actor must have its velocity set for this to work. If no
valid interception is possible (because the speed is too low), ``None`` is
returned.

To move an Actor to intercept a target, we can simply set its velocity to the
calculated interception velocity.::

catcher = Actor("catcher", (40, 20))
catcher.vel = (3, -1)
ball = Actor("ball", (10, 10))
ball.vel = ball.intercept_velocity(catcher, 12.0)

def update():
catcher.move_by_vel()
ball.move_by_vel()

def draw()
screen.clear()
catcher.draw()
ball.draw()


.. _transparency:

Transparency
Expand Down
85 changes: 85 additions & 0 deletions src/pgzero/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ def __init__(self, image, pos=POS_TOPLEFT, anchor=ANCHOR_CENTER, **kwargs):

self.image = image
self._init_position(pos, anchor, **kwargs)
self._vx = 0
self._vy = 0

def __getattr__(self, attr):
if attr in self.__class__.DELEGATED_ATTRIBUTES:
Expand Down Expand Up @@ -318,6 +320,43 @@ def y(self):
def y(self, py):
self.top = py - self._anchor[1]

@property
def vx(self):
return self._vx

@vx.setter
def vx(self, value):
if isinstance(value, (int, float)):
self._vx = value
else:
raise TypeError("Velocity components must be integers or floats,"
" not {}.".format(type(value)))

@property
def vy(self):
return self._vy

@vy.setter
def vy(self, value):
if isinstance(value, (int, float)):
self._vy = value
else:
raise TypeError("Velocity components must be integers or floats,"
" not {}.".format(type(value)))

@property
def vel(self):
return (self._vx, self._vy)

@vel.setter
def vel(self, value):
if isinstance(value, tuple) and len(value) == 2:
self._vx = value[0]
self._vy = value[1]
else:
raise TypeError("Velocity must be set to a tuple of two numbers,"
" not {}.".format(value))

@property
def image(self):
return self._image_name
Expand Down Expand Up @@ -361,5 +400,51 @@ def distance_to(self, target):
dy = ty - myy
return sqrt(dx * dx + dy * dy)

def move_by_vel(self, scale=1.0):
"""Moves the position of the actor by its velocity. scale can be set
to slow down or quicken the movement, for example if the game's
timescale is not 1."""
if not isinstance(scale, (int, float)):
raise TypeError(f"The velocity scaling must be of type integer or"
" float, not {type(scale)}.")
self.x += self._vx * scale
self.y += self._vy * scale

def intercept_velocity(self, target, speed):
"""Returns a vector with the given magnitude (movement speed) that will
intercept the target actor or point if it keeps moving along the same
direction."""
# Convert values to pygame vectors for easier math.
self_pos = pygame.math.Vector2(self.pos)
target_pos = pygame.math.Vector2(target.pos)
target_vel = pygame.math.Vector2(target.vel)

totarget_vec = target_pos - self_pos

a = target_vel.dot(target_vel) - speed**2
b = 2 * target_vel.dot(totarget_vec)
c = totarget_vec.dot(totarget_vec)

try:
p = -b / (2 * a)
q = sqrt((b * b) - 4 * a * c) / (2 * a)
except Exception:
return None

time1 = p - q
time2 = p + q

# Choose the correct intercept option.
if time1 > time2 and time2 > 0:
intercept_time = time2
else:
intercept_time = time1

intercept_point = target_pos + target_vel * intercept_time
intercept_vec = (intercept_point - self_pos).normalize() * speed

# Since Vector2s aren't used in pgzero directly, return as a tuple.
return tuple(intercept_vec)

def unload_image(self):
loaders.images.unload(self._image_name)
49 changes: 49 additions & 0 deletions test/test_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,52 @@ def test_dir_correct(self):
a = Actor("alien")
for attribute in dir(a):
a.__getattr__(attribute)

def test_velocity_starts_at_Zero(self):
"""An Actors velocity starts at zero in both axes."""
a = Actor("alien")
self.assertEqual(a.vel, (0, 0))

def test_velocity_components(self):
"""We can use the Actors velocity by individual components."""
a = Actor("alien")
a.vx = 15
a.vy = -5
self.assertEqual(a.vx, 15)
self.assertEqual(a.vy, -5)
self.assertEqual(a.vel, (15, -5))

def test_velocity_together(self):
"""We can use the Actors velocity as a tuple."""
a = Actor("alien")
a.vel = (15, -5)
self.assertEqual(a.vx, 15)
self.assertEqual(a.vy, -5)
self.assertEqual(a.vel, (15, -5))

def test_move_by_vel(self):
"""We can move an actor by its velocity."""
a = Actor("alien", (10, 10))
a.vel = (15, -5)
a.move_by_vel()
self.assertEqual(a.pos, (25, 5))

def test_interception_velocity(self):
"""We can get a valid interception vector from a starting Actor to
a moving target Actor."""
a = Actor("alien", (0, 10))
b = Actor("alien", (10, 0))
b.vy = 5
# Due to floating point inaccuracy, if we simply give 5 as the speed,
# no intersection will be found even though it should be.
a.vel = a.intercept_velocity(b, 5.0001)
# For the same reason, the result must be rounded to compare.
self.assertEqual((round(a.vx), round(a.vy)), (5, 0))

def test_no_interception(self):
"""If no valid interception vector exists, None is returned."""
a = Actor("alien", (0, 10))
b = Actor("alien", (10, 0))
b.vy = 5
v = a.intercept_velocity(b, 1)
self.assertIsNone(v)