Skip to content

Commit c98afc3

Browse files
authored
Introduce SpriteSequence, a covariant supertype of SpriteList. (#2647)
* Remove redundant call to `clear()` in `remove_from_sprite_lists()`. As we are just out of a while loop whose stopping condition is that `sprite_lists` is empty, calling `clear()` on it is redundant. * Concentrate updates to `BasicSprite.sprite_lists` in a pair of methods. There was already a method `register_sprite_list` to handle all additions to `sprite_lists`. We add a corresponding method `_unregister_sprite_list` to handle removals. We make the typings of these methods stricter, so that we can enforce the correct typing invariant on `sprite_lists`. We also make that invariant clearer in a comment. `sprite_lists` is unfortunately unsafely visible to everyone. So a user of the class could still violate the invariants. At least now the *intended* usage is safe. * Remove dead code attribute Sprite._sprite_list. * Fix the type signature of `get_closest_sprite`. This is similar to the fix done to `check_for_collision_with_list` done in c387717. * A few better types in arcade.future and arcade.particles. Adding type parameters to some `SpriteList`s. One allows to get rid of a cast. * Introduce SpriteSequence, a covariant supertype of SpriteList. This is done by analogy to `collections.abc.Sequence`, which is a covariant supertype of `list`. Before this commit, many parts of the codebase used `SpriteList`s without type arguments (defaulting to `Unknown`). That was the only way to allow reasonable usages of the given methods and attributes. However, doing so results in weaker typing. Using `SpriteSequence`, we can add correct type arguments to almost all of the references that were using `SpriteList`s before. The only missing pieces are `Scene` and `TileMap`. Unfortunately, their APIs are fundamentally unsound wrt. the type arguments of their `SpriteList`s. We cannot make it sound without breaking their APIs, so we do not change them. As a bonus, we can now create lists of `SpriteList`s with varying type arguments, and generically call `draw` or `update` on them. Previously, the only common supertype of `SpriteList[A]` and `SpriteList[B]` was `object`, which meant it was not possible to call those methods on them. In a sense, that ability mostly subsumes the convenience provided by `Scene`. A `list[SpriteSequence[BasicSprite]]` is almost as convenient, while being type-safe.
1 parent 9c029cd commit c98afc3

15 files changed

+306
-140
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
99
- Fix `UIScrollArea.add` always returning None
1010
- Support `layer` in `UIView.add_widget()`
1111
- Text objects are now lazy and can be created before the window
12+
- Introduce `arcade.SpriteSequence[T]` as a covariant supertype of `arcade.SpriteList[T]`
13+
(this is similar to Python's `Sequence[T]`, which is a supertype of `list[T]`)
14+
and various improvements to the typing of the API that leverage it
1215

1316
## Version 3.1.0
1417

arcade/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def configure_logging(level: int | None = None):
168168
from .sprite import PyMunk
169169
from .sprite import PymunkMixin
170170
from .sprite import SpriteType
171+
from .sprite import SpriteType_co
171172
from .sprite import Sprite
172173
from .sprite import BasicSprite
173174

@@ -176,6 +177,7 @@ def configure_logging(level: int | None = None):
176177
from .sprite import SpriteSolidColor
177178

178179
from .sprite_list import SpriteList
180+
from .sprite_list import SpriteSequence
179181
from .sprite_list import check_for_collision
180182
from .sprite_list import check_for_collision_with_list
181183
from .sprite_list import check_for_collision_with_lists
@@ -283,9 +285,11 @@ def configure_logging(level: int | None = None):
283285
"BasicSprite",
284286
"Sprite",
285287
"SpriteType",
288+
"SpriteType_co",
286289
"PymunkMixin",
287290
"SpriteCircle",
288291
"SpriteList",
292+
"SpriteSequence",
289293
"SpriteSolidColor",
290294
"Text",
291295
"Texture",

arcade/future/input/input_manager_example.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class Player(arcade.Sprite):
2626
def __init__(
2727
self,
2828
texture,
29-
walls: arcade.SpriteList,
29+
walls: arcade.SpriteSequence[arcade.BasicSprite],
3030
input_manager_template: InputManager,
3131
controller: pyglet.input.Controller | None = None,
3232
center_x: float = 0.0,
@@ -76,11 +76,11 @@ def __init__(
7676
}
7777

7878
self.players: list[Player | None] = []
79-
self.player_list = arcade.SpriteList()
79+
self.player_list: arcade.SpriteList[Player] = arcade.SpriteList()
8080
self.device_labels_batch = pyglet.graphics.Batch()
8181
self.player_device_labels: list[arcade.Text | None] = []
8282

83-
self.wall_list = arcade.SpriteList(use_spatial_hash=True)
83+
self.wall_list: arcade.SpriteList[arcade.Sprite] = arcade.SpriteList(use_spatial_hash=True)
8484

8585
for x in range(0, self.width + 64, 64):
8686
wall = arcade.Sprite(":resources:images/tiles/grassMid.png", scale=0.5)

arcade/future/light/light_demo.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def __init__(self, width, height, title):
1818
super().__init__(width, height, title)
1919
self.background = arcade.load_texture(":resources:images/backgrounds/abstract_1.jpg")
2020

21-
self.torch_list = arcade.SpriteList()
21+
self.torch_list: arcade.SpriteList[arcade.Sprite] = arcade.SpriteList()
2222
self.torch_list.extend(
2323
[
2424
arcade.Sprite(

arcade/particles/emitter.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from __future__ import annotations
77

8-
from typing import Callable, cast
8+
from typing import Callable
99

1010
import arcade
1111
from arcade import Vec2
@@ -151,7 +151,7 @@ def __init__(
151151
self.particle_factory = particle_factory
152152
self._emit_done_cb = emit_done_cb
153153
self._reap_cb = reap_cb
154-
self._particles: arcade.SpriteList = arcade.SpriteList(use_spatial_hash=False)
154+
self._particles: arcade.SpriteList[Particle] = arcade.SpriteList(use_spatial_hash=False)
155155

156156
def _emit(self):
157157
"""
@@ -189,7 +189,7 @@ def update(self, delta_time: float = 1 / 60):
189189
for _ in range(emit_count):
190190
self._emit()
191191
self._particles.update(delta_time)
192-
particles_to_reap = [p for p in self._particles if cast(Particle, p).can_reap()]
192+
particles_to_reap = [p for p in self._particles if p.can_reap()]
193193
for dead_particle in particles_to_reap:
194194
dead_particle.kill()
195195

arcade/paths.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44

55
import math
66

7-
from arcade import Sprite, SpriteList, check_for_collision_with_list, get_sprites_at_point
7+
from arcade import (
8+
BasicSprite,
9+
Sprite,
10+
SpriteSequence,
11+
check_for_collision_with_list,
12+
get_sprites_at_point,
13+
)
814
from arcade.math import get_distance, lerp_2d
915
from arcade.types import Point2
1016

1117
__all__ = ["AStarBarrierList", "astar_calculate_path", "has_line_of_sight"]
1218

1319

14-
def _spot_is_blocked(position: Point2, moving_sprite: Sprite, blocking_sprites: SpriteList) -> bool:
20+
def _spot_is_blocked(
21+
position: Point2, moving_sprite: Sprite, blocking_sprites: SpriteSequence[BasicSprite]
22+
) -> bool:
1523
"""
1624
Return if position is blocked
1725
@@ -275,7 +283,7 @@ class AStarBarrierList:
275283
def __init__(
276284
self,
277285
moving_sprite: Sprite,
278-
blocking_sprites: SpriteList,
286+
blocking_sprites: SpriteSequence[BasicSprite],
279287
grid_size: int,
280288
left: int,
281289
right: int,
@@ -372,7 +380,7 @@ def astar_calculate_path(
372380
def has_line_of_sight(
373381
observer: Point2,
374382
target: Point2,
375-
walls: SpriteList,
383+
walls: SpriteSequence[BasicSprite],
376384
max_distance: float = float("inf"),
377385
check_resolution: int = 2,
378386
) -> bool:

arcade/physics_engines.py

+36-22
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from arcade import (
99
BasicSprite,
1010
Sprite,
11-
SpriteList,
11+
SpriteSequence,
1212
SpriteType,
1313
check_for_collision,
1414
check_for_collision_with_lists,
@@ -20,7 +20,7 @@
2020
from arcade.utils import Chain, copy_dunders_unimplemented
2121

2222

23-
def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteList]) -> None:
23+
def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteSequence[BasicSprite]]) -> None:
2424
"""Kludge to 'guess' a colliding sprite out of a collision.
2525
2626
It works by iterating over increasing wiggle sizes of 8 points
@@ -80,7 +80,7 @@ def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteList]) -> None:
8080

8181

8282
def _move_sprite(
83-
moving_sprite: Sprite, can_collide: Iterable[SpriteList[SpriteType]], ramp_up: bool
83+
moving_sprite: Sprite, can_collide: Iterable[SpriteSequence[SpriteType]], ramp_up: bool
8484
) -> list[SpriteType]:
8585
"""Update a sprite's angle and position, returning a list of collisions.
8686
@@ -273,11 +273,14 @@ def _move_sprite(
273273
return complete_hit_list
274274

275275

276-
def _add_to_list(dest: list[SpriteList], source: SpriteList | Iterable[SpriteList] | None) -> None:
277-
"""Helper function to add a SpriteList or list of SpriteLists to a list."""
276+
def _add_to_list(
277+
dest: list[SpriteSequence[SpriteType]],
278+
source: SpriteSequence[SpriteType] | Iterable[SpriteSequence[SpriteType]] | None,
279+
) -> None:
280+
"""Helper function to add a SpriteSequence or list of SpriteSequences to a list."""
278281
if not source:
279282
return
280-
elif isinstance(source, SpriteList):
283+
elif isinstance(source, SpriteSequence):
281284
dest.append(source)
282285
else:
283286
dest.extend(source)
@@ -310,17 +313,17 @@ class PhysicsEngineSimple:
310313
def __init__(
311314
self,
312315
player_sprite: Sprite,
313-
walls: SpriteList | Iterable[SpriteList] | None = None,
316+
walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None,
314317
) -> None:
315318
self.player_sprite: Sprite = player_sprite
316319
"""The player-controlled :py:class:`.Sprite`."""
317-
self._walls: list[SpriteList] = []
320+
self._walls: list[SpriteSequence[BasicSprite]] = []
318321

319322
if walls:
320323
_add_to_list(self._walls, walls)
321324

322325
@property
323-
def walls(self) -> list[SpriteList]:
326+
def walls(self) -> list[SpriteSequence[BasicSprite]]:
324327
"""Which :py:class:`.SpriteList` instances block player movement.
325328
326329
.. important:: Avoid moving sprites in these lists!
@@ -334,7 +337,10 @@ def walls(self) -> list[SpriteList]:
334337
return self._walls
335338

336339
@walls.setter
337-
def walls(self, walls: SpriteList | Iterable[SpriteList] | None = None) -> None:
340+
def walls(
341+
self,
342+
walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None,
343+
) -> None:
338344
if walls:
339345
_add_to_list(self._walls, walls)
340346
else:
@@ -429,17 +435,17 @@ class PhysicsEnginePlatformer:
429435
def __init__(
430436
self,
431437
player_sprite: Sprite,
432-
platforms: SpriteList | Iterable[SpriteList] | None = None,
438+
platforms: SpriteSequence[Sprite] | Iterable[SpriteSequence[Sprite]] | None = None,
433439
gravity_constant: float = 0.5,
434-
ladders: SpriteList | Iterable[SpriteList] | None = None,
435-
walls: SpriteList | Iterable[SpriteList] | None = None,
440+
ladders: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None,
441+
walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None,
436442
) -> None:
437443
if not isinstance(player_sprite, Sprite):
438444
raise TypeError("player_sprite must be a Sprite, not a basic_sprite!")
439445

440-
self._ladders: list[SpriteList] = []
441-
self._platforms: list[SpriteList] = []
442-
self._walls: list[SpriteList] = []
446+
self._ladders: list[SpriteSequence[BasicSprite]] = []
447+
self._platforms: list[SpriteSequence[Sprite]] = []
448+
self._walls: list[SpriteSequence[BasicSprite]] = []
443449
self._all_obstacles = Chain(self._walls, self._platforms)
444450

445451
_add_to_list(self._ladders, ladders)
@@ -517,7 +523,7 @@ def __init__(
517523
# TODO: figure out what do do with 15_ladders_moving_platforms.py
518524
# It's no longer used by any example or tutorial file
519525
@property
520-
def ladders(self) -> list[SpriteList]:
526+
def ladders(self) -> list[SpriteSequence[BasicSprite]]:
521527
"""Ladders turn off gravity while touched by the player.
522528
523529
This means that whenever the :py:attr:`player_sprite` collides
@@ -533,7 +539,10 @@ def ladders(self) -> list[SpriteList]:
533539
return self._ladders
534540

535541
@ladders.setter
536-
def ladders(self, ladders: SpriteList | Iterable[SpriteList] | None = None) -> None:
542+
def ladders(
543+
self,
544+
ladders: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None,
545+
) -> None:
537546
if ladders:
538547
_add_to_list(self._ladders, ladders)
539548
else:
@@ -544,7 +553,7 @@ def ladders(self) -> None:
544553
self._ladders.clear()
545554

546555
@property
547-
def platforms(self) -> list[SpriteList]:
556+
def platforms(self) -> list[SpriteSequence[Sprite]]:
548557
""":py:class:`~arcade.sprite_list.sprite_list.SpriteList` instances containing platforms.
549558
550559
.. important:: For best performance, put non-moving terrain in
@@ -575,7 +584,9 @@ def platforms(self) -> list[SpriteList]:
575584
return self._platforms
576585

577586
@platforms.setter
578-
def platforms(self, platforms: SpriteList | Iterable[SpriteList] | None = None) -> None:
587+
def platforms(
588+
self, platforms: SpriteSequence[Sprite] | Iterable[SpriteSequence[Sprite]] | None = None
589+
) -> None:
579590
if platforms:
580591
_add_to_list(self._platforms, platforms)
581592
else:
@@ -586,7 +597,7 @@ def platforms(self) -> None:
586597
self._platforms.clear()
587598

588599
@property
589-
def walls(self) -> list[SpriteList]:
600+
def walls(self) -> list[SpriteSequence[BasicSprite]]:
590601
"""Exposes the :py:class:`SpriteList` instances use as terrain.
591602
592603
.. important:: For best performance, only add non-moving sprites!
@@ -611,7 +622,10 @@ def walls(self) -> list[SpriteList]:
611622
return self._walls
612623

613624
@walls.setter
614-
def walls(self, walls: SpriteList | Iterable[SpriteList] | None = None) -> None:
625+
def walls(
626+
self,
627+
walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None,
628+
) -> None:
615629
if walls:
616630
_add_to_list(self._walls, walls)
617631
else:

arcade/sprite/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from arcade.texture import Texture
66
from arcade.resources import resolve
7-
from .base import BasicSprite, SpriteType
7+
from .base import BasicSprite, SpriteType, SpriteType_co
88
from .sprite import Sprite
99
from .mixins import PymunkMixin, PyMunk
1010
from .animated import (
@@ -69,6 +69,7 @@ def load_animated_gif(resource_name: str | Path) -> TextureAnimationSprite:
6969

7070
__all__ = [
7171
"SpriteType",
72+
"SpriteType_co",
7273
"BasicSprite",
7374
"Sprite",
7475
"PyMunk",

arcade/sprite/base.py

+17-4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
# Type from sprite that can be any BasicSprite or any subclass of BasicSprite
1717
SpriteType = TypeVar("SpriteType", bound="BasicSprite")
1818

19+
# Same as SpriteType, for covariant type parameters
20+
SpriteType_co = TypeVar("SpriteType_co", bound="BasicSprite", covariant=True)
21+
1922

2023
@copy_dunders_unimplemented # See https://github.com/pythonarcade/arcade/issues/2074
2124
class BasicSprite:
@@ -70,7 +73,15 @@ def __init__(
7073
self._height = height * self._scale[1]
7174
self._visible = bool(visible)
7275
self._color: Color = WHITE
73-
self.sprite_lists: list["SpriteList"] = []
76+
77+
# In a more powerful type system, this would be typed as
78+
# list[SpriteList[? super Self]]
79+
# i.e., a list of SpriteList's with varying type arguments, but where
80+
# each of those type arguments is known to be a supertype of Self.
81+
# All changes to this list should go through the pair of methods
82+
# register_sprite_list, _unregister_sprite_list.
83+
# They ensure that the above typing invariant is preserved.
84+
self.sprite_lists: list["SpriteList[Any]"] = []
7485
"""The sprite lists this sprite is a member of"""
7586

7687
# Core properties we don't use, but spritelist expects it
@@ -747,21 +758,23 @@ def update_spatial_hash(self) -> None:
747758
if sprite_list.spatial_hash is not None:
748759
sprite_list.spatial_hash.move(self)
749760

750-
def register_sprite_list(self, new_list: SpriteList) -> None:
761+
def register_sprite_list(self: SpriteType, new_list: SpriteList[SpriteType]) -> None:
751762
"""
752763
Register this sprite as belonging to a list.
753764
754765
We will automatically remove ourselves from the list when kill() is called.
755766
"""
756767
self.sprite_lists.append(new_list)
757768

769+
def _unregister_sprite_list(self: SpriteType, new_list: SpriteList[SpriteType]) -> None:
770+
"""Unregister this sprite as belonging to a list."""
771+
self.sprite_lists.remove(new_list)
772+
758773
def remove_from_sprite_lists(self) -> None:
759774
"""Remove the sprite from all sprite lists."""
760775
while len(self.sprite_lists) > 0:
761776
self.sprite_lists[0].remove(self)
762777

763-
self.sprite_lists.clear()
764-
765778
# ----- Drawing Methods -----
766779

767780
def draw_hit_box(self, color: RGBOrA255 = BLACK, line_thickness: float = 2.0) -> None:

0 commit comments

Comments
 (0)