nodes/effects.pyΒΆ

Part of HeartBeast Action RPG.

 1"""Short-lived visual effects: sword hit, bat death, cut grass.
 2
 3Each effect is a Node2D that draws a fading burst and calls ``destroy()`` once
 4its timer runs out. They animate from a plain timer rather than from Property
 5state, so each sets ``dynamic`` to keep the retained 2D cache re-collecting it
 6every frame (without that, an effect would render one frozen frame).
 7"""
 8
 9from __future__ import annotations
10
11import math
12import random
13
14from settings import DEATH_EFFECT_DURATION, GRASS_EFFECT_DURATION, HIT_EFFECT_DURATION
15
16from simvx.core import Node2D, Vec2
17
18
19def _burst(count: int, min_speed: float, max_speed: float) -> list[tuple[float, float]]:
20    """``count`` velocities pointing evenly-ish outwards, in px/sec."""
21    velocities = []
22    for _ in range(count):
23        angle = random.uniform(0, math.tau)
24        speed = random.uniform(min_speed, max_speed)
25        velocities.append((math.cos(angle) * speed, math.sin(angle) * speed))
26    return velocities
27
28
29class _TimedEffect(Node2D):
30    """Base for the fading bursts: owns the lifetime and the self-destruct."""
31
32    dynamic = True
33    duration = 0.3
34
35    def __init__(self, position: Vec2, **kwargs):
36        super().__init__(position=position, **kwargs)
37        self._timer = 0.0
38
39    def on_update(self, dt: float):
40        self._timer += dt
41        if self._timer >= self.duration:
42            self.destroy()
43
44    @property
45    def progress(self) -> float:
46        """Lifetime elapsed, 0 at spawn to 1 at expiry."""
47        return min(1.0, self._timer / self.duration)
48
49
50class HitEffect(_TimedEffect):
51    """White flash and expanding ring where the sword landed."""
52
53    duration = HIT_EFFECT_DURATION
54
55    def on_draw(self, renderer):
56        t = self.progress
57        alpha = 1.0 - t
58        size = 5.0 + t * 8.0
59        renderer.draw_circle(self.position, size, colour=(1.0, 1.0, 1.0, alpha * 0.8), filled=True)
60        renderer.draw_circle(self.position, size * 1.6, colour=(1.0, 0.9, 0.7, alpha * 0.5), filled=False)
61
62
63class EnemyDeathEffect(_TimedEffect):
64    """Outward spray of embers where a bat died."""
65
66    duration = DEATH_EFFECT_DURATION
67
68    def __init__(self, position: Vec2, **kwargs):
69        super().__init__(position, **kwargs)
70        self._particles = _burst(8, 30, 80)
71
72    def on_draw(self, renderer):
73        t = self.progress
74        for dx, dy in self._particles:
75            centre = Vec2(self.position[0] + dx * self._timer, self.position[1] + dy * self._timer)
76            renderer.draw_circle(centre, 2.0 * (1.0 - t * 0.5), colour=(0.9, 0.5, 0.3, 1.0 - t), filled=True)
77
78
79class GrassEffect(_TimedEffect):
80    """Small scatter of leaves where a tuft was cut."""
81
82    duration = GRASS_EFFECT_DURATION
83
84    def __init__(self, position: Vec2, **kwargs):
85        super().__init__(position, **kwargs)
86        self._particles = _burst(5, 15, 40)
87
88    def on_draw(self, renderer):
89        t = self.progress
90        for dx, dy in self._particles:
91            centre = Vec2(self.position[0] + dx * self._timer, self.position[1] + dy * self._timer)
92            renderer.draw_circle(centre, 1.6 * (1.0 - t * 0.5), colour=(0.4, 0.7, 0.2, 1.0 - t), filled=True)