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 )