nodes/player.py

Part of PirateMaker.

  1"""PirateMaker Player node: controls, gravity, AABB collision, animation."""
  2
  3from __future__ import annotations
  4
  5from settings import GFX
  6from support import player_subfolders
  7
  8from simvx.core import (
  9    AudioClip,
 10    AudioPlayer,
 11    Input,
 12    Node2D,
 13    Vec2,
 14)
 15
 16from .folder_sprite import FolderSprite
 17
 18# Tunables
 19SPEED = 300.0
 20GRAVITY = 1500.0  # px / s²
 21JUMP_VELOCITY = -700.0  # initial v_y on jump
 22DAMAGE_INVUL = 0.6  # seconds
 23
 24
 25class Player(Node2D):
 26    """Side-scrolling platformer player with 4-state animation."""
 27
 28    def __init__(
 29        self,
 30        position: Vec2,
 31        collision_rects: list[tuple[float, float, float, float]],
 32        jump_stream: AudioClip,
 33        level: Node2D,
 34        pad,
 35    ):
 36        super().__init__(position=position)
 37        self.collision_rects = collision_rects
 38        self._jump_stream = jump_stream
 39        self._level = level
 40        # On-screen movement pad, so the level is playable by touch and mouse.
 41        self._pad = pad
 42        self._jump_was_held = False
 43
 44        # Movement state
 45        self.direction = Vec2(0, 0)
 46        self.on_floor = False
 47        self.orientation = "right"
 48        self.status = "idle"
 49
 50        # Hitbox half-sizes (centred on position)
 51        self.hw = 14.0
 52        self.hh = 28.0
 53
 54        # Animations: 8 named (idle/run/jump/fall × left/right)
 55        self._anim_set = player_subfolders(GFX / "player")
 56        self._sprite = FolderSprite(
 57            frames=self._anim_set.get("idle_right", []),
 58            fps=8.0,
 59        )
 60        self.add_child(self._sprite)
 61
 62        self._invul_t = 0.0
 63
 64    def try_damage(self) -> bool:
 65        if self._invul_t > 0:
 66            return False
 67        self._invul_t = DAMAGE_INVUL
 68        # Knockback up
 69        self.direction = Vec2(self.direction.x, -350.0)
 70        return True
 71
 72    def _held(self, action: str) -> bool:
 73        """Whether ``action`` is held on the keyboard or on the movement pad."""
 74        return Input.is_action_pressed(action) or self._pad.is_held(action)
 75
 76    def on_update(self, dt: float) -> None:
 77        self._invul_t = max(0.0, self._invul_t - dt)
 78
 79        # Input
 80        if self._held("move_left"):
 81            self.direction = Vec2(-1.0, self.direction.y)
 82            self.orientation = "left"
 83        elif self._held("move_right"):
 84            self.direction = Vec2(1.0, self.direction.y)
 85            self.orientation = "right"
 86        else:
 87            self.direction = Vec2(0.0, self.direction.y)
 88
 89        # Jump on the rising edge, so holding the key (or the pad button) does
 90        # not re-trigger the moment the player lands.
 91        jump_held = self._held("jump")
 92        if jump_held and not self._jump_was_held and self.on_floor:
 93            self.direction = Vec2(self.direction.x, JUMP_VELOCITY)
 94            self._level.add_child(AudioPlayer(stream=self._jump_stream, autoplay=True, volume_db=-14.0))
 95        self._jump_was_held = jump_held
 96
 97        # Gravity
 98        self.direction = Vec2(self.direction.x, self.direction.y + GRAVITY * dt)
 99
100        # Move horizontally + collide
101        new_x = self.position.x + self.direction.x * SPEED * dt
102        new_x = self._collide_axis(new_x, self.position.y, axis_x=True)
103
104        # Move vertically + collide
105        new_y = self.position.y + self.direction.y * dt
106        new_y, vert_hit = self._collide_axis(new_x, new_y, axis_x=False, return_hit=True)
107        if vert_hit:
108            self.direction = Vec2(self.direction.x, 0.0)
109
110        self.position = Vec2(new_x, new_y)
111        self._update_floor_check()
112        self._update_animation()
113
114    def _collide_axis(self, nx: float, ny: float, *, axis_x: bool, return_hit: bool = False):
115        """AABB collision against world rects on a single axis."""
116        my_l = nx - self.hw
117        my_r = nx + self.hw
118        my_t = ny - self.hh
119        my_b = ny + self.hh
120        hit = False
121        for rx, ry, rw, rh in self.collision_rects:
122            if my_r <= rx or my_l >= rx + rw:
123                continue
124            if my_b <= ry or my_t >= ry + rh:
125                continue
126            # Overlap → resolve along axis
127            hit = True
128            if axis_x:
129                if self.direction.x > 0:
130                    nx = rx - self.hw
131                elif self.direction.x < 0:
132                    nx = rx + rw + self.hw
133            else:
134                if self.direction.y > 0:
135                    ny = ry - self.hh
136                elif self.direction.y < 0:
137                    ny = ry + rh + self.hh
138            my_l = nx - self.hw
139            my_r = nx + self.hw
140            my_t = ny - self.hh
141            my_b = ny + self.hh
142        if return_hit:
143            if axis_x:
144                return nx, hit
145            return ny, hit
146        return nx if axis_x else ny
147
148    def _update_floor_check(self) -> None:
149        # Probe a 2px-tall rect just below the hitbox
150        probe_t = self.position.y + self.hh
151        probe_b = probe_t + 2
152        probe_l = self.position.x - self.hw
153        probe_r = self.position.x + self.hw
154        self.on_floor = False
155        for rx, ry, rw, rh in self.collision_rects:
156            if probe_r <= rx or probe_l >= rx + rw:
157                continue
158            if probe_b <= ry or probe_t >= ry + rh:
159                continue
160            self.on_floor = True
161            return
162
163    def _update_animation(self) -> None:
164        if self.direction.y < -50:
165            self.status = "jump"
166        elif self.direction.y > 50 and not self.on_floor:
167            self.status = "fall"
168        else:
169            self.status = "run" if abs(self.direction.x) > 0.1 else "idle"
170        frames = self._anim_set.get(f"{self.status}_{self.orientation}")
171        if frames:
172            self._sprite.play(frames)
173
174        # Damage flash: tint while invuln
175        if self._invul_t > 0:
176            self._sprite.colour = (1.0, 1.0, 1.0, 0.5)
177        else:
178            self._sprite.colour = (1.0, 1.0, 1.0, 1.0)