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 )