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}