diff --git a/doc/builtins.rst b/doc/builtins.rst index 4ac0e0fd..b8e655df 100644 --- a/doc/builtins.rst +++ b/doc/builtins.rst @@ -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 diff --git a/src/pgzero/actor.py b/src/pgzero/actor.py index e8697e9f..0bc0e8fb 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -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: @@ -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 @@ -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) diff --git a/test/test_actor.py b/test/test_actor.py index b74410c4..55dd3969 100644 --- a/test/test_actor.py +++ b/test/test_actor.py @@ -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)