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 ``draw_rect`` /
 11``draw_circle`` calls, so the port ships no art (see ATTRIBUTION.md).
 12"""
 13
 14from __future__ import annotations
 15
 16import math
 17
 18from settings import (
 19    COLOUR_PLAYER_BODY,
 20    COLOUR_PLAYER_HAIR,
 21    COLOUR_PLAYER_HEAD,
 22    COLOUR_PLAYER_TRIM,
 23    COLOUR_SHADOW,
 24    COLOUR_SWORD,
 25    PLAYER_ACCELERATION,
 26    PLAYER_ATTACK_COOLDOWN,
 27    PLAYER_ATTACK_DURATION,
 28    PLAYER_FRICTION,
 29    PLAYER_INVULN_DURATION,
 30    PLAYER_MAX_HP,
 31    PLAYER_RADIUS,
 32    PLAYER_RESPAWN_DELAY,
 33    PLAYER_ROLL_DURATION,
 34    PLAYER_ROLL_SPEED,
 35    PLAYER_SPEED,
 36    SWORD_HITBOX_OFFSET,
 37    SWORD_HITBOX_SIZE,
 38)
 39
 40from simvx.core import Input, Node2D, Property, Signal, Vec2
 41
 42from .stats import Stats
 43
 44#: Blink period of the invincibility flash, in seconds.
 45FLASH_INTERVAL = 0.1
 46
 47
 48def _rotate(v: Vec2, angle: float) -> Vec2:
 49    """Rotate ``v`` by ``angle`` radians (2D vectors have no rotate helper)."""
 50    cos_a, sin_a = math.cos(angle), math.sin(angle)
 51    return Vec2(v[0] * cos_a - v[1] * sin_a, v[0] * sin_a + v[1] * cos_a)
 52
 53
 54class Player(Node2D):
 55    """Top-down player with sword combat and a roll/dodge."""
 56
 57    # The body blinks, the sword swings and the roll squashes it, none of which
 58    # are Property state, so the retained 2D cache must re-collect every frame.
 59    dynamic = True
 60
 61    health_changed = Signal()  # (health: int)
 62
 63    speed = Property(PLAYER_SPEED, range=(50, 500), hint="px/sec")
 64    max_hp = Property(PLAYER_MAX_HP, range=(1, 20))
 65
 66    def __init__(self, position: Vec2 | None = None, **kwargs):
 67        super().__init__(position=position if position is not None else Vec2(0.0, 0.0), **kwargs)
 68
 69        # Movement. ``_facing`` is the last non-zero heading and never zero, so
 70        # the sword and the roll always have a direction to use.
 71        self._velocity = Vec2(0.0, 0.0)
 72        self._facing = Vec2(0.0, 1.0)
 73
 74        #: Steering from the on-screen stick, written by the World each frame.
 75        self.touch_direction = Vec2(0.0, 0.0)
 76        self._attack_requested = False
 77        self._roll_requested = False
 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        px, py = self.position
137        renderer.draw_circle((px, py + PLAYER_RADIUS), PLAYER_RADIUS * 0.9, colour=COLOUR_SHADOW, filled=True)
138        if self._dead:
139            # Face down: the body flattens into the shadow until the respawn.
140            renderer.draw_rect(
141                (px - PLAYER_RADIUS, py - 1),
142                (PLAYER_RADIUS * 2, 4),
143                colour=COLOUR_PLAYER_TRIM,
144                filled=True,
145            )
146            return
147
148        # Invincibility blink: skipping the body entirely is the classic look.
149        if self._invulnerable and not self._flash_visible:
150            return
151
152        if self.is_rolling:
153            self._draw_rolling(renderer, px, py)
154        else:
155            self._draw_standing(renderer, px, py)
156
157        if self.is_attacking:
158            self._draw_sword(renderer)
159
160    def _draw_standing(self, renderer, px: float, py: float):
161        r = PLAYER_RADIUS
162        renderer.draw_rect((px - r, py - r * 0.4), (r * 2, r * 1.7), colour=COLOUR_PLAYER_BODY, filled=True)
163        renderer.draw_rect((px - r, py + r * 0.9), (r * 2, r * 0.4), colour=COLOUR_PLAYER_TRIM, filled=True)
164        head_y = py - r * 1.1
165        renderer.draw_circle((px, head_y), r * 0.85, colour=COLOUR_PLAYER_HEAD, filled=True)
166        # The hair disc sits slightly *behind* the facing, so the sliver of face
167        # it leaves uncovered shows which way the sword will swing.
168        renderer.draw_circle(
169            (px - self._facing[0] * r * 0.45, head_y - self._facing[1] * r * 0.45),
170            r * 0.85,
171            colour=COLOUR_PLAYER_HAIR,
172            filled=True,
173        )
174
175    def _draw_rolling(self, renderer, px: float, py: float):
176        # Tucked into a ball, with a trim band that spins with the roll timer.
177        r = PLAYER_RADIUS
178        renderer.draw_circle((px, py), r, colour=COLOUR_PLAYER_BODY, filled=True)
179        spin = self._roll_timer / PLAYER_ROLL_DURATION * math.tau
180        band = _rotate(Vec2(r * 0.55, 0.0), spin)
181        renderer.draw_circle((px + band[0], py + band[1]), r * 0.4, colour=COLOUR_PLAYER_TRIM, filled=True)
182
183    def _draw_sword(self, renderer):
184        centre, (w, h), rotation = self.get_sword_hitbox()
185        corners = [Vec2(-w / 2, -h / 2), Vec2(w / 2, -h / 2), Vec2(w / 2, h / 2), Vec2(-w / 2, h / 2)]
186        renderer.draw_polygon([_rotate(c, rotation) + centre for c in corners], colour=COLOUR_SWORD)
187
188    # ── Input & movement ─────────────────────────────────────────────────────
189
190    def _handle_input(self, dt: float):
191        # Read (and clear) the one-shot intents first, so a tap that lands
192        # mid-roll is dropped rather than replayed when the dash ends.
193        attack = Input.is_action_just_pressed("attack") or self._attack_requested
194        roll = Input.is_action_just_pressed("roll") or self._roll_requested
195        self._attack_requested = False
196        self._roll_requested = False
197        if self.is_rolling:
198            return  # Locked for the duration of the dash.
199
200        move = self._move_input()
201        if move.length() > 0:
202            move = move.normalized()
203            self._facing = move.copy()
204            # Accelerate towards the target velocity instead of snapping to it.
205            diff = move * self.speed - self._velocity
206            max_accel = PLAYER_ACCELERATION * dt
207            if diff.length() > max_accel:
208                diff = diff.normalized() * max_accel
209            self._velocity += diff
210        else:
211            self._apply_friction(dt)
212
213        if attack and self._attack_cooldown <= 0:
214            self._start_attack()
215        if roll:
216            self._start_roll()
217
218    def _move_input(self) -> Vec2:
219        """Keyboard steering, falling back to the on-screen stick."""
220        move = Vec2(0.0, 0.0)
221        if Input.is_action_pressed("move_up"):
222            move[1] -= 1.0
223        if Input.is_action_pressed("move_down"):
224            move[1] += 1.0
225        if Input.is_action_pressed("move_left"):
226            move[0] -= 1.0
227        if Input.is_action_pressed("move_right"):
228            move[0] += 1.0
229        return move if move.length() > 0 else self.touch_direction
230
231    def _apply_friction(self, dt: float):
232        speed = self._velocity.length()
233        friction = PLAYER_FRICTION * dt
234        if speed <= friction:
235            self._velocity = Vec2(0.0, 0.0)
236        else:
237            self._velocity = self._velocity * ((speed - friction) / speed)
238
239    # ── Pointer / touch entry points ─────────────────────────────────────────
240
241    def request_attack(self):
242        """Swing the sword on the next update (on-screen ATTACK button)."""
243        self._attack_requested = True
244
245    def request_roll(self):
246        """Roll on the next update (on-screen ROLL button)."""
247        self._roll_requested = True
248
249    # ── Roll ─────────────────────────────────────────────────────────────────
250
251    def _start_roll(self):
252        if self.is_rolling:
253            return
254        self.is_rolling = True
255        self._roll_timer = 0.0
256        self._roll_direction = self._facing.copy()
257        self._velocity = self._roll_direction * PLAYER_ROLL_SPEED
258        self._invulnerable = True
259        self._invuln_timer = PLAYER_ROLL_DURATION
260        self._flash_timer = 0.0
261        self._flash_visible = True
262
263    def _update_roll(self, dt: float):
264        self._roll_timer += dt
265        self._velocity = self._roll_direction * PLAYER_ROLL_SPEED
266        if self._roll_timer >= PLAYER_ROLL_DURATION:
267            self.is_rolling = False
268            # Leave the dash at walking pace so the roll flows into a run.
269            self._velocity = self._roll_direction * self.speed
270
271    # ── Attack ───────────────────────────────────────────────────────────────
272
273    def _start_attack(self):
274        self.is_attacking = True
275        self._attack_timer = 0.0
276        self._attack_cooldown = PLAYER_ATTACK_COOLDOWN
277        self._sword_rotation = math.atan2(self._facing[1], self._facing[0])
278
279    def _update_attack(self, dt: float):
280        self._attack_timer += dt
281        if self._attack_timer >= PLAYER_ATTACK_DURATION:
282            self.is_attacking = False
283
284    # ── Timers ───────────────────────────────────────────────────────────────
285
286    def _update_timers(self, dt: float):
287        if self.is_rolling:
288            self._update_roll(dt)
289        if self.is_attacking:
290            self._update_attack(dt)
291        if self._attack_cooldown > 0:
292            self._attack_cooldown -= dt
293
294        if self._invulnerable:
295            self._invuln_timer -= dt
296            self._flash_timer += dt
297            if self._flash_timer >= FLASH_INTERVAL:
298                self._flash_timer = 0.0
299                self._flash_visible = not self._flash_visible
300            if self._invuln_timer <= 0:
301                self._invulnerable = False
302                self._flash_visible = True
303
304        self.position += self._velocity * dt
305
306    # ── Damage ───────────────────────────────────────────────────────────────
307
308    def take_damage(self, amount: int = 1):
309        if self._invulnerable or self._dead:
310            return
311        self.stats.health -= amount
312        self._invulnerable = True
313        self._invuln_timer = PLAYER_INVULN_DURATION
314        self._flash_timer = 0.0
315        self._flash_visible = True
316
317    def get_sword_hitbox(self) -> tuple[Vec2, tuple[int, int], float] | None:
318        """``(centre, size, rotation_rad)`` of the sword, or None when idle."""
319        if not self.is_attacking:
320            return None
321        centre = self.position + _rotate(Vec2(*SWORD_HITBOX_OFFSET), self._sword_rotation)
322        return (centre, SWORD_HITBOX_SIZE, self._sword_rotation)
323
324    # ── Death ────────────────────────────────────────────────────────────────
325
326    def _on_health_changed(self, health: int):
327        self.health_changed.emit(health)
328
329    def _on_death(self):
330        self._dead = True
331        self._velocity = Vec2(0.0, 0.0)
332        self._invulnerable = False
333        self.is_rolling = False
334        self.is_attacking = False
335        self._respawn_timer = PLAYER_RESPAWN_DELAY
336
337    def _revive(self):
338        self._dead = False
339        self.stats.health = self.stats.max_health
340        self._invulnerable = True
341        self._invuln_timer = PLAYER_INVULN_DURATION
342        self._velocity = Vec2(0.0, 0.0)