nodes/player.py

Part of HeartBeast Action RPG.

  1"""Player character: 8-direction movement, sword attack, roll/dodge.
  2
  3Mirrors the upstream HeartBeast Player scene:
  4
  5- acceleration towards a target velocity, friction back to a stop
  6- a sword hitbox that swings in the facing direction
  7- a roll that dashes and grants invincibility frames
  8- a hurtbox with an invincibility timer and a blink
  9
 10Nothing here loads a texture: the character is a handful of ``rect`` /
 11``circle`` calls, so the port ships no art (see ATTRIBUTION.md).
 12"""
 13
 14from __future__ import annotations
 15
 16import math
 17
 18from controls import ADVENTURER
 19from settings import (
 20    COLOUR_PLAYER_BODY,
 21    COLOUR_PLAYER_HAIR,
 22    COLOUR_PLAYER_HEAD,
 23    COLOUR_PLAYER_TRIM,
 24    COLOUR_SHADOW,
 25    COLOUR_SWORD,
 26    PLAYER_ACCELERATION,
 27    PLAYER_ATTACK_COOLDOWN,
 28    PLAYER_ATTACK_DURATION,
 29    PLAYER_FRICTION,
 30    PLAYER_INVULN_DURATION,
 31    PLAYER_MAX_HP,
 32    PLAYER_RADIUS,
 33    PLAYER_RESPAWN_DELAY,
 34    PLAYER_ROLL_DURATION,
 35    PLAYER_ROLL_SPEED,
 36    PLAYER_SPEED,
 37    SWORD_HITBOX_OFFSET,
 38    SWORD_HITBOX_SIZE,
 39)
 40
 41from simvx.core import Input, Node2D, Property, Signal, Vec2
 42
 43from .stats import Stats
 44
 45#: Blink period of the invincibility flash, in seconds.
 46FLASH_INTERVAL = 0.1
 47
 48
 49def _rotate(v: Vec2, angle: float) -> Vec2:
 50    """Rotate ``v`` by ``angle`` radians (2D vectors have no rotate helper)."""
 51    cos_a, sin_a = math.cos(angle), math.sin(angle)
 52    return Vec2(v[0] * cos_a - v[1] * sin_a, v[0] * sin_a + v[1] * cos_a)
 53
 54
 55class Player(Node2D):
 56    """Top-down player with sword combat and a roll/dodge."""
 57
 58    # The body blinks, the sword swings and the roll squashes it, none of which
 59    # are Property state, so the retained 2D cache must re-collect every frame.
 60    dynamic = True
 61
 62    # Walking, swinging and rolling are live exactly while the player is in the
 63    # tree, so a test that loads the world alone gets them with no setup.
 64    input_context = ADVENTURER
 65
 66    health_changed = Signal()  # (health: int)
 67
 68    speed = Property(PLAYER_SPEED, range=(50, 500), hint="px/sec")
 69    max_hp = Property(PLAYER_MAX_HP, range=(1, 20))
 70
 71    def __init__(self, position: Vec2 | None = None, **kwargs):
 72        super().__init__(position=position if position is not None else Vec2(0.0, 0.0), **kwargs)
 73
 74        # Movement. ``_facing`` is the last non-zero heading and never zero, so
 75        # the sword and the roll always have a direction to use.
 76        self._velocity = Vec2(0.0, 0.0)
 77        self._facing = Vec2(0.0, 1.0)
 78
 79        # Roll
 80        self.is_rolling = False
 81        self._roll_timer = 0.0
 82        self._roll_direction = Vec2(0.0, 1.0)
 83
 84        # Attack
 85        self.is_attacking = False
 86        self._attack_timer = 0.0
 87        self._attack_cooldown = 0.0
 88        self._sword_rotation = 0.0  # radians
 89
 90        # Invincibility
 91        self._invuln_timer = 0.0
 92        self._invulnerable = False
 93        self._flash_visible = True
 94        self._flash_timer = 0.0
 95
 96        # Health, mirroring the upstream standalone Stats scene.
 97        self.stats = Stats(max_health=self.max_hp)
 98        self.stats.health_changed.connect(self._on_health_changed)
 99        self.stats.no_health.connect(self._on_death)
