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