diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7fa468e..5c69be30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ### New Features - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. - Added the `sprite_pixel_demolition` example, a stress test where every pixel of the Arcade logo is a sprite (28,000, or 71,000 with smaller pixels) and exploding bullets knock them loose as debris. Debris can move as Python sprites or on the GPU with a shader (press G), which keeps 60 FPS with 15,000 pieces in flight. +- Added `arcade.sweep_line(start, end, sprite_list)`, which returns a `SweepInfo` for the first sprite a line hits (its `fraction`, `distance`, and surface `normal`), or `None`. It's `sweep_sprite` for a point: for lasers, hitscan weapons, or seeing what's in the way. Added the `sprite_laser_mirrors` example, where a laser bounces off rotating mirrors using the normal. ### Misc Changes - Sped up `arcade.draw_text()`. It no longer flushes OpenGL after every call (an unchanged call went from 76 to 15 µs), and several lines drawn in the same style each keep their own cached label instead of re-laying out one label every call (5 static lines went from 1.24 ms to 0.08 ms per frame). Any number of lines can share a style. A line whose text changes reuses its own label from the previous frame, found by the clock's tick count, instead of making a new one. diff --git a/arcade/__init__.py b/arcade/__init__.py index 18ce4c1a2..f4fe451f5 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -189,6 +189,7 @@ def configure_logging(level: int | None = None): from .sprite_list import check_for_collision from .sprite_list import get_collision_info from .sprite_list import get_collision_info_with_list +from .sprite_list import sweep_line from .sprite_list import sweep_sprite from .sprite_list import check_for_collision_with_list from .sprite_list import check_for_collision_with_lists @@ -340,6 +341,7 @@ def configure_logging(level: int | None = None): "check_for_collision", "get_collision_info", "get_collision_info_with_list", + "sweep_line", "sweep_sprite", "check_for_collision_with_list", "check_for_collision_with_lists", diff --git a/arcade/examples/sprite_laser_mirrors.py b/arcade/examples/sprite_laser_mirrors.py new file mode 100644 index 000000000..2e9e44f41 --- /dev/null +++ b/arcade/examples/sprite_laser_mirrors.py @@ -0,0 +1,152 @@ +""" +Lasers and Mirrors + +A laser shoots from the turret toward the mouse. It stops at the first crate +in its way, bounces off the rotating mirrors, and collects any coins it +touches. + +The laser doesn't move like a bullet: it reaches as far as it goes the moment +it's fired. Each straight part of the beam uses arcade.sweep_line() to find +the first sprite in its way. The hit's normal points out of the surface it +hit, which is all a mirror needs to reflect the beam. + +Artwork from https://kenney.nl + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.sprite_laser_mirrors +""" + +import random + +from pyglet.math import Vec2 + +import arcade + +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +WINDOW_TITLE = "Lasers and Mirrors Example" + +TURRET_POSITION = Vec2(WINDOW_WIDTH / 2, 40) +# How far the laser reaches, in total, after all its bounces +LASER_RANGE = 3000 +MAX_BOUNCES = 20 +COIN_COUNT = 12 + + +class GameView(arcade.View): + def __init__(self): + super().__init__() + self.background_color = arcade.color.DARK_SLATE_GRAY + + self.crates = arcade.SpriteList() + self.mirrors = arcade.SpriteList() + self.coins = arcade.SpriteList() + # Everything the laser can hit, so one sweep_line() call checks them all + self.obstacles = arcade.SpriteList() + + for x, y in ((250, 300), (1030, 300), (640, 560), (450, 520), (830, 520)): + crate = arcade.Sprite(":resources:images/tiles/boxCrate_double.png", 0.5, x, y) + self.crates.append(crate) + + positions = ((300, 150), (980, 150), (640, 350), (200, 620), (1080, 620), (640, 680)) + for i, (x, y) in enumerate(positions): + mirror = arcade.SpriteSolidColor(8, 110, x, y, arcade.color.LIGHT_CYAN) + mirror.angle = 45 if i % 2 else -45 + # Degrees per second + mirror.change_angle = random.choice((-20, -12, 12, 20)) + self.mirrors.append(mirror) + + self.obstacles.extend(self.crates) + self.obstacles.extend(self.mirrors) + for _ in range(COIN_COUNT): + self.add_coin() + + self.aim = Vec2(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2) + # The points the beam passes through, from the turret to where it stops + self.beam: list[Vec2] = [] + self.score = 0 + self.score_text = arcade.Text("", 10, 10, arcade.color.WHITE, 14) + self.help_text = arcade.Text( + "Aim with the mouse. Bounce the laser off the mirrors to collect the coins.", + 10, + WINDOW_HEIGHT - 30, + arcade.color.WHITE, + 14, + ) + + def add_coin(self): + """Put a coin somewhere it isn't touching a crate or a mirror""" + coin = arcade.Sprite(":resources:images/items/coinGold.png", 0.4) + while True: + coin.position = random.uniform(60, WINDOW_WIDTH - 60), random.uniform(120, 650) + if not arcade.check_for_collision_with_list(coin, self.obstacles): + break + self.coins.append(coin) + self.obstacles.append(coin) + + def fire_laser(self): + """Work out the beam's path, bouncing off mirrors""" + position = TURRET_POSITION + direction = (self.aim - TURRET_POSITION).normalize() + remaining = LASER_RANGE + self.beam = [position] + for _ in range(MAX_BOUNCES): + end = position + direction * remaining + hit = arcade.sweep_line(position, end, self.obstacles) + if hit is None: + self.beam.append(end) + return + position = position + direction * hit.distance + self.beam.append(position) + remaining -= hit.distance + + if hit.sprite in self.coins: + # Coins are collected, and the beam goes on + hit.sprite.remove_from_sprite_lists() + self.score += 1 + self.add_coin() + elif hit.sprite in self.mirrors: + # Reflect off the mirror's surface + direction = direction - 2 * direction.dot(hit.normal) * hit.normal + else: + # Crates stop the beam + return + + # Start the next part just off the surface, so rounding can't + # leave it inside the sprite it just hit + position = position + direction * 0.01 + + def on_update(self, delta_time): + for mirror in self.mirrors: + mirror.angle += mirror.change_angle * delta_time + self.fire_laser() + self.score_text.text = f"Coins: {self.score}" + + def on_draw(self): + self.clear() + self.crates.draw() + self.mirrors.draw() + self.coins.draw() + + # A wide, faint line under a thin, bright one looks like a glow + if len(self.beam) > 1: + arcade.draw_line_strip(self.beam, (255, 60, 60, 90), 9) + arcade.draw_line_strip(self.beam, arcade.color.WHITE, 2) + arcade.draw_circle_filled(*TURRET_POSITION, 18, arcade.color.GRAY) + + self.score_text.draw() + self.help_text.draw() + + def on_mouse_motion(self, x, y, dx, dy): + # Only aim upward, away from the turret's base + self.aim = Vec2(x, max(y, TURRET_POSITION.y + 1)) + + +def main(): + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + window.show_view(GameView()) + arcade.run() + + +if __name__ == "__main__": + main() diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index 069c01120..c29283fb7 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -9,6 +9,7 @@ check_for_collision, get_collision_info, get_collision_info_with_list, + sweep_line, sweep_sprite, check_for_collision_with_list, check_for_collision_with_lists, @@ -33,6 +34,7 @@ "check_for_collision", "get_collision_info", "get_collision_info_with_list", + "sweep_line", "sweep_sprite", "check_for_collision_with_list", "check_for_collision_with_lists", diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index b96420c17..402d5f32d 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from enum import IntEnum from math import hypot from typing import NamedTuple, TypeVar @@ -12,7 +12,7 @@ ) from arcade.math import get_distance from arcade.sprite import BasicSprite, SpriteType -from arcade.types import Point +from arcade.types import Point, Point2 from arcade.types.rect import LRBT, Rect from arcade.window_commands import get_window @@ -749,9 +749,33 @@ def _sweep_against( they already overlap at the start. The normal is a unit vector out of ``other``. Exact for convex hit boxes that don't rotate during the move. """ - hit_box1 = sprite._hit_box + hit_box = sprite._hit_box + return _sweep_shape( + hit_box.get_adjusted_points(), + hit_box.get_adjusted_bounds(), + hit_box._get_axes(), + other, + dx, + dy, + best_fraction, + ) + + +def _sweep_shape( + points1: Sequence[Point2], + bounds1: tuple[float, float, float, float], + axes1: dict[Point2, Point2], + other: BasicSprite, + dx: float, + dy: float, + best_fraction: float, +) -> tuple[float, float, float] | None: + """ + :py:func:`_sweep_against` for any convex shape moving by ``(dx, dy)``, + given its points, bounds ``(left, right, bottom, top)``, and the axes + of its edges. A single point has no axes, so this also sweeps a line. + """ hit_box2 = other._hit_box - points1 = hit_box1.get_adjusted_points() points2 = hit_box2.get_adjusted_points() if not points1 or not points2: return None @@ -766,7 +790,7 @@ def _sweep_against( normal_y = 0.0 # The y axis first, then x, then the other edges, so ties prefer them - left1, right1, bottom1, top1 = hit_box1.get_adjusted_bounds() + left1, right1, bottom1, top1 = bounds1 left2, right2, bottom2, top2 = hit_box2.get_adjusted_bounds() for min_1, max_1, min_2, max_2, speed, axis_x, axis_y in ( (bottom1, top1, bottom2, top2, dy, 0.0, 1.0), @@ -787,7 +811,7 @@ def _sweep_against( if enter >= leave or enter >= best_fraction or leave <= 0: return None - axes = hit_box1._get_axes() | hit_box2._get_axes() + axes = axes1 | hit_box2._get_axes() for axis_x, axis_y in axes.values(): projected_1 = [axis_x * px + axis_y * py for px, py in points1] projected_2 = [axis_x * px + axis_y * py for px, py in points2] @@ -942,6 +966,148 @@ def sweep_sprite( return SweepInfo(hit_sprite, fraction, fraction * hypot(dx, dy), Vec2(normal_x, normal_y)) +def _point_push_out(x: float, y: float, other: BasicSprite) -> tuple[float, Vec2]: + """ + For a point inside a sprite's hit box, how far it is from the nearest + edge, and a unit vector pointing out of that edge. + """ + hit_box = other._hit_box + points = hit_box.get_adjusted_points() + left, right, bottom, top = hit_box.get_adjusted_bounds() + # Distance to each side along x and y, then along each edge's axis + best = min( + (x - left, -1.0, 0.0), + (right - x, 1.0, 0.0), + (y - bottom, 0.0, -1.0), + (top - y, 0.0, 1.0), + ) + for axis_x, axis_y in hit_box._get_axes().values(): + length = hypot(axis_x, axis_y) + axis_x, axis_y = axis_x / length, axis_y / length + projected = [axis_x * px + axis_y * py for px, py in points] + position = axis_x * x + axis_y * y + best = min( + best, + (position - min(projected), -axis_x, -axis_y), + (max(projected) - position, axis_x, axis_y), + ) + depth, normal_x, normal_y = best + # 0.0 + x avoids -0.0 for a zero component + return depth, Vec2(0.0 + normal_x, 0.0 + normal_y) + + +def sweep_line( + start: Point2, + end: Point2, + sprite_list: SpriteSequence[SpriteType], +) -> SweepInfo | None: + """ + Find the first sprite in a list that a line from ``start`` to ``end`` hits. + + This is :py:func:`sweep_sprite` for a point instead of a sprite: useful + for lasers, hitscan weapons, and seeing what's in the way between two + points. The point where the line hits is + ``start + (end - start) * hit.fraction``:: + + hit = arcade.sweep_line(gun.position, target, walls) + if hit: + start, end = Vec2(*gun.position), Vec2(*target) + point = start + (end - start) * hit.fraction + arcade.draw_line(*start, *point, arcade.color.RED, 2) + hit.sprite.remove_from_sprite_lists() + + The :py:attr:`~SweepInfo.normal` points out of the hit sprite's surface, + so a laser can bounce off it: reflect the direction ``d`` with + ``d - 2 * d.dot(normal) * normal``. + + If ``start`` is inside a sprite in the list, that is an immediate hit, + with a :py:attr:`~SweepInfo.fraction` of 0.0 and a normal pointing out of + the nearest edge. If it's inside several, the one it's deepest inside is + returned. + + As with :py:func:`sweep_sprite`, touching doesn't count: a line along a + sprite's edge, or one that only touches a corner, doesn't hit it, and + neither does a line that ends exactly on its edge. + + .. note:: The result is only exact for convex hit boxes. + + If the list has a spatial hash, only sprites near the line are checked. + Otherwise every sprite in the list is. If the line hits two sprites at + the same point, either may be returned. + + Args: + start: + Where the line starts + end: + Where the line ends + sprite_list: + The sprites it may hit + + Returns: + A :py:class:`SweepInfo` for the first sprite hit, or ``None``. + """ + if __debug__: + if not isinstance(sprite_list, SpriteSequence): + raise TypeError(f"Parameter 3 is a {type(sprite_list)} instead of expected SpriteList.") + + start_x, start_y = start + end_x, end_y = end + dx = end_x - start_x + dy = end_y - start_y + path_left, path_right = min(start_x, end_x), max(start_x, end_x) + path_bottom, path_top = min(start_y, end_y), max(start_y, end_y) + + candidates: Iterable[SpriteType] + if sprite_list.spatial_hash is not None: + candidates = sprite_list.spatial_hash.get_sprites_near_rect( + LRBT(path_left, path_right, path_bottom, path_top) + ) + else: + candidates = sprite_list + + point = ((start_x, start_y),) + point_bounds = (start_x, start_x, start_y, start_y) + no_axes: dict[Point2, Point2] = {} + # Hits must be strictly before this, so a line that ends exactly on a + # sprite's edge doesn't hit it + best_fraction = 1.0 + best: tuple[SpriteType, float, float, float] | None = None + deepest_start: tuple[SpriteType, float, Vec2] | None = None + for other in candidates: + # Quick check with the other sprite's bounding box. Touching doesn't + # count, but a line along an axis has no width, so compare with < + # where the line is a single coordinate. + other_left, other_right, other_bottom, other_top = other._hit_box.get_adjusted_bounds() + if ( + path_right < other_left + or other_right < path_left + or path_top < other_bottom + or other_top < path_bottom + ): + continue + + limit = 0.0 if deepest_start is not None else best_fraction + result = _sweep_shape(point, point_bounds, no_axes, other, dx, dy, limit) + if result is None: + continue + fraction, normal_x, normal_y = result + if fraction < 0: + # The line starts inside this sprite. Keep the deepest. + depth, normal = _point_push_out(start_x, start_y, other) + if deepest_start is None or depth > deepest_start[1]: + deepest_start = (other, depth, normal) + elif deepest_start is None: + best_fraction = fraction + best = (other, fraction, normal_x, normal_y) + + if deepest_start is not None: + return SweepInfo(deepest_start[0], 0.0, 0.0, deepest_start[2]) + if best is None: + return None + hit_sprite, fraction, normal_x, normal_y = best + return SweepInfo(hit_sprite, fraction, fraction * hypot(dx, dy), Vec2(normal_x, normal_y)) + + def check_for_collision_with_lists( sprite: BasicSprite, sprite_lists: Iterable[SpriteSequence[SpriteType]], diff --git a/doc/example_code/images/sprite_laser_mirrors.png b/doc/example_code/images/sprite_laser_mirrors.png new file mode 100644 index 000000000..2eecf472b Binary files /dev/null and b/doc/example_code/images/sprite_laser_mirrors.png differ diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index 83f75b044..aa5165578 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -373,6 +373,12 @@ Shooting with Sprites :ref:`sprite_bullets_sweep` +.. figure:: images/thumbs/sprite_laser_mirrors.png + :figwidth: 170px + :target: sprite_laser_mirrors.html + + :ref:`sprite_laser_mirrors` + Audio ----- diff --git a/doc/example_code/sprite_laser_mirrors.rst b/doc/example_code/sprite_laser_mirrors.rst new file mode 100644 index 000000000..b2dc9ace8 --- /dev/null +++ b/doc/example_code/sprite_laser_mirrors.rst @@ -0,0 +1,25 @@ +:orphan: + +.. _sprite_laser_mirrors: + +Lasers and Mirrors +================== + +.. image:: images/sprite_laser_mirrors.png + :width: 600px + :align: center + :alt: Screenshot of a laser beam bouncing off a mirror and collecting coins + +A laser shoots from the turret toward the mouse. It stops at the first crate +in its way, bounces off the rotating mirrors, and collects any coins it +touches. + +The laser doesn't move like a bullet: it reaches as far as it goes the moment +it's fired. Each straight part of the beam uses :py:func:`arcade.sweep_line` +to find the first sprite in its way. The hit's +:py:attr:`~arcade.SweepInfo.normal` points out of the surface it hit, which +is all a mirror needs to reflect the beam. + +.. literalinclude:: ../../arcade/examples/sprite_laser_mirrors.py + :caption: sprite_laser_mirrors.py + :linenos: diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index a482f9bc1..b677293c7 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -1104,3 +1104,132 @@ def random_sprite(shared_angle): assert arcade.check_for_collision(b, a) is expected results[expected] += 1 assert min(results.values()) > 500 + + +# --- sweep_line + + +def _line_walls(spatial=False): + """Two thin walls at x 90..110 and 190..210, and a diamond above them""" + a = arcade.SpriteSolidColor(20, 100, center_x=100, center_y=0) + b = arcade.SpriteSolidColor(20, 100, center_x=200, center_y=0) + diamond = arcade.SpriteSolidColor(40, 40, center_x=300, center_y=200) + diamond.angle = 45 + return a, b, diamond, _walls(a, b, diamond, spatial=spatial) + + +@pytest.mark.parametrize("spatial", [False, True]) +def test_sweep_line_first_hit(window, spatial): + a, b, diamond, walls = _line_walls(spatial) + hit = arcade.sweep_line((0, 0), (300, 0), walls) + assert hit.sprite is a + assert hit.fraction == pytest.approx(0.3) + assert hit.distance == pytest.approx(90) + assert hit.normal == Vec2(-1, 0) + + hit = arcade.sweep_line((300, 10), (0, 10), walls) + assert hit.sprite is b + assert hit.distance == pytest.approx(90) + assert hit.normal == Vec2(1, 0) + + # A vertical line enters through the bottom + hit = arcade.sweep_line((100, -300), (100, 300), walls) + assert hit.sprite is a + assert hit.distance == pytest.approx(250) + assert hit.normal == Vec2(0, -1) + + +def test_sweep_line_rotated_sprite(window): + _, _, diamond, walls = _line_walls() + hit = arcade.sweep_line((300, 0), (300, 300), walls) + assert hit.sprite is diamond + # The bottom corner of a 40 pixel square turned 45 degrees + assert hit.distance == pytest.approx(200 - 20 * math.sqrt(2)) + + +def test_sweep_line_misses_and_touching(window): + a, _, _, walls = _line_walls() + assert arcade.sweep_line((0, 60), (300, 60), walls) is None + # Along an edge, or ending exactly on one, isn't a hit + assert arcade.sweep_line((0, 50), (300, 50), walls) is None + assert arcade.sweep_line((90, -100), (90, 100), walls) is None + assert arcade.sweep_line((0, 0), (90, 0), walls) is None + # Going a little further is + hit = arcade.sweep_line((0, 0), (91, 0), walls) + assert hit.sprite is a + assert hit.fraction == pytest.approx(90 / 91) + + +def test_sweep_line_starts_inside(window): + a, b, _, walls = _line_walls() + # Nearest edge: the left one, 5 away + hit = arcade.sweep_line((95, 0), (300, 0), walls) + assert (hit.sprite, hit.fraction, hit.distance) == (a, 0.0, 0.0) + assert hit.normal == Vec2(-1, 0) + # Nearest edge: the top one + hit = arcade.sweep_line((100, 45), (300, 45), walls) + assert hit.sprite is a + assert hit.normal == Vec2(0, 1) + # A line with no length only hits what it starts inside + assert arcade.sweep_line((200, 0), (200, 0), walls).sprite is b + assert arcade.sweep_line((0, 0), (0, 0), walls) is None + + +def test_sweep_line_starting_on_edge_going_in(window): + a, _, _, walls = _line_walls() + hit = arcade.sweep_line((90, 0), (300, 0), walls) + assert (hit.sprite, hit.fraction) == (a, 0.0) + assert hit.normal == Vec2(-1, 0) + # Leaving instead isn't a hit + assert arcade.sweep_line((90, 0), (0, 0), walls) is None + + +WALK_STEPS = 1000 + + +def _first_hit_by_walking(start, end, sprites): + """Where a line first enters a hit box, found by walking along it""" + shapes = [ + (sprite.hit_box.get_adjusted_bounds(), sprite.hit_box.get_adjusted_points()) + for sprite in sprites + ] + for i in range(WALK_STEPS + 1): + t = i / WALK_STEPS + x = start[0] + (end[0] - start[0]) * t + y = start[1] + (end[1] - start[1]) * t + for (left, right, bottom, top), points in shapes: + if left < x < right and bottom < y < top: + if arcade.geometry.is_point_in_polygon(x, y, points): + return t + return None + + +@pytest.mark.parametrize("spatial", [False, True]) +def test_sweep_line_matches_walking_the_line(window, spatial): + """Random rotated and scaled sprites, compared with walking each line in small steps""" + rng = random.Random(23) + sprites = [] + for _ in range(12): + sprite = arcade.SpriteSolidColor( + rng.randint(8, 60), rng.randint(8, 60), + center_x=rng.uniform(-200, 200), center_y=rng.uniform(-200, 200), + ) # fmt: skip + sprite.angle = rng.uniform(0, 360) + sprite.scale = rng.uniform(0.5, 2) + sprites.append(sprite) + walls = _walls(*sprites, spatial=spatial) + + hits = 0 + for _ in range(150): + start = (rng.uniform(-300, 300), rng.uniform(-300, 300)) + end = (rng.uniform(-300, 300), rng.uniform(-300, 300)) + walked = _first_hit_by_walking(start, end, sprites) + hit = arcade.sweep_line(start, end, walls) + if walked is None: + assert hit is None + continue + hits += 1 + assert hit is not None + assert hit.fraction == pytest.approx(walked, abs=2 / WALK_STEPS) + assert hit.normal.length() == pytest.approx(1) + assert hits > 30