nodes/player.py¶

Part of Dungeon Explorer.

  1"""Player character: CharacterBody2D with 8-direction movement, combat, and dodge roll."""
  2
  3import math
  4
  5from collision_layers import LAYER_PLAYER, MASK_WORLD_ONLY
  6from events import PlayerDied, PlayerLevelledUp
  7
  8from simvx.core import CharacterBody2D, CircleShape2D, Input, InputMap, Key, Property, Vec2
  9
 10# Player visual half-size (drawn rect), collision is separate
 11PLAYER_W = 10
 12PLAYER_H = 10
 13
 14# Dodge roll
 15DODGE_SPEED_MULT = 3.0
 16DODGE_DURATION = 0.25
 17DODGE_COOLDOWN = 0.6
 18
 19# Attack lunge
 20LUNGE_DISTANCE = 3.0
 21LUNGE_SNAP_BACK = 0.1
 22
 23# Damage flash
 24FLASH_DURATION = 0.1
 25
 26
 27class Player(CharacterBody2D):
 28    """Top-down player character with 8-direction movement and collision."""
 29
 30    __save_version__ = 1
 31
 32    speed = Property(120.0, range=(50, 300), hint="Movement speed in pixels/sec", group="Movement")
 33    hp = Property(100, range=(0, 9999), hint="Current health", group="Stats", persist=True, save_version=1)
 34    max_hp = Property(100, range=(1, 999), hint="Maximum health", group="Stats", persist=True, save_version=1)
 35    level = Property(1, range=(1, 100), hint="Character level", group="Stats", persist=True, save_version=1)
 36    xp = Property(0, range=(0, 999999), hint="Current experience", group="Stats", persist=True, save_version=1)
 37    xp_to_next = Property(
 38        100, range=(1, 999999), hint="XP needed for next level", group="Stats", persist=True, save_version=1
 39    )
 40    gold = Property(0, range=(0, 999999), hint="Gold", group="Stats", persist=True, save_version=1)
 41
 42    # Stats
 43    strength = Property(5, range=(1, 99), group="Stats", persist=True, save_version=1)
 44    dexterity = Property(5, range=(1, 99), group="Stats", persist=True, save_version=1)
 45    vitality = Property(5, range=(1, 99), group="Stats", persist=True, save_version=1)
 46    intelligence = Property(5, range=(1, 99), group="Stats", persist=True, save_version=1)
 47    stat_points = Property(0, range=(0, 99), group="Stats", persist=True, save_version=1)
 48    skill_points = Property(0, range=(0, 99), group="Stats", persist=True, save_version=1)
 49
 50    # Death, level-up and hp changes are surfaced as typed events on
 51    # ``self.tree.events``: :class:`PlayerDied`, :class:`PlayerLevelledUp`.
 52    # Connect via ``tree.events.subscribe(EventCls, handler)``.
 53
 54    def __init__(self, **kwargs):
 55        # The player sweeps against the dungeon walls and nothing else: enemies and
 56        # NPCs are on their own layers so they cannot body-block it.
 57        kwargs.setdefault("collision_layer", LAYER_PLAYER)
 58        kwargs.setdefault("collision_mask", MASK_WORLD_ONLY)
 59        super().__init__(shape=CircleShape2D(10.0), **kwargs)
 60        self._facing = Vec2(0, 1)
 61        self._dodge_timer = 0.0
 62        self._dodge_cooldown_timer = 0.0
 63        self._dodge_dir = Vec2()
 64        self._dodge_start_flash = 0.0
 65        self._dodge_end_flash = 0.0
 66        self._dodge_sparkle_timer = 0.0
 67        self._invincible = False
 68        # Analog stick vector pushed in by the on-screen gamepad (touch/mouse play).
 69        self._virtual_input: tuple[float, float] | None = None
 70        self._attack_cooldown = 0.0
 71        # Damage flash
 72        self._flash_timer = 0.0
 73        self._flash_colour = (1.0, 0.2, 0.2, 0.8)  # Red by default
 74        # Attack lunge
 75        self._lunge_timer = 0.0
 76        self._lunge_offset = Vec2()
 77        # Kill combo
 78        self._combo_count = 0
 79        self._combo_timer = 0.0
 80        self._combo_window = 2.0  # Seconds between kills for combo
 81        # Melee arc visual
 82        self._arc_timer = 0.0
 83        self._arc_angle = 0.0
 84        self._arc_colour = (1.0, 1.0, 1.0, 1.0)
 85        # Dodge afterimage
 86        self._afterimages: list[tuple[Vec2, float]] = []
 87        # Weapon tint (set by game when weapon changes)
 88        self._weapon_tint = (0.2, 0.5, 0.9, 1.0)  # Default blue
 89        # Walk animation timer
 90        self._walk_timer = 0.0
 91        # Shield block
 92        self._blocking = False
 93        self._block_direction = Vec2(0, 1)
 94        self._has_shield = False
 95        self._block_flash_timer = 0.0
 96
 97    def on_ready(self):
 98        InputMap.add_action("move_up", [Key.W, Key.UP])
 99        InputMap.add_action("move_down", [Key.S, Key.DOWN])
