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