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)