nodes/particles2d.py¶

Part of Dungeon Explorer.

  1"""Simple 2D particle system for visual effects."""
  2
  3import math
  4import random as _random
  5
  6from simvx.core import Node2D, Vec2
  7
  8
  9class SimpleParticles(Node2D):
 10    """Lightweight pooled 2D particle emitter."""
 11
 12    # ``on_draw`` animates non-Property state (the live particle pool) every frame,
 13    # so it is honestly ``dynamic``. It draws EVERY pool slot every frame (inactive
 14    # slots at alpha 0), so the op count is constant (= ``pool_size``) regardless of
 15    # how many particles are active. The retained 2D cache therefore patches this
 16    # one node in place each frame instead of falling back to a whole-scene
 17    # re-collect (the "vary attributes, not the op COUNT" patch contract).
 18    dynamic = True
 19
 20    def __init__(self, pool_size: int = 64, **kwargs):
 21        super().__init__(name="Particles", **kwargs)
 22        self._pool = [
 23            {
 24                "pos": Vec2(),
 25                "vel": Vec2(),
 26                "colour": (1.0, 1.0, 1.0, 1.0),
 27                "lifetime": 0.0,
 28                "age": 0.0,
 29                "active": False,
 30                "size": 2,
 31            }
 32            for _ in range(pool_size)
 33        ]
 34
 35    def emit(
 36        self,
 37        count: int,
 38        position: Vec2,
 39        vel_range: tuple[float, float] = (-50, 50),
 40        colour: tuple = (1.0, 1.0, 1.0, 1.0),
 41        lifetime: float = 0.5,
 42        size: int = 2,
 43    ):
 44        """Emit particles from a position."""
 45        import random
 46
 47        spawned = 0
 48        for p in self._pool:
 49            if spawned >= count:
 50                break
 51            if not p["active"]:
 52                p["pos"] = Vec2(position.x, position.y)
 53                p["vel"] = Vec2(
 54                    random.uniform(vel_range[0], vel_range[1]),
 55                    random.uniform(vel_range[0], vel_range[1]),
 56                )
 57                p["colour"] = colour
 58                p["lifetime"] = lifetime
 59                p["age"] = 0.0
 60                p["active"] = True
 61                p["size"] = size
 62                spawned += 1
 63
 64    def emit_melee_sparks(self, position: Vec2, attack_dir: Vec2, is_crit: bool = False):
 65        """6-8 sparks perpendicular to attack direction. White/red for crit."""
 66        import random
 67
 68        count = random.randint(6, 8)
 69        perp_x, perp_y = -attack_dir.y, attack_dir.x
 70        colour = (1.0, 0.3, 0.2, 1.0) if is_crit else (1.0, 1.0, 0.9, 1.0)
 71        spawned = 0
 72        for p in self._pool:
 73            if spawned >= count:
 74                break
 75            if not p["active"]:
 76                p["pos"] = Vec2(position.x, position.y)
 77                spread = random.uniform(-1.0, 1.0)
 78                speed = random.uniform(80, 180)
 79                # Perpendicular to attack with some forward scatter
 80                vx = perp_x * spread * speed + attack_dir.x * random.uniform(20, 60)
 81                vy = perp_y * spread * speed + attack_dir.y * random.uniform(20, 60)
 82                p["vel"] = Vec2(vx, vy)
 83                if is_crit:
 84                    # Mix white and red for crit
 85                    t = random.random()
 86                    p["colour"] = (1.0, 0.3 + t * 0.7, 0.2 + t * 0.8, 1.0)
 87                else:
 88                    p["colour"] = colour
 89                p["lifetime"] = random.uniform(0.15, 0.35)
 90                p["age"] = 0.0
 91                p["active"] = True
 92                p["size"] = 3 if is_crit else 2
 93                spawned += 1
 94
 95    def emit_ranged_hit(self, position: Vec2, style: str = "arrow"):
 96        """Ranged impact particles: arrow splinters or magic dispersal."""
 97        import random
 98
 99        if style == "arrow":
