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.rect((p["pos"].x - hs, p["pos"].y - hs), (s, s), fill=(c[0], c[1], c[2], alpha))
170
171
172# ============================================================================
173# Theme-based ambient particles
174# ============================================================================
175
176
177class AmbientParticles(Node2D):
178    """Theme-aware ambient particle emitter for dungeon atmosphere.
179
180    Emits dust motes (default), drips (Haunted), or embers (Infernal)
181    within a given area around the camera/player.
182    """
183
184    # ``on_draw`` animates the live particle pool (non-Property state) every frame,
185    # so it is honestly ``dynamic``. It draws EVERY pool slot every frame (inactive
186    # slots at alpha 0) for a constant op count (= ``pool_size``), so the retained 2D
187    # cache patches this node in place each frame rather than re-collecting the whole
188    # scene (the "vary attributes, not the op COUNT" patch contract).
189    dynamic = True
190
191    def __init__(self, theme_name: str = "", pool_size: int = 32, **kwargs):
192        super().__init__(name="AmbientParticles", **kwargs)
193        self._theme_name = theme_name
194        self._pool = [
195            {
196                "pos": Vec2(),
197                "vel": Vec2(),
198                "colour": (1.0, 1.0, 1.0, 1.0),
199                "lifetime": 0.0,
200                "age": 0.0,
201                "active": False,
202                "size": 1,
203            }
204            for _ in range(pool_size)
205        ]
206        self._spawn_timer = 0.0
207        self._spawn_interval = 0.15
208        self._area_centre = Vec2()
209        self._area_radius = 200.0
210
211    def set_theme(self, theme_name: str) -> None:
212        self._theme_name = theme_name
213
214    def set_area(self, centre: Vec2, radius: float = 200.0) -> None:
215        self._area_centre = Vec2(centre.x, centre.y)
216        self._area_radius = radius
217
218    def on_update(self, dt: float):
219        # Spawn new particles
220        self._spawn_timer += dt
221        if self._spawn_timer >= self._spawn_interval:
222            self._spawn_timer -= self._spawn_interval
223            self._spawn_particle()
224
225        # Update existing
226        for p in self._pool:
227            if not p["active"]:
228                continue
229            p["age"] += dt
230            if p["age"] >= p["lifetime"]:
231                p["active"] = False
232                continue
233            pos = p["pos"]
234            vel = p["vel"]
235            p["pos"] = Vec2(pos.x + vel.x * dt, pos.y + vel.y * dt)
236
237    def _spawn_particle(self) -> None:
238        for p in self._pool:
239            if not p["active"]:
240                cx, cy = float(self._area_centre.x), float(self._area_centre.y)
241                r = self._area_radius
242                ox = _random.uniform(-r, r)
243                oy = _random.uniform(-r, r)
244                p["pos"] = Vec2(cx + ox, cy + oy)
245                p["active"] = True
246                p["age"] = 0.0
247
248                if "Haunted" in self._theme_name:
249                    # Drip: falls downward, blue-ish
250                    p["vel"] = Vec2(_random.uniform(-2, 2), _random.uniform(20, 50))
251                    p["colour"] = (0.3, 0.35, 0.6, 0.6)
252                    p["lifetime"] = _random.uniform(1.0, 2.5)
253                    p["size"] = 1
254                elif "Infernal" in self._theme_name:
255                    # Ember: rises upward, orange/red
256                    p["vel"] = Vec2(_random.uniform(-10, 10), _random.uniform(-40, -15))
257                    p["colour"] = (1.0, _random.uniform(0.3, 0.6), 0.05, 0.7)
258                    p["lifetime"] = _random.uniform(0.5, 1.5)
259                    p["size"] = 2
260                else:
261                    # Dust mote: slow drift
262                    p["vel"] = Vec2(_random.uniform(-8, 8), _random.uniform(-5, 5))
263                    p["colour"] = (0.7, 0.65, 0.55, 0.3)
264                    p["lifetime"] = _random.uniform(2.0, 4.0)
265                    p["size"] = 1
266                break
267
268    def on_draw(self, renderer):
269        # Draw every pool slot every frame for a constant op count (see class doc).
270        # Inactive slots draw at alpha 0 -> invisible, no rendering change.
271        for p in self._pool:
272            c = p["colour"]
273            s = p["size"]
274            if p["active"]:
275                alpha = c[3] * max(0.0, 1.0 - p["age"] / max(0.01, p["lifetime"]))
276            else:
277                alpha = 0.0
278            renderer.rect((p["pos"].x - s, p["pos"].y - s), (s * 2, s * 2), fill=(c[0], c[1], c[2], alpha))