nodes/particles.py

Part of SNKRX.

  1"""CPU 2D particle pool: SNKRX-style hit sparks, death bursts, projectile trails.
  2
  3A flat numpy buffer per particle (position, velocity, life, scale, colour) is
  4walked each frame and drawn via ``renderer.draw_circle`` from the host node's
  5``on_draw``. Pool-allocated to avoid Node churn. Scales gracefully to thousands.
  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    """Pool of CPU-driven 2D particles, drawn as filled circles.
 39
 40    Place one per scene as a child of the gameplay arena. ``emit(...)`` adds
 41    a burst; particles auto-fade and recycle.
 42    """
 43
 44    # Particles advance every frame in ``update()`` and ``on_draw`` reads that
 45    # live numpy buffer, so this node genuinely produces new geometry per frame.
 46    # Declare it dynamic: the retained 2D cache re-collects it every frame
 47    # instead of relying on view-decoupling re-running ``on_draw`` on scroll.
 48    dynamic = True
 49
 50    def __init__(self, capacity: int = 1500, **kwargs):
 51        super().__init__(name=kwargs.pop("name", "Particles2D"), **kwargs)
 52        self._buf = np.zeros(capacity, dtype=PARTICLE_DTYPE)
 53        self._cursor = 0
 54        self._capacity = capacity
 55
 56    # ------------------------------------------------------------------ emit
 57
 58    def emit_burst(
 59        self,
 60        pos: Vec2,
 61        *,
 62        count: int = 12,
 63        speed: float = 200.0,
 64        speed_var: float = 80.0,
 65        life: float = 0.5,
 66        life_var: float = 0.2,
 67        scale0: float = 4.0,
 68        scale1: float = 0.0,
 69        colour: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
 70        drag: float = 4.0,
 71        cone: float | None = None,
 72        direction: float = 0.0,
 73    ) -> None:
 74        """Emit *count* particles at *pos* with the given parameters.
 75
 76        ``cone`` (radians) restricts emission to ±cone around ``direction``;
 77        ``None`` emits in a full circle.
 78        """
 79        for _ in range(count):
 80            i = self._cursor
 81            self._cursor = (self._cursor + 1) % self._capacity
 82            if cone is None:
 83                a = random.uniform(0, math.tau)
 84            else:
 85                a = direction + random.uniform(-cone, cone)
 86            s = speed + random.uniform(-speed_var, speed_var)
 87            p = self._buf[i]
 88            p["x"] = pos.x
 89            p["y"] = pos.y
 90            p["vx"] = math.cos(a) * s
 91            p["vy"] = math.sin(a) * s
 92            p["life_max"] = max(0.05, life + random.uniform(-life_var, life_var))
 93            p["life"] = p["life_max"]
 94            p["scale0"] = scale0
 95            p["scale1"] = scale1
 96            p["r"], p["g"], p["b"], p["a"] = colour
 97            p["drag"] = drag
 98            p["alive"] = 1
 99
100    def emit_trail(
101        self,
102        pos: Vec2,
103        *,
104        colour: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
105        life: float = 0.25,
106        scale0: float = 2.5,
107    ) -> None:
108        """Emit a single fading trail particle."""
109        i = self._cursor
110        self._cursor = (self._cursor + 1) % self._capacity
111        p = self._buf[i]
112        p["x"] = pos.x + random.uniform(-1.5, 1.5)
113        p["y"] = pos.y + random.uniform(-1.5, 1.5)
114        p["vx"] = random.uniform(-20, 20)
115        p["vy"] = random.uniform(-20, 20)
116        p["life_max"] = life
117        p["life"] = life
118        p["scale0"] = scale0
119        p["scale1"] = 0.0
120        p["r"], p["g"], p["b"], p["a"] = colour
121        p["drag"] = 6.0
122        p["alive"] = 1
123
124    # ---------------------------------------------------------------- update
125
126    def update(self, dt: float):
127        if dt <= 0:
128            return
129        b = self._buf
130        alive = b["alive"] > 0
131        if not alive.any():
132            return
133        decay = np.exp(-b["drag"] * dt)
134        b["vx"] = b["vx"] * decay
135        b["vy"] = b["vy"] * decay
136        b["x"] = b["x"] + b["vx"] * dt
137        b["y"] = b["y"] + b["vy"] * dt
138        b["life"] = b["life"] - dt
139        b["alive"] = (b["life"] > 0).astype(np.uint8)
140
141    # ------------------------------------------------------------------ draw
142
143    def on_draw(self, renderer):
144        b = self._buf
145        live_idx = np.flatnonzero(b["alive"])
146        if live_idx.size == 0:
147            return
148        # Vectorise once
149        lifes = b["life"][live_idx]
150        life_max = b["life_max"][live_idx]
151        t = np.clip(lifes / np.maximum(life_max, 1e-6), 0.0, 1.0)
152        scales = b["scale1"][live_idx] + (b["scale0"][live_idx] - b["scale1"][live_idx]) * t
153        alphas = b["a"][live_idx] * t
154        xs = b["x"][live_idx]
155        ys = b["y"][live_idx]
156        rs = b["r"][live_idx]
157        gs = b["g"][live_idx]
158        bs = b["b"][live_idx]
159        for x, y, s, r, g, bl, a in zip(xs, ys, scales, rs, gs, bs, alphas, strict=False):
160            if s <= 0.2 or a <= 0.05:
161                continue
162            renderer.draw_circle(
163                (float(x), float(y)),
164                float(s),
165                colour=(float(r), float(g), float(bl), float(a)),
166                filled=True,
167                segments=10,
168            )