From de6ae13e09033731f3d2cfac010fb86813c0568b Mon Sep 17 00:00:00 2001 From: Mambouna Date: Sat, 13 Sep 2025 15:36:08 +0200 Subject: [PATCH 1/6] Added an intercept function on actors. --- src/pgzero/actor.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/pgzero/actor.py b/src/pgzero/actor.py index e8697e9f..2cdf07fe 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -33,6 +33,9 @@ def calculate_anchor(value, dim, total): ) return float(value) +def dot_product(vector1, vector2): + return vector1[0] * vector1[1] + vector2[0] * vector2[1] + # These are methods (of the same name) on pygame.Rect SYMBOLIC_POSITIONS = set(( @@ -361,5 +364,43 @@ def distance_to(self, target): dy = ty - myy return sqrt(dx * dx + dy * dy) + def intercept(self, target, target_move_vector, 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.""" + self_pos = pygame.math.Vector2(self.pos) + if isinstance(target, Actor): # If target is an actor, use its pos. + target_pos = pygame.math.Vector2(target.pos) + else: # If it is a tuple, use it directly. + target_pos = pygame.math.Vector2(target) + target_vel = pygame.math.Vector2(target_move_vector) + + 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) + + p = -b / (2 * a) + q = sqrt((b * b) - 4 * a * c) / (2 * a) + + time1 = p - q + time2 = p + q + + if isinstance(time1, complex) or isinstance(time2, complex): + return None # If there is no valid intercept solution, return None. + + # Otherwise, 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) From 0b3fdafd76cb7e898726c74323d07ac48e47e516 Mon Sep 17 00:00:00 2001 From: Mambouna Date: Sat, 13 Sep 2025 16:09:15 +0200 Subject: [PATCH 2/6] Added actor velocity properties. --- src/pgzero/actor.py | 54 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/pgzero/actor.py b/src/pgzero/actor.py index 2cdf07fe..950e1eeb 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -134,6 +134,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: @@ -321,6 +323,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 {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 {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(f"Velocity must be set to a tuple of two numbers," + " not {value}.") + @property def image(self): return self._image_name @@ -364,16 +403,27 @@ def distance_to(self, target): dy = ty - myy return sqrt(dx * dx + dy * dy) - def intercept(self, target, target_move_vector, speed): + 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_vector(self, target, speed, target_move_vector=(0, 0)): """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.""" self_pos = pygame.math.Vector2(self.pos) if isinstance(target, Actor): # If target is an actor, use its pos. target_pos = pygame.math.Vector2(target.pos) + target_vel = pygame.math.Vector2(target.vel) else: # If it is a tuple, use it directly. target_pos = pygame.math.Vector2(target) - target_vel = pygame.math.Vector2(target_move_vector) + target_vel = pygame.math.Vector2(target_move_vector) totarget_vec = target_pos - self_pos From 7bf03eb7381eeec8fef5253929f4b220538a7516 Mon Sep 17 00:00:00 2001 From: Mambouna Date: Sat, 13 Sep 2025 17:37:52 +0200 Subject: [PATCH 3/6] Added documentation and unittests. --- doc/builtins.rst | 56 +++++++++++++++++++++++++++++++++++++++++++++ src/pgzero/actor.py | 29 ++++++++++++----------- test/test_actor.py | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 15 deletions(-) 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 950e1eeb..0f872382 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -333,7 +333,7 @@ def vx(self, value): self._vx = value else: raise TypeError("Velocity components must be integers or floats," - " not {type(value)}.") + " not {}.".format(type(value))) @property def vy(self): @@ -345,7 +345,7 @@ def vy(self, value): self._vy = value else: raise TypeError("Velocity components must be integers or floats," - " not {type(value)}.") + " not {}.".format(type(value))) @property def vel(self): @@ -357,8 +357,8 @@ def vel(self, value): self._vx = value[0] self._vy = value[1] else: - raise TypeError(f"Velocity must be set to a tuple of two numbers," - " not {value}.") + raise TypeError("Velocity must be set to a tuple of two numbers," + " not {}.".format(value)) @property def image(self): @@ -413,17 +413,13 @@ def move_by_vel(self, scale=1.0): self.x += self._vx * scale self.y += self._vy * scale - def intercept_vector(self, target, speed, target_move_vector=(0, 0)): + 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.""" self_pos = pygame.math.Vector2(self.pos) - if isinstance(target, Actor): # If target is an actor, use its pos. - target_pos = pygame.math.Vector2(target.pos) - target_vel = pygame.math.Vector2(target.vel) - else: # If it is a tuple, use it directly. - target_pos = pygame.math.Vector2(target) - target_vel = pygame.math.Vector2(target_move_vector) + target_pos = pygame.math.Vector2(target.pos) + target_vel = pygame.math.Vector2(target.vel) totarget_vec = target_pos - self_pos @@ -431,14 +427,17 @@ def intercept_vector(self, target, speed, target_move_vector=(0, 0)): b = 2 * target_vel.dot(totarget_vec) c = totarget_vec.dot(totarget_vec) - p = -b / (2 * a) - q = sqrt((b * b) - 4 * a * c) / (2 * a) + try: + p = -b / (2 * a) + q = sqrt((b * b) - 4 * a * c) / (2 * a) + except Exception: + return None time1 = p - q time2 = p + q - if isinstance(time1, complex) or isinstance(time2, complex): - return None # If there is no valid intercept solution, return None. + #if isinstance(time1, complex) or isinstance(time2, complex): + # return None # If there is no valid intercept solution, return None. # Otherwise, choose the correct intercept option. if time1 > time2 and time2 > 0: 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) From bfe9ed96dc134166455a122949f43bcb91aa4cc3 Mon Sep 17 00:00:00 2001 From: Mambouna Date: Sat, 13 Sep 2025 17:46:07 +0200 Subject: [PATCH 4/6] Removed custom dot function. --- src/pgzero/actor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pgzero/actor.py b/src/pgzero/actor.py index 0f872382..acc0b2d6 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -33,9 +33,6 @@ def calculate_anchor(value, dim, total): ) return float(value) -def dot_product(vector1, vector2): - return vector1[0] * vector1[1] + vector2[0] * vector2[1] - # These are methods (of the same name) on pygame.Rect SYMBOLIC_POSITIONS = set(( From da27342a462a6cbdcb838bb899227e409d172862 Mon Sep 17 00:00:00 2001 From: Mambouna Date: Sat, 13 Sep 2025 17:48:28 +0200 Subject: [PATCH 5/6] Removed outdated code to catch complex results. --- src/pgzero/actor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pgzero/actor.py b/src/pgzero/actor.py index acc0b2d6..0c8e9758 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -433,9 +433,6 @@ def intercept_velocity(self, target, speed): time1 = p - q time2 = p + q - #if isinstance(time1, complex) or isinstance(time2, complex): - # return None # If there is no valid intercept solution, return None. - # Otherwise, choose the correct intercept option. if time1 > time2 and time2 > 0: intercept_time = time2 From c2b88ef8aaba2333e847f7dc270f9b7ecae7656e Mon Sep 17 00:00:00 2001 From: Mambouna Date: Sat, 13 Sep 2025 17:49:40 +0200 Subject: [PATCH 6/6] Adjusted comments --- src/pgzero/actor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pgzero/actor.py b/src/pgzero/actor.py index 0c8e9758..0bc0e8fb 100644 --- a/src/pgzero/actor.py +++ b/src/pgzero/actor.py @@ -414,6 +414,7 @@ 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) @@ -433,7 +434,7 @@ def intercept_velocity(self, target, speed): time1 = p - q time2 = p + q - # Otherwise, choose the correct intercept option. + # Choose the correct intercept option. if time1 > time2 and time2 > 0: intercept_time = time2 else: