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)