nodes/player.py¶
Part of Clear Code Zelda.
1"""Player character: 8-direction movement, sword attack, magic, weapon/spell switch.
2
3Mirrors upstream ``code/player.py`` but uses SimVX nodes:
4
5- ``Node2D`` root with a child :class:`FolderSprite` for animation.
6- Movement timed in seconds (not frames). ``speed`` is px/sec.
7- Movement is pre-collision: the level pushes the player back out of walls.
8- Attack/magic/weapon-switch keys go through ``InputMap`` actions.
9- The level hears about attacks, spells and hits through ``Signal``, so the
10 player never has to know what a level does with them.
11"""
12
13from __future__ import annotations
14
15from settings import magic_data, weapon_data
16from support import import_folder
17
18from simvx.core import Input, Node2D, Property, Signal, Vec2
19
20from .anim_sprite import FolderSprite
21
22PLAYER_SPRITE_W = 64
23PLAYER_SPRITE_H = 64
24
25#: How long the sprite tints red after taking a hit.
26FLASH_DURATION = 0.25
27
28#: Fixed part of the attack animation, before the weapon's own cooldown.
29ATTACK_WINDUP = 0.3
30
31
32class Player(Node2D):
33 """Top-down player with sword combat, magic, and stat upgrades."""
34
35 # Current values.
36 hp = Property(100.0, range=(0, 9999))
37 energy = Property(60.0, range=(0, 9999))
38 exp = Property(500, range=(0, 999_999))
39
40 # Upgradeable stats. These Properties are what the game reads; the parallel
41 # ``stats`` dict below is the table the upgrade screen iterates over, and
42 # :meth:`upgrade_stat` is the single place that writes both.
43 max_hp = Property(100.0, range=(1, 9999))
44 max_energy = Property(60.0, range=(1, 9999))
45 base_attack = Property(10.0, range=(1, 99))
46 base_magic = Property(4.0, range=(0, 99))
47 speed = Property(220.0, range=(50, 600), hint="px/sec")
48
49 #: Upgrade-screen attribute name -> the Property mirroring it.
50 STAT_FIELDS = {
51 "health": "max_hp",
52 "energy": "max_energy",
53 "attack": "base_attack",
54 "magic": "base_magic",
55 "speed": "speed",
56 }
57
58 # What the level listens to.
59 attack_started = Signal() # a swing began: spawn the weapon sprite
60 attack_finished = Signal() # the swing ended: despawn it
61 magic_cast = Signal(str, float, int) # spell name, strength, energy cost
62 damaged = Signal(int, str) # amount, attack type
63
64 def __init__(self, **kwargs):
65 super().__init__(**kwargs)
66 self._direction = Vec2(0.0, 0.0)
67 self._status = "down"
68
69 # Combat state
70 self.attacking = False
71 self._attack_timer = 0.0
72 self.weapon = next(iter(weapon_data))
73 self._weapon_switch_cooldown = 0.0
74 self._switch_duration = 0.2
75
76 # Magic
77 self.magic = next(iter(magic_data))
78 self._magic_switch_cooldown = 0.0
79
80 # Damage / invulnerability
81 self.vulnerable = True
82 self._invuln_timer = 0.0
83 self._invuln_duration = 0.5
84 self._flash_timer = 0.0
85
86 # Stat tables the upgrade screen drives. Current values start from the
87 # Properties above so there is only one set of starting numbers.
88 self.stats = {name: float(getattr(self, field)) for name, field in self.STAT_FIELDS.items()}
89 self.max_stats = {"health": 300, "energy": 140, "attack": 20, "magic": 10, "speed": 400}
90 self.upgrade_cost = dict.fromkeys(self.STAT_FIELDS, 100)
91
92 # Steering from the on-screen thumb-stick, written by the level each
93 # frame. Zero when the pointer is not driving the player.
94 self.touch_direction = Vec2(0.0, 0.0)
95
96 # Animation library: built lazily on ready (assets must exist).
97 self._anims: dict[str, list[str]] = {}
98 self._sprite: FolderSprite | None = None
99
100 # Total kill count (for HUD / stats).
101 self.kills = 0
102
103 # -- lifecycle ----------------------------------------------------------
104
105 def on_ready(self):
106 # Action registration happens at the *root* level; player just queries.
107 self._anims = {
108 d: import_folder(f"player/{d}")
109 for d in (
110 "down",
111 "up",
112 "left",
113 "right",
114 "down_idle",
115 "up_idle",
116 "left_idle",
117 "right_idle",
118 "down_attack",
119 "up_attack",
120 "left_attack",
121 "right_attack",
122 )
123 }
124 self._sprite = self.add_child(
125 FolderSprite(
126 frames=self._anims["down_idle"],
127 fps=8.0,
128 width=PLAYER_SPRITE_W,
129 height=PLAYER_SPRITE_H,
130 name="PlayerSprite",
131 )
132 )
133
134 def on_update(self, dt: float):
135 # Tick all the cooldowns up front (single source of truth).
136 if self._invuln_timer > 0:
137 self._invuln_timer -= dt
138 if self._invuln_timer <= 0:
139 self.vulnerable = True
140 if self._weapon_switch_cooldown > 0:
141 self._weapon_switch_cooldown = max(0.0, self._weapon_switch_cooldown - dt)
142 if self._magic_switch_cooldown > 0:
143 self._magic_switch_cooldown = max(0.0, self._magic_switch_cooldown - dt)
144
145 if self.attacking:
146 self._attack_timer -= dt
147 if self._attack_timer <= 0:
148 self.attacking = False
149 self.attack_finished()
150
151 self._handle_input()
152 self._update_status()
153 self._move(dt)
154 self._recover_energy(dt)
155 self._update_flash(dt)
156 self._update_animation()
157
158 # -- input --------------------------------------------------------------
159
160 def _handle_input(self):
161 if self.attacking:
162 self._direction = Vec2(0.0, 0.0)
163 return
164
165 d = Vec2(0.0, 0.0)
166 if Input.is_action_pressed("move_up"):
167 d.y -= 1
168 self._status = "up"
169 elif Input.is_action_pressed("move_down"):
170 d.y += 1
171 self._status = "down"
172
173 if Input.is_action_pressed("move_right"):
174 d.x += 1
175 self._status = "right"
176 elif Input.is_action_pressed("move_left"):
177 d.x -= 1
178 self._status = "left"
179
180 if d.x != 0 and d.y != 0:
181 # Normalise the keyboard diagonal; the thumb-stick is already unit-clamped.
182 d *= 0.7071067811865475
183 elif d.x == 0 and d.y == 0:
184 d = self._steer_from_touch()
185
186 self._direction = d
187
188 if Input.is_action_just_pressed("attack"):
189 self.try_attack()
190 if Input.is_action_just_pressed("magic"):
191 self.try_cast_magic()
192 if Input.is_action_just_pressed("weapon_swap"):
193 self.cycle_weapon()
194 if Input.is_action_just_pressed("magic_swap"):
195 self.cycle_magic()
196
197 def _steer_from_touch(self) -> Vec2:
198 """Direction from the on-screen stick, facing set from its dominant axis."""
199 d = Vec2(self.touch_direction.x, self.touch_direction.y)
200 if d.x == 0 and d.y == 0:
201 return d
202 if abs(d.x) > abs(d.y):
203 self._status = "right" if d.x > 0 else "left"
204 else:
205 self._status = "down" if d.y > 0 else "up"
206 return d
207
208 # -- actions (shared by keyboard and the on-screen buttons) -------------
209
210 def try_attack(self) -> None:
211 """Start a swing. No-op while one is already running."""
212 if self.attacking:
213 return
214 self.attacking = True
215 self._attack_timer = ATTACK_WINDUP + weapon_data[self.weapon]["cooldown"]
216 self.attack_started()
217
218 def try_cast_magic(self) -> None:
219 """Cast the selected spell if there is enough energy for it."""
220 if self.attacking:
221 return
222 info = magic_data[self.magic]
223 if self.energy < info["cost"]:
224 return
225 self.attacking = True
226 self._attack_timer = ATTACK_WINDUP
227 self.magic_cast(self.magic, info["strength"] + self.base_magic, info["cost"])
228
229 def cycle_weapon(self) -> None:
230 """Switch to the next weapon (rate-limited, like the upstream)."""
231 if self._weapon_switch_cooldown > 0:
232 return
233 names = list(weapon_data)
234 self.weapon = names[(names.index(self.weapon) + 1) % len(names)]
235 self._weapon_switch_cooldown = self._switch_duration
236
237 def cycle_magic(self) -> None:
238 """Switch to the next spell (rate-limited, like the upstream)."""
239 if self._magic_switch_cooldown > 0:
240 return
241 names = list(magic_data)
242 self.magic = names[(names.index(self.magic) + 1) % len(names)]
243 self._magic_switch_cooldown = self._switch_duration
244
245 # -- movement & animation ----------------------------------------------
246
247 def _move(self, dt: float):
248 # Movement is pre-collision; level.py applies wall pushback.
249 self.position += self._direction * self.speed * dt
250
251 def _update_status(self):
252 # Strip _attack/_idle suffixes when state changes.
253 base = self._status.split("_")[0]
254 if self.attacking:
255 self._status = f"{base}_attack"
256 elif self._direction.x == 0 and self._direction.y == 0:
257 self._status = f"{base}_idle"
258 else:
259 self._status = base
260
261 def _update_animation(self):
262 sprite = self._sprite
263 if sprite is None:
264 return
265 anim = self._anims.get(self._status) or self._anims["down"]
266 if anim is sprite._frames:
267 return
268 sprite.play(anim, fps=10.0, loop=not self.attacking)
269
270 def _update_flash(self, dt: float):
271 """Tint the sprite red while the hit-flash timer runs."""
272 if self._sprite is None or self._flash_timer <= 0:
273 return
274 self._flash_timer = max(0.0, self._flash_timer - dt)
275 self._sprite.colour = (1.0, 0.35, 0.35, 1.0) if self._flash_timer > 0 else (1.0, 1.0, 1.0, 1.0)
276
277 def _recover_energy(self, dt: float):
278 if self.energy < self.max_energy:
279 self.energy = min(self.max_energy, self.energy + (0.6 + 0.4 * self.base_magic) * dt)
280
281 # -- combat -------------------------------------------------------------
282
283 def take_damage(self, amount: int, attack_type: str) -> None:
284 """Apply enemy damage unless the player is still invulnerable."""
285 if not self.vulnerable:
286 return
287 self.hp = max(0.0, self.hp - amount)
288 self._flash_timer = FLASH_DURATION
289 self.grant_invulnerability(self._invuln_duration)
290 self.damaged(amount, attack_type)
291
292 def grant_invulnerability(self, duration: float) -> None:
293 """Ignore incoming damage for *duration* seconds."""
294 self.vulnerable = False
295 self._invuln_timer = duration
296
297 def revive(self) -> None:
298 """Full-heal after a knockout, with a moment of invulnerability."""
299 self.hp = self.max_hp
300 self.energy = self.max_energy
301 self.grant_invulnerability(1.0)
302
303 def get_full_weapon_damage(self) -> int:
304 return int(self.base_attack + weapon_data[self.weapon]["damage"])
305
306 def get_full_magic_damage(self) -> int:
307 return int(self.base_magic + magic_data[self.magic]["strength"])
308
309 # -- upgrades -----------------------------------------------------------
310
311 def upgrade_stat(self, attr: str) -> bool:
312 """Spend EXP to raise *attr*. Returns True when the purchase went through."""
313 cost = self.upgrade_cost[attr]
314 if self.exp < cost or self.stats[attr] >= self.max_stats[attr]:
315 return False
316 self.exp -= cost
317 self.stats[attr] = min(self.max_stats[attr], self.stats[attr] * 1.2)
318 self.upgrade_cost[attr] = int(cost * 1.4)
319 setattr(self, self.STAT_FIELDS[attr], self.stats[attr])
320 return True
321
322 # -- weapon facing helpers ---------------------------------------------
323
324 def facing_vector(self) -> Vec2:
325 base = self._status.split("_")[0]
326 return {
327 "up": Vec2(0.0, -1.0),
328 "down": Vec2(0.0, 1.0),
329 "left": Vec2(-1.0, 0.0),
330 "right": Vec2(1.0, 0.0),
331 }.get(base, Vec2(0.0, 1.0))
332
333 @property
334 def status(self) -> str:
335 return self._status
336
337 @property
338 def weapon_switching(self) -> bool:
339 """True while the weapon-swap cooldown runs; the HUD highlights the box."""
340 return self._weapon_switch_cooldown > 0
341
342 @property
343 def magic_switching(self) -> bool:
344 """True while the spell-swap cooldown runs; the HUD highlights the box."""
345 return self._magic_switch_cooldown > 0