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 = math.cos(angle) * radius * 0.6
43 y1 = math.sin(angle) * radius * 0.6
44 x2 = math.cos(angle) * radius
45 y2 = math.sin(angle) * radius
46 renderer.line((x1, y1), (x2, y2), stroke=(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.circle((x, y), size, fill=(1.0, 0.5 + age, 0.1, alpha * 0.7))
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 = math.cos(angle) * radius
100 y = math.sin(angle) * radius
101 renderer.rect((x - 2, y - 2), (4, 4), fill=(0.5, 0.8, 1.0, alpha))
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.line((self._start.x, self._start.y), (self._end.x, self._end.y), stroke=(1.0, 0.8, 0.3, alpha * 0.6))
122 # Flash at endpoints
123 for pos in (self._start, self._end):
124 renderer.circle((pos.x, pos.y), 4, fill=(1.0, 1.0, 0.8, alpha * 0.4))
125
126
127class WarCryPulse(_AbilityFX):
128 """Outward pulse ring for War Cry."""
129
130 def __init__(self, **kwargs):
131 super().__init__(name="WarCryPulse", **kwargs)
132 self._timer = 0.0
133 self._duration = 0.3
134
135 def on_update(self, dt: float):
136 self._timer += dt
137 if self._timer >= self._duration:
138 self.destroy()
139
140 def on_draw(self, renderer):
141 t = self._timer / self._duration
142 alpha = 1.0 - t
143 radius = 10 + t * 40
144 segments = 12
145 for i in range(segments):
146 angle = (i / segments) * math.pi * 2
147 x = math.cos(angle) * radius
148 y = math.sin(angle) * radius
149 renderer.rect((x - 1, y - 1), (3, 3), fill=(1.0, 0.7, 0.2, alpha))
150
151
152class ArcaneShieldOrb(_AbilityFX):
153 """Orbiting shield particles for Arcane Shield."""
154
155 def __init__(self, owner, **kwargs):
156 super().__init__(name="ArcaneOrb", **kwargs)
157 self._owner = owner
158 self._timer = 0.0
159 self._duration = 5.0 # Duration of shield
160
161 def on_update(self, dt: float):
162 self._timer += dt
163 if self._timer >= self._duration or not self._owner.parent:
164 self.destroy()
165
166 def on_draw(self, renderer):
167 alpha = max(0.3, 1.0 - self._timer / self._duration)
168 # Orbiting particles
169 for i in range(3):
170 angle = self._timer * 4.0 + i * (math.pi * 2 / 3)
171 x = self._owner.position.x + math.cos(angle) * 16
172 y = self._owner.position.y + math.sin(angle) * 16
173 renderer.circle((x, y), 3, fill=(0.4, 0.3, 0.9, alpha))
174
175
176class ShieldBashFlash(_AbilityFX):
177 """Impact flash for Shield Bash."""
178
179 def __init__(self, **kwargs):
180 super().__init__(name="BashFlash", **kwargs)
181 self._timer = 0.0
182 self._duration = 0.12
183
184 def on_update(self, dt: float):
185 self._timer += dt
186 if self._timer >= self._duration:
187 self.destroy()
188
189 def on_draw(self, renderer):
190 alpha = 1.0 - self._timer / self._duration
191 renderer.circle((0, 0), 12, fill=(0.9, 0.9, 1.0, alpha * 0.6))
192
193
194class MultishotFlash(_AbilityFX):
195 """Spread flash for Multishot."""
196
197 def __init__(self, **kwargs):
198 super().__init__(name="MultiFlash", **kwargs)
199 self._timer = 0.0
200 self._duration = 0.1
201
202 def on_update(self, dt: float):
203 self._timer += dt
204 if self._timer >= self._duration:
205 self.destroy()
206
207 def on_draw(self, renderer):
208 alpha = 1.0 - self._timer / self._duration
209 renderer.circle((0, 0), 8, fill=(1.0, 0.85, 0.3, alpha * 0.5))
210
211
212# ── Ability functions ────────────────────────────────────────────────────
213
214
215def whirlwind(player, targets, skill_data) -> list[Node2D]:
216 """360 AoE hitbox, 40px radius, 1.5x weapon damage + spinning arc."""
217 hitbox = Hitbox(
218 damage=int(player.damage_base * 1.5) if hasattr(player, "damage_base") else 15,
219 direction=Vec2(0, 1),
220 name="MeleeHit",
221 )
222 hitbox.position = Vec2(player.position.x, player.position.y)
223 hitbox.radius = 40
224 hitbox.lifetime = 0.2
225 arc = WhirlwindArc()
226 arc.position = Vec2(player.position.x, player.position.y)
227 return [hitbox, arc]
228
229
230def fireball(player, targets, skill_data) -> list[Node2D]:
231 """Projectile, 200px/s, AoE on impact + fire trail."""
232 proj = Projectile(
233 damage=20,
234 direction=player.facing,
235 speed=200,
236 max_range=250,
237 colour=(1.0, 0.4, 0.1, 1.0),
238 style="bolt",
239 name="Projectile",
240 )
241 proj.position = Vec2(player.position.x + player.facing.x * 12, player.position.y + player.facing.y * 12)
242 trail = FireballTrail(proj)
243 return [proj, trail]
244
245
246def multishot(player, targets, skill_data) -> list[Node2D]:
247 """3 arrows, 30 degree spread + flash."""
248 nodes = []
249 base_angle = math.atan2(player.facing.y, player.facing.x)
250 spread = math.radians(15)
251 for offset in (-spread, 0, spread):
252 angle = base_angle + offset
253 direction = Vec2(math.cos(angle), math.sin(angle))
254 proj = Projectile(damage=10, direction=direction, speed=250, max_range=300, name="Projectile")
255 proj.position = Vec2(player.position.x + direction.x * 12, player.position.y + direction.y * 12)
256 nodes.append(proj)
257 flash = MultishotFlash()
258 flash.position = Vec2(player.position.x, player.position.y)
259 nodes.append(flash)
260 return nodes
261
262
263def shield_bash(player, targets, skill_data) -> list[Node2D]:
264 """Stun + knockback single target + impact flash."""
265 hitbox = Hitbox(damage=8, direction=player.facing, name="MeleeHit")
266 hitbox.position = Vec2(player.position.x + player.facing.x * 20, player.position.y + player.facing.y * 20)
267 hitbox.radius = 18
268 hitbox.lifetime = 0.1
269 flash = ShieldBashFlash()
270 flash.position = Vec2(hitbox.position.x, hitbox.position.y)
271 return [hitbox, flash]
272
273
274def dash_strike(player, targets, skill_data) -> list[Node2D]:
275 """Teleport 80px + melee hit + speed trail."""
276 start_pos = Vec2(player.position.x, player.position.y)
277 player.position = Vec2(
278 player.position.x + player.facing.x * 80,
279 player.position.y + player.facing.y * 80,
280 )
281 hitbox = Hitbox(damage=15, direction=player.facing, name="MeleeHit")
282 hitbox.position = Vec2(player.position.x + player.facing.x * 15, player.position.y + player.facing.y * 15)
283 hitbox.radius = 16
284 hitbox.lifetime = 0.15
285 trail = DashStrikeTrail(start_pos, Vec2(player.position.x, player.position.y))
286 return [hitbox, trail]
287
288
289def war_cry(player, targets, skill_data) -> list[Node2D]:
290 """Self buff + pulse visual."""
291 pulse = WarCryPulse()
292 pulse.position = Vec2(player.position.x, player.position.y)
293 return [pulse]
294
295
296def ice_nova(player, targets, skill_data) -> list[Node2D]:
297 """Freeze 100px radius AoE + expanding ice ring."""
298 hitbox = Hitbox(damage=12, direction=Vec2(0, 1), name="MeleeHit")
299 hitbox.position = Vec2(player.position.x, player.position.y)
300 hitbox.radius = 100
301 hitbox.lifetime = 0.3
302 ring = IceNovaRing()
303 ring.position = Vec2(player.position.x, player.position.y)
304 return [hitbox, ring]
305
306
307def arcane_shield(player, targets, skill_data) -> list[Node2D]:
308 """Damage absorb + orbiting shield particles."""
309 orb = ArcaneShieldOrb(player)
310 return [orb]
311
312
313def trap(player, targets, skill_data) -> list[Node2D]:
314 """Place a trap node at player position."""
315 trap_node = TrapNode(damage=15)
316 trap_node.position = Vec2(player.position.x, player.position.y)
317 return [trap_node]
318
319
320class TrapNode(_AbilityFX):
321 """A placed trap that triggers when an enemy walks over it."""
322
323 def __init__(self, damage: int = 15, **kwargs):
324 super().__init__(name="Trap", **kwargs)
325 self._damage = damage
326 self._timer = 10.0 # Lasts 10 seconds
327 self._triggered = False
328 self._trigger_radius = 20.0
329
330 def on_update(self, dt: float):
331 self._timer -= dt
332 if self._timer <= 0:
333 self.destroy()
334
335 def check_trigger(self, enemies: list) -> list:
336 """Check if any enemy is in trigger radius. Returns triggered enemies."""
337 if self._triggered:
338 return []
339 triggered = []
340 for enemy in enemies:
341 dx = enemy.position.x - self.position.x
342 dy = enemy.position.y - self.position.y
343 if dx * dx + dy * dy < self._trigger_radius**2:
344 triggered.append(enemy)
345 if triggered:
346 self._triggered = True
347 self.destroy()
348 return triggered
349
350 def on_draw(self, renderer):
351 alpha = min(1.0, self._timer / 2.0) # Fade when expiring
352 renderer.rect((-6, -6), (12, 12), fill=(0.8, 0.2, 0.2, alpha * 0.6))
353
354
355# Registry mapping skill IDs to ability functions
356ABILITY_MAP = {
357 "whirlwind": whirlwind,
358 "fireball": fireball,
359 "multishot": multishot,
360 "shield_bash": shield_bash,
361 "dash_strike": dash_strike,
362 "war_cry": war_cry,
363 "ice_nova": ice_nova,
364 "arcane_shield": arcane_shield,
365 "trap": trap,
366}