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 )