nodes/obstacle.pyΒΆ

Part of Pixel Runner.

 1"""Obstacle nodes: replaces Pygame ``Obstacle(sprite.Sprite)`` for fly + snail.
 2
 3Pygame original:
 4- ``Obstacle.__init__(type)`` branches on string to load fly/snail frames.
 5- ``update()`` ticks animation, slides x by -6 px/frame, and self-kills when
 6  off-screen (``self.kill()`` from a ``sprite.Group``).
 7
 8SimVX port:
 9- One subclass per kind (``Snail``, ``Fly``): each declares its frames, its
10  animation rate, and how high above the ground line it rides.
11- ``AnimatedSprite2D.from_frames`` stitches the two PNGs into a single strip
12  atlas and plays them as a looping flipbook, so there is no per-frame
13  bookkeeping and no per-frame texture upload.
14- ``add_to_group("obstacles")`` so the runner can iterate with one call.
15- ``destroy()`` removes the node from the tree at end-of-frame; no Group needed.
16"""
17
18from simvx.core import AnimatedSprite2D, Node2D, Property, Rect2, Vec2
19
20from .assets import GRAPHICS
21
22OBSTACLE_SPEED = 360.0  # pixels/sec; pygame used 5-6 px/frame at 60 Hz ~ 300-360
23
24
25class _Obstacle(Node2D):
26    """Shared base: a looping flipbook that scrolls left and self-destructs off-screen."""
27
28    speed = Property(OBSTACLE_SPEED, range=(0, 1500), hint="Scroll speed (px/sec)")
29
30    # Subclasses set these
31    frame_paths: tuple[str, ...] = ()
32    frame_size: tuple[int, int] = (64, 64)
33    fps: float = 5.0  # animation rate
34    ground_offset: float = 0.0  # centre y above the ground line
35
36    def __init__(self, x: float, horizon: float, **kwargs):
37        super().__init__(**kwargs)
38        self.position = Vec2(x, float(horizon) - self.ground_offset)
39        self.add_to_group("obstacles")
40        self.frames = self.add_child(
41            AnimatedSprite2D.from_frames(list(self.frame_paths), fps=self.fps, loop=True, name="idle", play=True)
42        )
43        self.frames.name = "Frames"  # from_frames' ``name`` is the animation, not the node
44
45    @property
46    def rect(self) -> Rect2:
47        """Axis-aligned collision box, centred on the node like the sprite is."""
48        w, h = self.frame_size
49        return Rect2(self.position.x - w * 0.5, self.position.y - h * 0.5, w, h)
50
51    def set_horizon(self, horizon: float) -> None:
52        """Re-seat on the ground line after a window resize."""
53        self.position = Vec2(float(self.position.x), float(horizon) - self.ground_offset)
54
55    def on_update(self, dt: float):
56        # Scroll left, then cull once the whole frame has left the screen.
57        x = float(self.position.x) - self.speed * dt
58        self.position = Vec2(x, float(self.position.y))
59        if x < -self.frame_size[0]:
60            self.destroy()
61
62
63class Snail(_Obstacle):
64    """Ground crawler: sits on the player's baseline so you must jump it."""
65
66    frame_paths = (str(GRAPHICS / "snail" / "snail1.png"), str(GRAPHICS / "snail" / "snail2.png"))
67    frame_size = (72, 36)
68    fps = 2.0  # pygame timer fired the snail swap every 500 ms -> 2 fps
69    ground_offset = 18.0  # half the frame height, so midbottom rests on the ground line
70
71
72class Fly(_Obstacle):
73    """Flying enemy: passes overhead at the player's head height."""
74
75    frame_paths = (str(GRAPHICS / "fly" / "Fly1.png"), str(GRAPHICS / "fly" / "Fly2.png"))
76    frame_size = (84, 40)
77    fps = 5.0  # pygame timer fired every 200 ms -> 5 fps
78    # The player is 84 px tall standing, so a bottom edge at horizon - 90 clears
79    # a standing runner by 6 px but can clip a mistimed jump (apex ~136 px).
80    ground_offset = 110.0