nodes/abilities.py¶
Part of Dungeon Explorer.
1"""Active ability implementations with visual effects."""
2
3import math
4
5from simvx.core import Node2D, Vec2
6
7from .combat import Hitbox
8from .projectile import Projectile
9
10# ── Visual effect nodes ──────────────────────────────────────────────────
11
12
13class _AbilityFX(Node2D):
14 """Base for short-lived ability visuals. Never persisted: these are spawned
15 and auto-destroyed during play, so a save taken while one is on screen must
16 re-apply cleanly (its path is gone by the time the save loads).
17 """
18
19 __save_persist__ = False
20
21
22class WhirlwindArc(_AbilityFX):
23 """Spinning arc visual for Whirlwind ability."""
24
25 def __init__(self, **kwargs):
26 super().__init__(name="WhirlwindArc", **kwargs)
27 self._timer = 0.0
28 self._duration = 0.3
29
30 def on_update(self, dt: float):
31 self._timer += dt
32 if self._timer >= self._duration:
33 self.destroy()
34
35 def on_draw(self, renderer):
36 t = self._timer / self._duration
37 alpha = 1.0 - t
38 radius = 20 + t * 30
39 # Spinning arc lines
40 for i in range(6):
41 angle = t * math.pi * 4 + i * (math.pi / 3)
42 x1 = self.position.x + math.cos(angle) * radius * 0.6
43 y1 = self.position.y + math.sin(angle) * radius * 0.6
44 x2 = self.position.x + math.cos(angle) * radius
45 y2 = self.position.y + math.sin(angle) * radius
46 renderer.draw_line((x1, y1), (x2, y2), colour=(1.0, 0.9, 0.4, alpha))
47
48
49class FireballTrail(_AbilityFX):
50 """Trailing fire particles following a fireball projectile."""
51
52 def __init__(self, proj: Projectile, **kwargs):
53 super().__init__(name="FireTrail", **kwargs)
54 self._proj = proj
55 self._timer = 0.0
56 self._trail: list[tuple[float, float, float]] = [] # (x, y, age)
57
58 def on_update(self, dt: float):
59 self._timer += dt
60 if not self._proj.parent:
61 if not self._trail:
62 self.destroy()
63 return
64 else:
65 self._trail.append((self._proj.position.x, self._proj.position.y, 0.0))
66 # Age trail points
67 self._trail = [(x, y, a + dt) for x, y, a in self._trail if a + dt < 0.4]
68 if self._timer > 3.0:
69 self.destroy()
70
71 def on_draw(self, renderer):
72 for x, y, age in self._trail:
73 alpha = max(0.0, 1.0 - age / 0.4)
74 size = max(1, int(4 * (1.0 - age / 0.4)))
75 renderer.draw_circle((x, y), size, colour=(1.0, 0.5 + age, 0.1, alpha * 0.7), filled=True)
76
77
78class IceNovaRing(_AbilityFX):
79 """Expanding ice ring visual for Ice Nova ability."""
80
81 def __init__(self, **kwargs):
82 super().__init__(name="IceNovaRing", **kwargs)
83 self._timer = 0.0
84 self._duration = 0.4
85
86 def on_update(self, dt: float):
87 self._timer += dt
88 if self._timer >= self._duration:
89 self.destroy()
90
91 def on_draw(self, renderer):
92 t = self._timer / self._duration
93 alpha = 1.0 - t
94 radius = t * 100
95 # Draw ring as series of small rects around circumference
96 segments = 16
97 for i in range(segments):
98 angle = (i / segments) * math.pi * 2
99 x = self.position.x + math.cos(angle) * radius
100 y = self.position.y + math.sin(angle) * radius
101 renderer.draw_rect((x - 2, y - 2), (4, 4), colour=(0.5, 0.8, 1.0, alpha), filled=True)
102
103
104class DashStrikeTrail(_AbilityFX):
105 """Speed trail for Dash Strike ability."""
106
107 def __init__(self, start: Vec2, end: Vec2, **kwargs):
108 super().__init__(name="DashTrail", **kwargs)
109 self._start = start
110 self._end = end
111 self._timer = 0.0
112 self._duration = 0.2
113
114 def on_update(self, dt: float):
115 self._timer += dt
116 if self._timer >= self._duration:
117 self.destroy()
118
119 def on_draw(self, renderer):
120 alpha = max(0.0, 1.0 - self._timer / self._duration)
121 renderer.draw_line(
122 (self._start.x, self._start.y),
123 (self._end.x, self._end.y),
124 colour=(1.0, 0.8, 0.3, alpha * 0.6),
125 )
126 # Flash at endpoints
127 for pos in (self._start, self._end):
128 renderer.draw_circle((pos.x, pos.y), 4, colour=(1.0, 1.0, 0.8, alpha * 0.4), filled=True)
129
130
131class WarCryPulse(_AbilityFX):
132 """Outward pulse ring for War Cry."""
133
134 def __init__(self, **kwargs):
135 super().__init__(name="WarCryPulse", **kwargs)
136 self._timer = 0.0
137 self._duration = 0.3
138
139 def on_update(self, dt: float):
140 self._timer += dt
141 if self._timer >= self._duration:
142 self.destroy()
143
144 def on_draw(self, renderer):
145 t = self._timer / self._duration
146 alpha = 1.0 - t
147 radius = 10 + t * 40
148 segments = 12
149 for i in range(segments):
150 angle = (i / segments) * math.pi * 2
151 x = self.position.x + math.cos(angle) * radius
152 y = self.position.y + math.sin(angle) * radius
153 renderer.draw_rect((x - 1, y - 1), (3, 3), colour=(1.0, 0.7, 0.2, alpha), filled=True)
154
155
156class ArcaneShieldOrb(_AbilityFX):
157 """Orbiting shield particles for Arcane Shield."""
158
159 def __init__(self, owner, **kwargs):
160 super().__init__(name="ArcaneOrb", **kwargs)
161 self._owner = owner
162 self._timer = 0.0
163 self._duration = 5.0 # Duration of shield
164
165 def on_update(self, dt: float):
166 self._timer += dt
167 if self._timer >= self._duration or not self._owner.parent:
168 self.destroy()
169
170 def on_draw(self, renderer):
171 alpha = max(0.3, 1.0 - self._timer / self._duration)
172 # Orbiting particles
173 for i in range(3):
174 angle = self._timer * 4.0 + i * (math.pi * 2 / 3)
175 x = self._owner.position.x + math.cos(angle) * 16
176 y = self._owner.position.y + math.sin(angle) * 16
177 renderer.draw_circle((x, y), 3, colour=(0.4, 0.3, 0.9, alpha), filled=True)
178
179
180class ShieldBashFlash(_AbilityFX):
181 """Impact flash for Shield Bash."""
182
183 def __init__(self, **kwargs):
184 super().__init__(name="BashFlash", **kwargs)
185 self._timer = 0.0
186 self._duration = 0.12
187
188 def on_update(self, dt: float):
189 self._timer += dt
190 if self._timer >= self._duration:
191 self.destroy()
192
193 def on_draw(self, renderer):
194 alpha = 1.0 - self._timer / self._duration
195 renderer.draw_circle((self.position.x, self.position.y), 12, colour=(0.9, 0.9, 1.0, alpha * 0.6), filled=True)
196
197
198class MultishotFlash(_AbilityFX):
199 """Spread flash for Multishot."""
200
201 def __init__(self, **kwargs):
202 super().__init__(name="MultiFlash", **kwargs)
203 self._timer = 0.0
204 self._duration = 0.1
205
206 def on_update(self, dt: float):
207 self._timer += dt
208 if self._timer >= self._duration:
209 self.destroy()
210
211 def on_draw(self, renderer):
212 alpha = 1.0 - self._timer / self._duration
213 renderer.draw_circle((self.position.x, self.position.y), 8, colour=(1.0, 0.85, 0.3, alpha * 0.5), filled=True)
214
215
216# ── Ability functions ────────────────────────────────────────────────────
217
218
219def whirlwind(player, targets, skill_data) -> list[Node2D]:
220 """360 AoE hitbox, 40px radius, 1.5x weapon damage + spinning arc."""
221 hitbox = Hitbox(
222 damage=int(player.damage_base * 1.5) if hasattr(player, "damage_base") else 15,
223 direction=Vec2(0, 1),
224 name="MeleeHit",
225 )
226 hitbox.position = Vec2(player.position.x, player.position.y)
227 hitbox.radius = 40
228 hitbox.lifetime = 0.2
229 arc = WhirlwindArc()
230 arc.position = Vec2(player.position.x, player.position.y)
231 return [hitbox, arc]
232
233
234def fireball(player, targets, skill_data) -> list[Node2D]:
235 """Projectile, 200px/s, AoE on impact + fire trail."""
236 proj = Projectile(
237 damage=20,
238 direction=player.facing,
239 speed=200,
240 max_range=250,
241 colour=(1.0, 0.4, 0.1, 1.0),
242 style="bolt",
243 name="Projectile",
244 )
245 proj.position = Vec2(player.position.x + player.facing.x * 12, player.position.y + player.facing.y * 12)
246 trail = FireballTrail(proj)
247 return [proj, trail]
248
249
250def multishot(player, targets, skill_data) -> list[Node2D]:
251 """3 arrows, 30 degree spread + flash."""
252 nodes = []
253 base_angle = math.atan2(player.facing.y, player.facing.x)
254 spread = math.radians(15)
255 for offset in (-spread, 0, spread):
256 angle = base_angle + offset
257 direction = Vec2(math.cos(angle), math.sin(angle))
258 proj = Projectile(damage=10, direction=direction, speed=250, max_range=300, name="Projectile")
259 proj.position = Vec2(player.position.x + direction.x * 12, player.position.y + direction.y * 12)
260 nodes.append(proj)
261 flash = MultishotFlash()
262 flash.position = Vec2(player.position.x, player.position.y)
263 nodes.append(flash)
264 return nodes
265
266
267def shield_bash(player, targets, skill_data) -> list[Node2D]:
268 """Stun + knockback single target + impact flash."""
269 hitbox = Hitbox(damage=8, direction=player.facing, name="MeleeHit")
270 hitbox.position = Vec2(player.position.x + player.facing.x * 20, player.position.y + player.facing.y * 20)
271 hitbox.radius = 18
272 hitbox.lifetime = 0.1
273 flash = ShieldBashFlash()
274 flash.position = Vec2(hitbox.position.x, hitbox.position.y)
275 return [hitbox, flash]
276
277
278def dash_strike(player, targets, skill_data) -> list[Node2D]:
279 """Teleport 80px + melee hit + speed trail."""
280 start_pos = Vec2(player.position.x, player.position.y)
281 player.position = Vec2(
282 player.position.x + player.facing.x * 80,
283 player.position.y + player.facing.y * 80,
284 )
285 hitbox = Hitbox(damage=15, direction=player.facing, name="MeleeHit")
286 hitbox.position = Vec2(player.position.x + player.facing.x * 15, player.position.y + player.facing.y * 15)
287 hitbox.radius = 16
288 hitbox.lifetime = 0.15
289 trail = DashStrikeTrail(start_pos, Vec2(player.position.x, player.position.y))
290 return [hitbox, trail]
291
292
293def war_cry(player, targets, skill_data) -> list[Node2D]:
294 """Self buff + pulse visual."""
295 pulse = WarCryPulse()
296 pulse.position = Vec2(player.position.x, player.position.y)
297 return [pulse]
298
299
300def ice_nova(player, targets, skill_data) -> list[Node2D]:
301 """Freeze 100px radius AoE + expanding ice ring."""
302 hitbox = Hitbox(damage=12, direction=Vec2(0, 1), name="MeleeHit")
303 hitbox.position = Vec2(player.position.x, player.position.y)
304 hitbox.radius = 100
305 hitbox.lifetime = 0.3
306 ring = IceNovaRing()
307 ring.position = Vec2(player.position.x, player.position.y)
308 return [hitbox, ring]
309
310
311def arcane_shield(player, targets, skill_data) -> list[Node2D]:
312 """Damage absorb + orbiting shield particles."""
313 orb = ArcaneShieldOrb(player)
314 return [orb]
315
316
317def trap(player, targets, skill_data) -> list[Node2D]:
318 """Place a trap node at player position."""
319 trap_node = TrapNode(damage=15)
320 trap_node.position = Vec2(player.position.x, player.position.y)
321 return [trap_node]
322
323
324class TrapNode(_AbilityFX):
325 """A placed trap that triggers when an enemy walks over it."""
326
327 def __init__(self, damage: int = 15, **kwargs):
328 super().__init__(name="Trap", **kwargs)
329 self._damage = damage
330 self._timer = 10.0 # Lasts 10 seconds
331 self._triggered = False
332 self._trigger_radius = 20.0
333
334 def on_update(self, dt: float):
335 self._timer -= dt
336 if self._timer <= 0:
337 self.destroy()
338
339 def check_trigger(self, enemies: list) -> list:
340 """Check if any enemy is in trigger radius. Returns triggered enemies."""
341 if self._triggered:
342 return []
343 triggered = []
344 for enemy in enemies:
345 dx = enemy.position.x - self.position.x
346 dy = enemy.position.y - self.position.y
347 if dx * dx + dy * dy < self._trigger_radius**2:
348 triggered.append(enemy)
349 if triggered:
350 self._triggered = True
351 self.destroy()
352 return triggered
353
354 def on_draw(self, renderer):
355 alpha = min(1.0, self._timer / 2.0) # Fade when expiring
356 renderer.draw_rect(
357 (self.position.x - 6, self.position.y - 6), (12, 12), colour=(0.8, 0.2, 0.2, alpha * 0.6), filled=True
358 )
359
360
361# Registry mapping skill IDs to ability functions
362ABILITY_MAP = {
363 "whirlwind": whirlwind,
364 "fireball": fireball,
365 "multishot": multishot,
366 "shield_bash": shield_bash,
367 "dash_strike": dash_strike,
368 "war_cry": war_cry,
369 "ice_nova": ice_nova,
370 "arcane_shield": arcane_shield,
371 "trap": trap,
372}