100            # Arrow splinters: small brown/tan fragments
101            for _ in range(5):
102                for p in self._pool:
103                    if not p["active"]:
104                        p["pos"] = Vec2(position.x, position.y)
105                        angle = random.uniform(0, math.pi * 2)
106                        speed = random.uniform(40, 100)
107                        p["vel"] = Vec2(math.cos(angle) * speed, math.sin(angle) * speed)
108                        p["colour"] = (0.6, 0.4, 0.2, 1.0)
109                        p["lifetime"] = random.uniform(0.2, 0.4)
110                        p["age"] = 0.0
111                        p["active"] = True
112                        p["size"] = 2
113                        break
114        else:
115            # Magic dispersal: ring of coloured particles
116            for i in range(8):
117                angle = (i / 8.0) * math.pi * 2
118                for p in self._pool:
119                    if not p["active"]:
120                        p["pos"] = Vec2(position.x, position.y)
121                        speed = random.uniform(60, 120)
122                        p["vel"] = Vec2(math.cos(angle) * speed, math.sin(angle) * speed)
123                        p["colour"] = (0.5, 0.3, 1.0, 1.0)
124                        p["lifetime"] = random.uniform(0.2, 0.5)
125                        p["age"] = 0.0
126                        p["active"] = True
127                        p["size"] = 3
128                        break
129
130    def emit_levelup(self, position: Vec2):
131        """Level-up celebration: upward particle column."""
132        import random
133
134        for _i in range(20):
135            for p in self._pool:
136                if not p["active"]:
137                    p["pos"] = Vec2(position.x + random.uniform(-8, 8), position.y)
138                    p["vel"] = Vec2(random.uniform(-15, 15), random.uniform(-200, -80))
139                    t = random.random()
140                    p["colour"] = (1.0, 0.8 + t * 0.2, 0.2 + t * 0.4, 1.0)
141                    p["lifetime"] = random.uniform(0.5, 1.2)
142                    p["age"] = 0.0
143                    p["active"] = True
144                    p["size"] = 3
145                    break
146
147    def on_update(self, dt: float):
148        for p in self._pool:
149            if not p["active"]:
150                continue
151            p["age"] += dt
152            if p["age"] >= p["lifetime"]:
153                p["active"] = False
154                continue
155            pos = p["pos"]
156            vel = p["vel"]
157            p["pos"] = Vec2(pos.x + vel.x * dt, pos.y + vel.y * dt)
158            # Gravity
159            p["vel"] = Vec2(vel.x, vel.y + 120 * dt)
160
161    def on_draw(self, renderer):
162        # Draw every pool slot every frame for a constant op count (see class doc).
163        # Inactive slots draw at alpha 0 -> invisible, no rendering change.
164        for p in self._pool:
165            c = p["colour"]
166            s = p.get("size", 2)
167            hs = s // 2
168            alpha = max(0.0, 1.0 - p["age"] / p["lifetime"]) if p["active"] else 0.0
169            renderer.draw_rect(
170                (p["pos"].x - hs, p["pos"].y - hs), (s, s), colour=(c[0], c[1], c[2], alpha), filled=True
171            )
172
173
174# ============================================================================
175# Theme-based ambient particles
176# ============================================================================
177
178
179class AmbientParticles(Node2D):
180    """Theme-aware ambient particle emitter for dungeon atmosphere.
181
182    Emits dust motes (default), drips (Haunted), or embers (Infernal)
183    within a given area around the camera/player.
184    """
185
186    # ``on_draw`` animates the live particle pool (non-Property state) every frame,
187    # so it is honestly ``dynamic``. It draws EVERY pool slot every frame (inactive
188    # slots at alpha 0) for a constant op count (= ``pool_size``), so the retained 2D
189    # cache patches this node in place each frame rather than re-collecting the whole
190    # scene (the "vary attributes, not the op COUNT" patch contract).
191    dynamic = True
192
193    def __init__(self, theme_name: str = "", pool_size: int = 32, **kwargs):
194        super().__init__(name="AmbientParticles", **kwargs)
195        self._theme_name = theme_name
196        self._pool = [
197            {
198                "pos": Vec2(),
199                "vel": Vec2(),
200                "colour": (1.0, 1.0, 1.0, 1.0),
201                "lifetime": 0.0,
202                "age": 0.0,
203                "active": False,
204                "size": 1,
205            }
206            for _ in range(pool_size)
207        ]
208        self._spawn_timer = 0.0
209        self._spawn_interval = 0.15
210        self._area_centre = Vec2()
211        self._area_radius = 200.0
212
213    def set_theme(self, theme_name: str) -> None:
214        self._theme_name = theme_name
215
216    def set_area(self, centre: Vec2, radius: float = 200.0) -> None:
217        self._area_centre = Vec2(centre.x, centre.y)
218        self._area_radius = radius
219
220    def on_update(self, dt: float):
221        # Spawn new particles
222        self._spawn_timer += dt
223        if self._spawn_timer >= self._spawn_interval:
224            self._spawn_timer -= self._spawn_interval
225            self._spawn_particle()
226
227        # Update existing
228        for p in self._pool:
229            if not p["active"]:
230                continue
231            p["age"] += dt
232            if p["age"] >= p["lifetime"]:
233                p["active"] = False
234                continue
235            pos = p["pos"]
236            vel = p["vel"]
237            p["pos"] = Vec2(pos.x + vel.x * dt, pos.y + vel.y * dt)
238
239    def _spawn_particle(self) -> None:
240        for p in self._pool:
241            if not p["active"]:
242                cx, cy = float(self._area_centre.x), float(self._area_centre.y)
243                r = self._area_radius
244                ox = _random.uniform(-r, r)
245                oy = _random.uniform(-r, r)
246                p["pos"] = Vec2(cx + ox, cy + oy)
247                p["active"] = True
248                p["age"] = 0.0
249
250                if "Haunted" in self._theme_name:
251                    # Drip: falls downward, blue-ish
252                    p["vel"] = Vec2(_random.uniform(-2, 2), _random.uniform(20, 50))
253                    p["colour"] = (0.3, 0.35, 0.6, 0.6)
254                    p["lifetime"] = _random.uniform(1.0, 2.5)
255                    p["size"] = 1
256                elif "Infernal" in self._theme_name:
257                    # Ember: rises upward, orange/red
258                    p["vel"] = Vec2(_random.uniform(-10, 10), _random.uniform(-40, -15))
259                    p["colour"] = (1.0, _random.uniform(0.3, 0.6), 0.05, 0.7)
260                    p["lifetime"] = _random.uniform(0.5, 1.5)
261                    p["size"] = 2
262                else:
263                    # Dust mote: slow drift
264                    p["vel"] = Vec2(_random.uniform(-8, 8), _random.uniform(-5, 5))
265                    p["colour"] = (0.7, 0.65, 0.55, 0.3)
266                    p["lifetime"] = _random.uniform(2.0, 4.0)
267                    p["size"] = 1
268                break
269
270    def on_draw(self, renderer):
271        # Draw every pool slot every frame for a constant op count (see class doc).
272        # Inactive slots draw at alpha 0 -> invisible, no rendering change.
273        for p in self._pool:
274            c = p["colour"]
275            s = p["size"]
276            if p["active"]:
277                alpha = c[3] * max(0.0, 1.0 - p["age"] / max(0.01, p["lifetime"]))
278            else:
279                alpha = 0.0
280            renderer.draw_rect(
281                (p["pos"].x - s, p["pos"].y - s), (s * 2, s * 2), colour=(c[0], c[1], c[2], alpha), filled=True
282            )