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}