100        self.add_child(self.stats)
101
102        # Death / respawn
103        self._dead = False
104        self._respawn_timer = 0.0
105
106    # ── Public state ─────────────────────────────────────────────────────────
107
108    @property
109    def is_dead(self) -> bool:
110        """True while the player is down and waiting to respawn."""
111        return self._dead
112
113    @property
114    def is_invulnerable(self) -> bool:
115        """True during roll or post-hit invincibility frames."""
116        return self._invulnerable
117
118    @property
119    def facing(self) -> Vec2:
120        """Unit heading the sword swings along."""
121        return self._facing
122
123    # ── Lifecycle ────────────────────────────────────────────────────────────
124
125    def on_update(self, dt: float):
126        if self._dead:
127            self._respawn_timer -= dt
128            if self._respawn_timer <= 0:
129                self._revive()
130            return
131
132        self._handle_input(dt)
133        self._update_timers(dt)
134
135    def on_draw(self, renderer):
136        renderer.circle((0, PLAYER_RADIUS), PLAYER_RADIUS * 0.9, fill=COLOUR_SHADOW)
137        if self._dead:
138            # Face down: the body flattens into the shadow until the respawn.
139            renderer.rect((-PLAYER_RADIUS, -1), (PLAYER_RADIUS * 2, 4), fill=COLOUR_PLAYER_TRIM)
140            return
141
142        # Invincibility blink: skipping the body entirely is the classic look.
143        if self._invulnerable and not self._flash_visible:
144            return
145
146        if self.is_rolling:
147            self._draw_rolling(renderer, 0, 0)
148        else:
149            self._draw_standing(renderer, 0, 0)
150
151        if self.is_attacking:
152            self._draw_sword(renderer)
153
154    def _draw_standing(self, renderer, px: float, py: float):
155        r = PLAYER_RADIUS
156        renderer.rect((px - r, py - r * 0.4), (r * 2, r * 1.7), fill=COLOUR_PLAYER_BODY)
157        renderer.rect((px - r, py + r * 0.9), (r * 2, r * 0.4), fill=COLOUR_PLAYER_TRIM)
158        head_y = py - r * 1.1
159        renderer.circle((px, head_y), r * 0.85, fill=COLOUR_PLAYER_HEAD)
160        # The hair disc sits slightly *behind* the facing, so the sliver of face
161        # it leaves uncovered shows which way the sword will swing.
162        renderer.circle(
163            (px - self._facing[0] * r * 0.45, head_y - self._facing[1] * r * 0.45), r * 0.85, fill=COLOUR_PLAYER_HAIR
164        )
165
166    def _draw_rolling(self, renderer, px: float, py: float):
167        # Tucked into a ball, with a trim band that spins with the roll timer.
168        r = PLAYER_RADIUS
169        renderer.circle((px, py), r, fill=COLOUR_PLAYER_BODY)
170        spin = self._roll_timer / PLAYER_ROLL_DURATION * math.tau
171        band = _rotate(Vec2(r * 0.55, 0.0), spin)
172        renderer.circle((px + band[0], py + band[1]), r * 0.4, fill=COLOUR_PLAYER_TRIM)
173
174    def _draw_sword(self, renderer):
175        centre, (w, h), rotation = self.get_sword_hitbox()
176        corners = [Vec2(-w / 2, -h / 2), Vec2(w / 2, -h / 2), Vec2(w / 2, h / 2), Vec2(-w / 2, h / 2)]
177        # The hitbox is in world space; the sword is drawn in the player's own.
178        renderer.polygon([self.to_local(_rotate(c, rotation) + centre) for c in corners], fill=COLOUR_SWORD)
179
180    # ── Input & movement ─────────────────────────────────────────────────────
181
182    def _handle_input(self, dt: float):
183        # Read the one-shot presses first, so a press that lands mid-roll is
184        # dropped rather than replayed when the dash ends.
185        attack = Input.just_pressed("attack")
186        roll = Input.just_pressed("roll")
187        if self.is_rolling:
188            return  # Locked for the duration of the dash.
189
190        # One typed action: the keys, a controller's stick and the drawn pad's
191        # stick all arrive here as the same vector.
192        move = Input.value("move")
193        if move.length() > 0:
194            move = move.normalized()
195            self._facing = move.copy()
196            # Accelerate towards the target velocity instead of snapping to it.
197            diff = move * self.speed - self._velocity
198            max_accel = PLAYER_ACCELERATION * dt
199            if diff.length() > max_accel:
200                diff = diff.normalized() * max_accel
201            self._velocity += diff
202        else:
203            self._apply_friction(dt)
204
205        if attack and self._attack_cooldown <= 0:
206            self._start_attack()
207        if roll:
208            self._start_roll()
209
210    def _apply_friction(self, dt: float):
211        speed = self._velocity.length()
212        friction = PLAYER_FRICTION * dt
213        if speed <= friction:
214            self._velocity = Vec2(0.0, 0.0)
215        else:
216            self._velocity = self._velocity * ((speed - friction) / speed)
217
218    # ── Roll ─────────────────────────────────────────────────────────────────
219
220    def _start_roll(self):
221        if self.is_rolling:
222            return
223        self.is_rolling = True
224        self._roll_timer = 0.0
225        self._roll_direction = self._facing.copy()
226        self._velocity = self._roll_direction * PLAYER_ROLL_SPEED
227        self._invulnerable = True
228        self._invuln_timer = PLAYER_ROLL_DURATION
229        self._flash_timer = 0.0
230        self._flash_visible = True
231
232    def _update_roll(self, dt: float):
233        self._roll_timer += dt
234        self._velocity = self._roll_direction * PLAYER_ROLL_SPEED
235        if self._roll_timer >= PLAYER_ROLL_DURATION:
236            self.is_rolling = False
237            # Leave the dash at walking pace so the roll flows into a run.
238            self._velocity = self._roll_direction * self.speed
239
240    # ── Attack ───────────────────────────────────────────────────────────────
241
242    def _start_attack(self):
243        self.is_attacking = True
244        self._attack_timer = 0.0
245        self._attack_cooldown = PLAYER_ATTACK_COOLDOWN
246        self._sword_rotation = math.atan2(self._facing[1], self._facing[0])
247
248    def _update_attack(self, dt: float):
249        self._attack_timer += dt
250        if self._attack_timer >= PLAYER_ATTACK_DURATION:
251            self.is_attacking = False
252
253    # ── Timers ───────────────────────────────────────────────────────────────
254
255    def _update_timers(self, dt: float):
256        if self.is_rolling:
257            self._update_roll(dt)
258        if self.is_attacking:
259            self._update_attack(dt)
260        if self._attack_cooldown > 0:
261            self._attack_cooldown -= dt
262
263        if self._invulnerable:
264            self._invuln_timer -= dt
265            self._flash_timer += dt
266            if self._flash_timer >= FLASH_INTERVAL:
267                self._flash_timer = 0.0
268                self._flash_visible = not self._flash_visible
269            if self._invuln_timer <= 0:
270                self._invulnerable = False
271                self._flash_visible = True
272
273        self.position += self._velocity * dt
274
275    # ── Damage ───────────────────────────────────────────────────────────────
276
277    def take_damage(self, amount: int = 1):
278        if self._invulnerable or self._dead:
279            return
280        self.stats.health -= amount
281        self._invulnerable = True
282        self._invuln_timer = PLAYER_INVULN_DURATION
283        self._flash_timer = 0.0
284        self._flash_visible = True
285
286    def get_sword_hitbox(self) -> tuple[Vec2, tuple[int, int], float] | None:
287        """``(centre, size, rotation_rad)`` of the sword, or None when idle."""
288        if not self.is_attacking:
289            return None
290        centre = self.position + _rotate(Vec2(*SWORD_HITBOX_OFFSET), self._sword_rotation)
291        return (centre, SWORD_HITBOX_SIZE, self._sword_rotation)
292
293    # ── Death ────────────────────────────────────────────────────────────────
294
295    def _on_health_changed(self, health: int):
296        self.health_changed.emit(health)
297
298    def _on_death(self):
299        self._dead = True
300        self._velocity = Vec2(0.0, 0.0)
301        self._invulnerable = False
302        self.is_rolling = False
303        self.is_attacking = False
304        self._respawn_timer = PLAYER_RESPAWN_DELAY
305
306    def _revive(self):
307        self._dead = False
308        self.stats.health = self.stats.max_health
309        self._invulnerable = True
310        self._invuln_timer = PLAYER_INVULN_DURATION
311        self._velocity = Vec2(0.0, 0.0)