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 )