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