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