nodes/player.py

Part of Pixel Runner.

  1"""Player node: replaces Pygame ``Player(sprite.Sprite)``.
  2
  3The pygame original swapped ``self.image`` between three Surfaces every frame to
  4animate. SimVX splits that into two children with one job each: an
  5``AnimatedSprite2D`` built by ``from_frames`` plays the two walk PNGs as a
  6looping flipbook (one strip atlas, no per-frame upload), and a plain ``Sprite2D``
  7holds the single jump frame. Only one of the two is visible at a time.
  8
  9Each frame:
 10- jump while grounded if the "jump" action was just pressed
 11- integrate gravity, clamp to the ground line
 12- show the jump frame while airborne, the walk cycle while grounded
 13
 14This is the SimVX equivalent of pygame.sprite.Sprite + manual gravity, but
 15expressed as a real scene-tree node with Properties for inspector tunables.
 16"""
 17
 18from simvx.core import (
 19    AnimatedSprite2D,
 20    AudioPlayer,
 21    Input,
 22    Node2D,
 23    Property,
 24    Rect2,
 25    Sprite2D,
 26    Vec2,
 27)
 28
 29from .assets import GRAPHICS
 30from .audio import make_jump
 31
 32START_X = 80.0  # how far in from the left edge the runner stands
 33HALF_HEIGHT = 42.0  # centre-to-feet, so the sprite rests on the ground line
 34JUMP_VELOCITY = -700  # px/sec; pygame used -20 px/frame at 60 Hz; tuned for the new dt-based path
 35GRAVITY = 1800  # px/sec²
 36
 37_PLAYER_GFX = GRAPHICS / "player"
 38
 39
 40class Player(Node2D):
 41    """Side-view runner with two-frame walk cycle and a single jump frame."""
 42
 43    speed_jump = Property(float(JUMP_VELOCITY), range=(-2000, 0), hint="Jump impulse (negative = up)")
 44    gravity = Property(float(GRAVITY), range=(0, 5000), hint="Pixels/sec² downward")
 45
 46    def __init__(self, **kwargs):
 47        super().__init__(name="Player", **kwargs)
 48        # The runner owns the ground line and hands it over with set_horizon
 49        # before anything updates, so there is no window size baked in here.
 50        self._horizon = 0.0
 51        self.position = Vec2(START_X, -HALF_HEIGHT)
 52        self.velocity_y = 0.0
 53        self._airborne = False
 54
 55        # Walk cycle: from_frames stitches the two PNGs into one strip atlas and
 56        # registers them as an animation. Its ``name`` is the *animation* name,
 57        # so the node keeps its own name separately.
 58        self.walk_anim = self.add_child(
 59            AnimatedSprite2D.from_frames(
 60                [str(_PLAYER_GFX / "player_walk_1.png"), str(_PLAYER_GFX / "player_walk_2.png")],
 61                fps=8.0,
 62                loop=True,
 63                name="walk",
 64                play=True,
 65            )
 66        )
 67        self.walk_anim.name = "Walk"
 68        # Single-frame jump pose, shown instead of the walk cycle while airborne.
 69        self.sprite_jump = self.add_child(
 70            Sprite2D(
 71                texture=str(_PLAYER_GFX / "jump.png"),
 72                width=72,
 73                height=84,
 74                name="Jump",
 75            )
 76        )
 77        self.sprite_jump.visible = False
 78
 79        # Jump SFX: child node so backend wiring is automatic on enter_tree.
 80        # Generated procedurally (no bundled audio files; see nodes/audio.py).
 81        self.jump_sfx = self.add_child(
 82            AudioPlayer(
 83                stream=make_jump(),
 84                bus="SFX",
 85                volume_db=-6.0,
 86                name="JumpSFX",
 87            )
 88        )
 89
 90    def set_horizon(self, horizon: float) -> None:
 91        """Update the ground line so the player rests on it after window resize."""
 92        self._horizon = float(horizon)
 93        if not self._airborne:
 94            self.position = Vec2(float(self.position.x), self._horizon - HALF_HEIGHT)
 95
 96    def _set_airborne_visual(self, airborne: bool) -> None:
 97        """Show jump frame while airborne, walk animation while grounded."""
 98        self.walk_anim.visible = not airborne
 99        self.sprite_jump.visible = airborne
100        if airborne:
101            self.walk_anim.stop()
102        else:
103            if not self.walk_anim.playing:
104                self.walk_anim.play("walk")
105
106    def reset(self):
107        """Restore midbottom on the current horizon (called from runner restart)."""
108        self.position = Vec2(START_X, self._horizon - HALF_HEIGHT)
109        self.velocity_y = 0.0
110        self._airborne = False
111        self._set_airborne_visual(False)
112
113    @property
114    def rect(self) -> Rect2:
115        """Tight axis-aligned collision box.
116
117        We intentionally use the walk-frame footprint (64x84) whichever frame is
118        showing, pygame did the same.
119        """
120        return Rect2(float(self.position.x) - 32.0, float(self.position.y) - HALF_HEIGHT, 64.0, 84.0)
121
122    def on_update(self, dt: float):
123        # One action covers space, the up arrow, W, a tap anywhere on the
124        # screen and the pad's A button, so there is nothing to branch on.
125        if Input.just_pressed("jump") and not self._airborne:
126            self.velocity_y = self.speed_jump
127            self._airborne = True
128            self.jump_sfx.play()
129
130        # Apply gravity and integrate.
131        self.velocity_y += self.gravity * dt
132        new_y = float(self.position.y) + self.velocity_y * dt
133        ground_centre_y = self._horizon - HALF_HEIGHT
134        if new_y >= ground_centre_y:
135            new_y = ground_centre_y
136            self.velocity_y = 0.0
137            self._airborne = False
138        self.position = Vec2(float(self.position.x), new_y)
139
140        # Visual: jump frame while airborne, otherwise the walking AnimatedSprite2D.
141        self._set_airborne_visual(self._airborne)