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
 32GROUND_Y = 300  # baseline (player feet): matches upstream pygame coordinates
 33JUMP_VELOCITY = -700  # px/sec; pygame used -20 px/frame at 60 Hz; tuned for the new dt-based path
 34GRAVITY = 1800  # px/sec²
 35
 36_PLAYER_GFX = GRAPHICS / "player"
 37
 38
 39class Player(Node2D):
 40    """Side-view runner with two-frame walk cycle and a single jump frame."""
 41
 42    speed_jump = Property(float(JUMP_VELOCITY), range=(-2000, 0), hint="Jump impulse (negative = up)")
 43    gravity = Property(float(GRAVITY), range=(0, 5000), hint="Pixels/sec² downward")
 44
 45    def __init__(self, **kwargs):
 46        super().__init__(name="Player", **kwargs)
 47        self._horizon = float(GROUND_Y)
 48        self.position = Vec2(80, self._horizon - 42)
 49        self.velocity_y = 0.0
 50        self._airborne = False
 51
 52        # Walk cycle: from_frames stitches the two PNGs into one strip atlas and
 53        # registers them as an animation. Its ``name`` is the *animation* name,
 54        # so the node keeps its own name separately.
 55        self.walk_anim = self.add_child(
 56            AnimatedSprite2D.from_frames(
 57                [str(_PLAYER_GFX / "player_walk_1.png"), str(_PLAYER_GFX / "player_walk_2.png")],
 58                fps=8.0,
 59                loop=True,
 60                name="walk",
 61                play=True,
 62            )
 63        )
 64        self.walk_anim.name = "Walk"
 65        # Single-frame jump pose, shown instead of the walk cycle while airborne.
 66        self.sprite_jump = self.add_child(
 67            Sprite2D(
 68                texture=str(_PLAYER_GFX / "jump.png"),
 69                width=72,
 70                height=84,
 71                name="Jump",
 72            )
 73        )
 74        self.sprite_jump.visible = False
 75
 76        # Jump SFX: child node so backend wiring is automatic on enter_tree.
 77        # Generated procedurally (no bundled audio files; see nodes/audio.py).
 78        self.jump_sfx = self.add_child(
 79            AudioPlayer(
 80                stream=make_jump(),
 81                bus="SFX",
 82                volume_db=-6.0,
 83                name="JumpSFX",
 84            )
 85        )
 86
 87    def set_horizon(self, horizon: float) -> None:
 88        """Update the ground line so the player rests on it after window resize."""
 89        self._horizon = float(horizon)
 90        if not self._airborne:
 91            self.position = Vec2(float(self.position.x), self._horizon - 42)
 92
 93    def _set_airborne_visual(self, airborne: bool) -> None:
 94        """Show jump frame while airborne, walk animation while grounded."""
 95        self.walk_anim.visible = not airborne
 96        self.sprite_jump.visible = airborne
 97        if airborne:
 98            self.walk_anim.stop()
 99        else:
100            if not self.walk_anim.playing:
101                self.walk_anim.play("walk")
102
103    def reset(self):
104        """Restore midbottom on the current horizon (called from runner restart)."""
105        self.position = Vec2(80, self._horizon - 42)
106        self.velocity_y = 0.0
107        self._airborne = False
108        self._set_airborne_visual(False)
109
110    @property
111    def rect(self) -> Rect2:
112        """Tight axis-aligned collision box.
113
114        We intentionally use the walk-frame footprint (64x84) whichever frame is
115        showing, pygame did the same.
116        """
117        return Rect2(float(self.position.x) - 32.0, float(self.position.y) - 42.0, 64.0, 84.0)
118
119    def on_update(self, dt: float):
120        # Jump on SPACE if grounded.
121        if Input.is_action_just_pressed("jump") and not self._airborne:
122            self.velocity_y = self.speed_jump
123            self._airborne = True
124            self.jump_sfx.play()
125
126        # Apply gravity and integrate.
127        self.velocity_y += self.gravity * dt
128        new_y = float(self.position.y) + self.velocity_y * dt
129        ground_centre_y = self._horizon - 42.0
130        if new_y >= ground_centre_y:
131            new_y = ground_centre_y
132            self.velocity_y = 0.0
133            self._airborne = False
134        self.position = Vec2(float(self.position.x), new_y)
135
136        # Visual: jump frame while airborne, otherwise the walking AnimatedSprite2D.
137        self._set_airborne_visual(self._airborne)