nodes/particles.pyΒΆ

Part of Mr. Rescue.

  1"""CPU particle pool: water mist, smoke, sparkles, ash.
  2
  3Mirrors snkrx/nodes/particles.py: flat numpy buffer per particle, drawn as
  4filled circles. Lives as a Node2D under the gameplay scene so it's
  5camera-transformed.
  6"""
  7
  8from __future__ import annotations
  9
 10import math
 11import random
 12
 13import numpy as np
 14
 15from simvx.core import Node2D, Vec2
 16
 17PARTICLE_DTYPE = np.dtype(
 18    [
 19        ("x", np.float32),
 20        ("y", np.float32),
 21        ("vx", np.float32),
 22        ("vy", np.float32),
 23        ("life", np.float32),
 24        ("life_max", np.float32),
 25        ("scale0", np.float32),
 26        ("scale1", np.float32),
 27        ("r", np.float32),
 28        ("g", np.float32),
 29        ("b", np.float32),
 30        ("a", np.float32),
 31        ("drag", np.float32),
 32        ("alive", np.uint8),
 33    ]
 34)
 35
 36
 37class Particles2D(Node2D):
 38    # ``on_draw`` reads the live numpy ``_buf`` (positions/scales/alphas advanced
 39    # every tick by ``on_update``) and emits a fresh set of circles each frame, so
 40    # it is genuinely immediate-mode: declare it dynamic so the item pipeline
 41    # re-collects it every frame on both desktop and web.
 42    dynamic = True
 43
 44    def __init__(self, capacity: int = 800, **kwargs):
 45        super().__init__(**kwargs)
 46        self._buf = np.zeros(capacity, dtype=PARTICLE_DTYPE)
 47        self._cursor = 0
 48        self._capacity = capacity
 49
 50    def emit_burst(
 51        self,
 52        pos,
 53        *,
 54        count=8,
 55        speed=80.0,
 56        speed_var=20.0,
 57        life=0.4,
 58        life_var=0.15,
 59        scale0=2.5,
 60        scale1=0.0,
 61        colour=(1.0, 1.0, 1.0, 1.0),
 62        drag=4.0,
 63        cone=None,
 64        direction=0.0,
 65    ):
 66        for _ in range(count):
 67            i = self._cursor
 68            self._cursor = (self._cursor + 1) % self._capacity
 69            if cone is None:
 70                a = random.uniform(0, math.tau)
 71            else:
 72                a = direction + random.uniform(-cone, cone)
 73            s = speed + random.uniform(-speed_var, speed_var)
 74            p = self._buf[i]
 75            p["x"] = pos[0] if not isinstance(pos, Vec2) else pos.x
 76            p["y"] = pos[1] if not isinstance(pos, Vec2) else pos.y
 77            p["vx"] = math.cos(a) * s
 78            p["vy"] = math.sin(a) * s
 79            p["life_max"] = max(0.05, life + random.uniform(-life_var, life_var))
 80            p["life"] = p["life_max"]
 81            p["scale0"] = scale0
 82            p["scale1"] = scale1
 83            p["r"], p["g"], p["b"], p["a"] = colour
 84            p["drag"] = drag
 85            p["alive"] = 1
 86
 87    def emit_one(self, pos, *, vx, vy, life=0.3, scale0=2.0, scale1=0.0, colour=(1.0, 1.0, 1.0, 1.0), drag=4.0):
 88        i = self._cursor
 89        self._cursor = (self._cursor + 1) % self._capacity
 90        p = self._buf[i]
 91        p["x"] = pos[0] if not isinstance(pos, Vec2) else pos.x
 92        p["y"] = pos[1] if not isinstance(pos, Vec2) else pos.y
 93        p["vx"] = vx
 94        p["vy"] = vy
 95        p["life_max"] = life
 96        p["life"] = life
 97        p["scale0"] = scale0
 98        p["scale1"] = scale1
 99        p["r"], p["g"], p["b"], p["a"] = colour
100        p["drag"] = drag
101        p["alive"] = 1
102
103    def on_update(self, dt: float):
104        if dt <= 0:
105            return
106        b = self._buf
107        if not (b["alive"] > 0).any():
108            return
109        decay = np.exp(-b["drag"] * dt)
110        b["vx"] = b["vx"] * decay
111        b["vy"] = b["vy"] * decay
112        b["x"] = b["x"] + b["vx"] * dt
113        b["y"] = b["y"] + b["vy"] * dt
114        b["life"] = b["life"] - dt
115        b["alive"] = (b["life"] > 0).astype(np.uint8)
116
117    def on_draw(self, renderer):
118        b = self._buf
119        live_idx = np.flatnonzero(b["alive"])
120        if live_idx.size == 0:
121            return
122        lifes = b["life"][live_idx]
123        life_max = b["life_max"][live_idx]
124        t = np.clip(lifes / np.maximum(life_max, 1e-6), 0.0, 1.0)
125        scales = b["scale1"][live_idx] + (b["scale0"][live_idx] - b["scale1"][live_idx]) * t
126        alphas = b["a"][live_idx] * t
127        xs = b["x"][live_idx]
128        ys = b["y"][live_idx]
129        rs = b["r"][live_idx]
130        gs = b["g"][live_idx]
131        bs = b["b"][live_idx]
132        for x, y, s, r, g, bl, a in zip(xs, ys, scales, rs, gs, bs, alphas, strict=False):
133            if s <= 0.2 or a <= 0.05:
134                continue
135            renderer.draw_circle(
136                (float(x), float(y)),
137                float(s),
138                colour=(float(r), float(g), float(bl), float(a)),
139                filled=True,
140                segments=6,
141            )