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 )