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- The gameplay actions ride on the player's own :class:`InputContext`, so they
9 are declared exactly while the player is in the tree.
10- The level hears about attacks, spells and hits through ``Signal``, so the
11 player never has to know what a level does with them.
12"""
13
14from __future__ import annotations
15
16from controls import EXPLORING
17from settings import magic_data, weapon_data
18from support import import_folder
19
20from simvx.core import Input, Node2D, Property, Signal, Vec2
21
22from .anim_sprite import FolderSprite
23
24PLAYER_SPRITE_W = 64
25PLAYER_SPRITE_H = 64
26
27#: How long the sprite tints red after taking a hit.
28FLASH_DURATION = 0.25
29
30#: Fixed part of the attack animation, before the weapon's own cooldown.
31ATTACK_WINDUP = 0.3
32
33
34class Player(Node2D):
35 """Top-down player with sword combat, magic, and stat upgrades."""
36
37 #: Walking and fighting, live only while the player is in the tree.
38 input_context = EXPLORING
39
40 # Current values.
41 hp = Property(100.0, range=(0, 9999))
42 energy = Property(60.0, range=(0, 9999))
43 exp = Property(500, range=(0, 999_999))
44
45 # Upgradeable stats. These Properties are what the game reads; the parallel
46 # ``stats`` dict below is the table the upgrade screen iterates over, and
47 # :meth:`upgrade_stat` is the single place that writes both.
48 max_hp = Property(100.0, range=(1, 9999))
49 max_energy = Property(60.0, range=(1, 9999))
50 base_attack = Property(10.0, range=(1, 99))
51 base_magic = Property(4.0, range=(0, 99))
52 speed = Property(220.0, range=(50, 600), hint="px/sec")
53
54 #: Upgrade-screen attribute name -> the Property mirroring it.
55 STAT_FIELDS = {
56 "health": "max_hp",
57 "energy": "max_energy",
58 "attack": "base_attack",
59 "magic": "base_magic",
60 "speed": "speed",
61 }
62
63 # What the level listens to.
64 attack_started = Signal() # a swing began: spawn the weapon sprite
65 attack_finished = Signal() # the swing ended: despawn it
66 magic_cast = Signal(str, float, int) # spell name, strength, energy cost
67 damaged = Signal(int, str) # amount, attack type
68
69 def __init__(self, **kwargs):
70 super().__init__(**kwargs)
71 self._direction = Vec2(0.0, 0.0)
72 self._status = "down"
73
74 # Combat state
75 self.attacking = False
76 self._attack_timer = 0.0
77 self.weapon = next(iter(weapon_data))
78 self._weapon_switch_cooldown = 0.0
79 self._switch_duration = 0.2
80
81 # Magic
82 self.magic = next(iter(magic_data))
83 self._magic_switch_cooldown = 0.0
84
85 # Damage / invulnerability
86 self.vulnerable = True
87 self._invuln_timer = 0.0
88 self._invuln_duration = 0.5
89 self._flash_timer = 0.0
90
91 # Stat tables the upgrade screen drives. Current values start from the
92 # Properties above so there is only one set of starting numbers.
93 self.stats = {name: float(getattr(self, field)) for name, field in self.STAT_FIELDS.items()}
94 self.max_stats = {"health": 300, "energy": 140, "attack": 20, "magic": 10, "speed": 400}
95 self.upgrade_cost = dict.fromkeys(self.STAT_FIELDS, 100)
96
97 # Animation library: built lazily on ready (assets must exist).
98 self._anims: dict[str, list[str]] = {}
99 self._sprite: FolderSprite | None = None
100
101 # Total kill count (for HUD / stats).
102 self.kills = 0
103
104 # -- lifecycle ----------------------------------------------------------
105
106 def on_ready(self):
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.emit()
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 # One typed action: the keys, a controller's left stick and the pad
166 # drawn on the glass all arrive here already normalised.
167 d = Input.value("move")
168 if d.x != 0.0 or d.y != 0.0:
169 # Face along whichever axis is being pushed hardest. A keyboard
170 # diagonal is an exact tie, which reads as vertical, the way the
171 # original does.
172 if abs(d.x) > abs(d.y):
173 self._status = "right" if d.x > 0 else "left"
174 else:
175 self._status = "down" if d.y > 0 else "up"
176 self._direction = d
177
178 if Input.just_pressed("attack"):
179 self.try_attack()
180 if Input.just_pressed("magic"):
181 self.try_cast_magic()
182 if Input.just_pressed("weapon_swap"):
183 self.cycle_weapon()
184 if Input.just_pressed("magic_swap"):
185 self.cycle_magic()
186
187 # -- actions ------------------------------------------------------------
188
189 def try_attack(self) -> None:
190 """Start a swing. No-op while one is already running."""
191 if self.attacking:
192 return
193 self.attacking = True
194 self._attack_timer = ATTACK_WINDUP + weapon_data[self.weapon]["cooldown"]
195 self.attack_started.emit()
196
197 def try_cast_magic(self) -> None:
198 """Cast the selected spell if there is enough energy for it."""
199 if self.attacking:
200 return
201 info = magic_data[self.magic]
202 if self.energy < info["cost"]:
203 return
204 self.attacking = True
205 self._attack_timer = ATTACK_WINDUP
206 self.magic_cast.emit(self.magic, info["strength"] + self.base_magic, info["cost"])
207
208 def cycle_weapon(self) -> None:
209 """Switch to the next weapon (rate-limited, like the upstream)."""
210 if self._weapon_switch_cooldown > 0:
211 return
212 names = list(weapon_data)
213 self.weapon = names[(names.index(self.weapon) + 1) % len(names)]
214 self._weapon_switch_cooldown = self._switch_duration
215
216 def cycle_magic(self) -> None:
217 """Switch to the next spell (rate-limited, like the upstream)."""
218 if self._magic_switch_cooldown > 0:
219 return
220 names = list(magic_data)
221 self.magic = names[(names.index(self.magic) + 1) % len(names)]
222 self._magic_switch_cooldown = self._switch_duration
223
224 # -- movement & animation ----------------------------------------------
225
226 def _move(self, dt: float):
227 # Movement is pre-collision; level.py applies wall pushback.
228 self.position += self._direction * self.speed * dt
229
230 def _update_status(self):
231 # Strip _attack/_idle suffixes when state changes.
232 base = self._status.split("_")[0]
233 if self.attacking:
234 self._status = f"{base}_attack"
235 elif self._direction.x == 0 and self._direction.y == 0:
236 self._status = f"{base}_idle"
237 else:
238 self._status = base
239
240 def _update_animation(self):
241 sprite = self._sprite
242 if sprite is None:
243 return
244 anim = self._anims.get(self._status) or self._anims["down"]
245 if anim is sprite._frames:
246 return
247 sprite.play(anim, fps=10.0, loop=not self.attacking)
248
249 def _update_flash(self, dt: float):
250 """Tint the sprite red while the hit-flash timer runs."""
251 if self._sprite is None or self._flash_timer <= 0:
252 return
253 self._flash_timer = max(0.0, self._flash_timer - dt)
254 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)
255
256 def _recover_energy(self, dt: float):
257 if self.energy < self.max_energy:
258 self.energy = min(self.max_energy, self.energy + (0.6 + 0.4 * self.base_magic) * dt)
259
260 # -- combat -------------------------------------------------------------
261
262 def take_damage(self, amount: int, attack_type: str) -> None:
263 """Apply enemy damage unless the player is still invulnerable."""
264 if not self.vulnerable:
265 return
266 self.hp = max(0.0, self.hp - amount)
267 self._flash_timer = FLASH_DURATION
268 self.grant_invulnerability(self._invuln_duration)
269 self.damaged.emit(amount, attack_type)
270
271 def grant_invulnerability(self, duration: float) -> None:
272 """Ignore incoming damage for *duration* seconds."""
273 self.vulnerable = False
274 self._invuln_timer = duration
275
276 def revive(self) -> None:
277 """Full-heal after a knockout, with a moment of invulnerability."""
278 self.hp = self.max_hp
279 self.energy = self.max_energy
280 self.grant_invulnerability(1.0)
281
282 def get_full_weapon_damage(self) -> int:
283 return int(self.base_attack + weapon_data[self.weapon]["damage"])
284
285 def get_full_magic_damage(self) -> int:
286 return int(self.base_magic + magic_data[self.magic]["strength"])
287
288 # -- upgrades -----------------------------------------------------------
289
290 def upgrade_stat(self, attr: str) -> bool:
291 """Spend EXP to raise *attr*. Returns True when the purchase went through."""
292 cost = self.upgrade_cost[attr]
293 if self.exp < cost or self.stats[attr] >= self.max_stats[attr]:
294 return False
295 self.exp -= cost
296 self.stats[attr] = min(self.max_stats[attr], self.stats[attr] * 1.2)
297 self.upgrade_cost[attr] = int(cost * 1.4)
298 setattr(self, self.STAT_FIELDS[attr], self.stats[attr])
299 return True
300
301 # -- weapon facing helpers ---------------------------------------------
302
303 def facing_vector(self) -> Vec2:
304 base = self._status.split("_")[0]
305 return {
306 "up": Vec2(0.0, -1.0),
307 "down": Vec2(0.0, 1.0),
308 "left": Vec2(-1.0, 0.0),
309 "right": Vec2(1.0, 0.0),
310 }.get(base, Vec2(0.0, 1.0))
311
312 @property
313 def status(self) -> str:
314 return self._status
315
316 @property
317 def weapon_switching(self) -> bool:
318 """True while the weapon-swap cooldown runs; the HUD highlights the box."""
319 return self._weapon_switch_cooldown > 0
320
321 @property
322 def magic_switching(self) -> bool:
323 """True while the spell-swap cooldown runs; the HUD highlights the box."""
324 return self._magic_switch_cooldown > 0