100        InputMap.add_action("move_left", [Key.A, Key.LEFT])
101        InputMap.add_action("move_right", [Key.D, Key.RIGHT])
102        InputMap.add_action("attack", [Key.SPACE])
103        InputMap.add_action("dodge", [Key.LEFT_SHIFT])
104
105    def on_fixed_update(self, dt: float):
106        self._dodge_cooldown_timer = max(0.0, self._dodge_cooldown_timer - dt)
107        self._attack_cooldown = max(0.0, self._attack_cooldown - dt)
108        self._flash_timer = max(0.0, self._flash_timer - dt)
109        self._dodge_end_flash = max(0.0, self._dodge_end_flash - dt)
110        if self._block_flash_timer > 0:
111            self._block_flash_timer -= dt
112
113        # Combo timer
114        if self._combo_timer > 0:
115            self._combo_timer -= dt
116            if self._combo_timer <= 0:
117                self._combo_count = 0
118
119        # Lunge snap-back
120        if self._lunge_timer > 0:
121            self._lunge_timer -= dt
122            if self._lunge_timer <= 0:
123                self._lunge_offset = Vec2()
124
125        # Arc timer
126        if self._arc_timer > 0:
127            self._arc_timer = max(0.0, self._arc_timer - dt)
128
129        # Afterimage decay
130        self._afterimages = [(p, t - dt) for p, t in self._afterimages if t - dt > 0]
131
132        if self._dodge_timer > 0:
133            self._process_dodge(dt)
134        else:
135            self._process_movement(dt)
136
137    def _process_movement(self, dt: float):
138        """Handle 8-directional input movement."""
139        if self._blocking:
140            self.velocity = Vec2()
141            if Input.is_action_just_released("dodge"):
142                self._end_block()
143            return
144
145        direction = Vec2()
146        analog_magnitude = 1.0
147        # Virtual input override (for virtual gamepad)
148        vi = self._virtual_input
149        if vi is not None and (vi[0] * vi[0] + vi[1] * vi[1]) > 0.01:
150            direction = Vec2(vi[0], vi[1])
151        else:
152            if Input.is_action_pressed("move_up"):
153                direction.y -= 1
154            if Input.is_action_pressed("move_down"):
155                direction.y += 1
156            if Input.is_action_pressed("move_left"):
157                direction.x -= 1
158            if Input.is_action_pressed("move_right"):
159                direction.x += 1
160
161        length = math.sqrt(direction.x**2 + direction.y**2)
162        if length > 0:
163            # Preserve analog magnitude from joystick, clamp to [0, 1]
164            analog_magnitude = min(1.0, length)
165            direction = direction * (1.0 / length)
166            self._facing = Vec2(direction)
167
168        self.velocity = direction * (self.speed * analog_magnitude)
169        self.move_and_slide(dt)
170        if length > 0:
171            self._walk_timer += dt * 8.0
172        else:
173            self._walk_timer = 0.0
174
175        if Input.is_action_just_pressed("dodge"):
176            if self._has_shield:
177                self._start_block()
178            elif self._dodge_cooldown_timer <= 0 and length > 0:
179                self._start_dodge()
180
181    def _start_dodge(self):
182        self._dodge_timer = DODGE_DURATION
183        self._dodge_cooldown_timer = DODGE_COOLDOWN
184        self._dodge_dir = Vec2(self._facing)
185        self._invincible = True
186        self._dodge_start_flash = 0.08  # Start flash
187        self._dodge_sparkle_timer = 0.0
188
189    def _process_dodge(self, dt: float):
190        self._dodge_timer -= dt
191        self._dodge_sparkle_timer += dt
192        if self._dodge_start_flash > 0:
193            self._dodge_start_flash -= dt
194        if self._dodge_timer <= 0:
195            self._dodge_timer = 0
196            self._invincible = False
197            self._dodge_end_flash = 0.08  # End flash
198            return
199        # Record afterimage with facing for full-shape reproduction
200        if len(self._afterimages) == 0 or (self._afterimages and self._afterimages[-1][1] < 0.12):
201            self._afterimages.append((Vec2(self.position), 0.2))
202        self.velocity = self._dodge_dir * self.speed * DODGE_SPEED_MULT
203        self.move_and_slide(dt)
204
205    def _start_block(self):
206        self._blocking = True
207        self._block_direction = Vec2(self._facing)
208
209    def _end_block(self):
210        self._blocking = False
211        self._block_flash_timer = 0.0
212
213    @property
214    def is_blocking(self) -> bool:
215        return self._blocking
216
217    @property
218    def is_dodging(self) -> bool:
219        return self._dodge_timer > 0
220
221    @property
222    def is_invincible(self) -> bool:
223        return self._invincible
224
225    @property
226    def facing(self) -> Vec2:
227        return Vec2(self._facing)
228
229    @property
230    def combo_count(self) -> int:
231        return self._combo_count
232
233    def take_damage(self, amount: int) -> None:
234        """Apply damage. Respects invincibility. Triggers red flash."""
235        if self._invincible or self.hp <= 0:
236            return
237        self.hp = max(0, self.hp - amount)
238        self._flash_timer = FLASH_DURATION
239        self._flash_colour = (1.0, 0.2, 0.2, 0.8)
240        if self.hp <= 0 and self.tree is not None:
241            self.tree.events.publish(PlayerDied(player=self))
242
243    def trigger_crit_flash(self):
244        """Flash yellow on crit hit."""
245        self._flash_timer = FLASH_DURATION
246        self._flash_colour = (1.0, 1.0, 0.2, 0.8)
247
248    def start_attack_lunge(self):
249        """Brief forward lunge during attack."""
250        self._lunge_offset = Vec2(self._facing.x * LUNGE_DISTANCE, self._facing.y * LUNGE_DISTANCE)
251        self._lunge_timer = LUNGE_SNAP_BACK
252
253    def start_melee_arc(self, spread: float = 1.05, colour: tuple = (1.0, 1.0, 1.0, 1.0)):
254        """Show a melee arc visual."""
255        self._arc_timer = 0.12
256        self._arc_angle = spread
257        self._arc_colour = colour
258
259    def register_kill(self):
260        """Register a kill for combo tracking. Returns combo count."""
261        if self._combo_timer > 0:
262            self._combo_count += 1
263        else:
264            self._combo_count = 1
265        self._combo_timer = self._combo_window
266        return self._combo_count
267
268    def set_weapon_tint(self, tint: tuple) -> None:
269        """Set body colour tint based on equipped weapon type."""
270        self._weapon_tint = tint
271
272    def heal(self, amount: int) -> None:
273        self.hp = min(self.max_hp, self.hp + amount)
274
275    def gain_xp(self, amount: int) -> None:
276        self.xp += amount
277        while self.xp >= self.xp_to_next:
278            self.xp -= self.xp_to_next
279            self.level += 1
280            self.stat_points += 3
281            self.skill_points += 1
282            self.xp_to_next = int(self.xp_to_next * 1.15)
283            self.max_hp += 5 + self.vitality
284            self.hp = self.max_hp
285            if self.tree is not None:
286                self.tree.events.publish(PlayerLevelledUp(player=self, new_level=self.level))
287
288    def on_draw(self, renderer):
289        hw = PLAYER_W
290        body_r = hw  # Body radius for circle
291
292        # Dodge afterimages: full-shape (circle + triangle)
293        for ai_pos, ai_timer in self._afterimages:
294            alpha = ai_timer / 0.2 * 0.4
295            renderer.draw_circle((ai_pos.x, ai_pos.y), body_r, colour=(0.4, 0.6, 1.0, alpha), filled=True)
296            # Afterimage triangle (using current facing)
297            fai = math.atan2(self._facing.y, self._facing.x)
298            t_tip_x = ai_pos.x + self._facing.x * (body_r + 5)
299            t_tip_y = ai_pos.y + self._facing.y * (body_r + 5)
300            t_bl_x = ai_pos.x + math.cos(fai + 2.4) * body_r
301            t_bl_y = ai_pos.y + math.sin(fai + 2.4) * body_r
302            t_br_x = ai_pos.x + math.cos(fai - 2.4) * body_r
303            t_br_y = ai_pos.y + math.sin(fai - 2.4) * body_r
304            renderer.fill_triangle(
305                t_tip_x, t_tip_y, t_bl_x, t_bl_y, t_br_x, t_br_y, colour=(1.0, 0.9, 0.3, alpha * 0.7)
306            )
307
308        px = self.position.x + self._lunge_offset.x
309        py = self.position.y + self._lunge_offset.y
310
311        # Dodge start/end flash (both ticked in on_fixed_update)
312        if self._dodge_end_flash > 0:
313            flash_alpha = self._dodge_end_flash / 0.08
314            renderer.draw_circle((px, py), body_r + 6, colour=(1.0, 1.0, 1.0, flash_alpha * 0.5), filled=True)
315        if self._dodge_start_flash > 0:
316            flash_alpha = self._dodge_start_flash / 0.08
317            renderer.draw_circle((px, py), body_r + 4, colour=(0.7, 0.85, 1.0, flash_alpha * 0.6), filled=True)
318
319        # Speed lines during dodge
320        if self.is_dodging:
321            for i in range(3):
322                offset = (i - 1) * 6
323                perp = Vec2(-self._dodge_dir.y, self._dodge_dir.x)
324                lx = px - self._dodge_dir.x * 15 + perp.x * offset
325                ly = py - self._dodge_dir.y * 15 + perp.y * offset
326                lx2 = lx - self._dodge_dir.x * 12
327                ly2 = ly - self._dodge_dir.y * 12
328                renderer.draw_line((lx, ly), (lx2, ly2), colour=(0.6, 0.8, 1.0, 0.5))
329
330        # Invincibility sparkle during dodge
331        if self.is_dodging:
332            sparkle_phase = self._dodge_sparkle_timer * 15.0
333            for i in range(4):
334                angle = sparkle_phase + i * (math.pi / 2)
335                sx = px + math.cos(angle) * (body_r + 3)
336                sy = py + math.sin(angle) * (body_r + 3)
337                renderer.draw_rect((sx - 1, sy - 1), (2, 2), colour=(0.9, 0.95, 1.0, 0.7), filled=True)
338
339        # Body colour: flash on damage/dodge, otherwise weapon tint
340        if self._flash_timer > 0:
341            body_colour = self._flash_colour
342        elif self.is_dodging:
343            body_colour = (0.9, 0.9, 1.0, 0.6)
344        else:
345            body_colour = self._weapon_tint
346
347        # Arms (animated when walking)
348        arm_swing = math.sin(self._walk_timer) * 4.0
349        perp_x, perp_y = -self._facing.y, self._facing.x
350        for side in (-1, 1):
351            ax = px + perp_x * (body_r + 2) * side
352            ay = py + perp_y * (body_r + 2) * side + arm_swing * side
353            renderer.draw_rect((ax - 2, ay - 2), (4, 4), colour=(0.15, 0.4, 0.75, 0.9), filled=True)
354
355        # Round body
356        renderer.draw_circle((px, py), body_r, colour=body_colour, filled=True)
357
358        # Facing indicator: triangle pointing in facing direction
359        fang = math.atan2(self._facing.y, self._facing.x)
360        tip_x = px + self._facing.x * (body_r + 5)
361        tip_y = py + self._facing.y * (body_r + 5)
362        base_l_x = px + math.cos(fang + 2.4) * body_r
363        base_l_y = py + math.sin(fang + 2.4) * body_r
364        base_r_x = px + math.cos(fang - 2.4) * body_r
365        base_r_y = py + math.sin(fang - 2.4) * body_r
366        renderer.fill_triangle(tip_x, tip_y, base_l_x, base_l_y, base_r_x, base_r_y, colour=(1.0, 0.9, 0.3, 0.9))
367
368        # Melee arc visual
369        if self._arc_timer > 0:
370            arc_alpha = self._arc_timer / 0.12
371            ac = self._arc_colour
372            colour = (ac[0], ac[1], ac[2], arc_alpha)
373            half = self._arc_angle / 2
374            length = body_r + 12
375            for side_a in (-1, 1):
376                angle = fang + side_a * half
377                ex = px + math.cos(angle) * length
378                ey = py + math.sin(angle) * length
379                renderer.draw_line((px, py), (ex, ey), colour=colour)
380
381        # Health bar above player (only if damaged)
382        if self.hp < self.max_hp:
383            bar_w = body_r * 2
384            bar_h = 3
385            bar_x = px - body_r
386            bar_y = py - body_r - 6
387            renderer.draw_rect((bar_x, bar_y), (bar_w, bar_h), colour=(0.3, 0.0, 0.0, 0.8), filled=True)
388            fill_w = bar_w * (self.hp / max(1, self.max_hp))
389            renderer.draw_rect((bar_x, bar_y), (fill_w, bar_h), colour=(0.0, 0.8, 0.0, 0.8), filled=True)
390
391        # Shield block arc
392        if self._blocking:
393            fang = math.atan2(self._block_direction.y, self._block_direction.x)
394            shield_r = body_r + 8
395            # Draw arc of small rects forming a shield curve
396            shield_colour = (0.5, 0.7, 1.0, 0.7)
397            if self._block_flash_timer > 0:
398                flash_a = min(1.0, self._block_flash_timer / 0.15)
399                shield_colour = (1.0, 1.0, 1.0, 0.5 + 0.5 * flash_a)
400            for k in range(-3, 4):
401                angle = fang + k * 0.25
402                sx = px + math.cos(angle) * shield_r
403                sy = py + math.sin(angle) * shield_r
404                renderer.draw_rect((sx - 2, sy - 2), (4, 4), colour=shield_colour, filled=True)
405
406        # Combo counter
407        if self._combo_count >= 2:
408            renderer.draw_text(
409                f"x{self._combo_count} COMBO!", (px - 20, py - body_r - 20), scale=1.0, colour=(1.0, 0.7, 0.0, 1.0)
410            )