Particle System - Fireworks

Fireworks
particle_fireworks.py
  1"""
  2Particle Fireworks
  3
  4Use a fireworks display to demonstrate "real-world" uses of Emitters and Particles
  5
  6If Python and Arcade are installed, this example can be run from the command line with:
  7python -m arcade.examples.particle_fireworks
  8"""
  9from __future__ import annotations
 10import random
 11
 12import pyglet
 13from pyglet.math import Vec2
 14
 15import arcade
 16from arcade.types import Point, PathOrTexture, LBWH
 17from arcade.math import rand_in_rect, clamp, lerp, rand_in_circle, rand_on_circle
 18from arcade.particles import (
 19    Emitter,
 20    LifetimeParticle,
 21    FadeParticle,
 22    EmitterIntervalWithTime,
 23    EmitMaintainCount,
 24    EmitBurst,
 25)
 26
 27WINDOW_WIDTH = 800
 28WINDOW_HEIGHT = 600
 29WINDOW_TITLE = "Particle based fireworks"
 30LAUNCH_INTERVAL_MIN = 1.5
 31LAUNCH_INTERVAL_MAX = 2.5
 32TEXTURE = "images/pool_cue_ball.png"
 33RAINBOW_COLORS = (
 34    arcade.color.ELECTRIC_CRIMSON,
 35    arcade.color.FLUORESCENT_ORANGE,
 36    arcade.color.ELECTRIC_YELLOW,
 37    arcade.color.ELECTRIC_GREEN,
 38    arcade.color.ELECTRIC_CYAN,
 39    arcade.color.MEDIUM_ELECTRIC_BLUE,
 40    arcade.color.ELECTRIC_INDIGO,
 41    arcade.color.ELECTRIC_PURPLE,
 42)
 43SPARK_TEXTURES = [arcade.make_circle_texture(8, clr) for clr in RAINBOW_COLORS]
 44SPARK_PAIRS = [
 45    [SPARK_TEXTURES[0], SPARK_TEXTURES[3]],
 46    [SPARK_TEXTURES[1], SPARK_TEXTURES[5]],
 47    [SPARK_TEXTURES[7], SPARK_TEXTURES[2]],
 48]
 49ROCKET_SMOKE_TEXTURE = arcade.make_soft_circle_texture(15, arcade.color.GRAY)
 50PUFF_TEXTURE = arcade.make_soft_circle_texture(80, (40, 40, 40, 255))
 51FLASH_TEXTURE = arcade.make_soft_circle_texture(70, (128, 128, 90, 255))
 52CLOUD_TEXTURES = [
 53    arcade.make_soft_circle_texture(50, arcade.color.WHITE),
 54    arcade.make_soft_circle_texture(50, arcade.color.LIGHT_GRAY),
 55    arcade.make_soft_circle_texture(50, arcade.color.LIGHT_BLUE),
 56]
 57STAR_TEXTURES = [
 58    arcade.make_soft_circle_texture(8, arcade.color.WHITE),
 59    arcade.make_soft_circle_texture(8, arcade.color.PASTEL_YELLOW),
 60]
 61SPINNER_HEIGHT = 75
 62
 63
 64def make_spinner():
 65    spinner = Emitter(
 66        center_xy=(WINDOW_WIDTH / 2, SPINNER_HEIGHT - 5),
 67        emit_controller=EmitterIntervalWithTime(0.025, 2.0),
 68        particle_factory=lambda emitter: FadeParticle(
 69            filename_or_texture=random.choice(STAR_TEXTURES), change_xy=(0, 6.0), lifetime=0.2
 70        ),
 71    )
 72    spinner.change_angle = 16.28
 73    return spinner
 74
 75
 76def make_rocket(emit_done_cb):
 77    """Emitter that displays the smoke trail as the firework shell climbs into the sky"""
 78    rocket = RocketEmitter(
 79        center_xy=(random.uniform(100, WINDOW_WIDTH - 100), 25),
 80        emit_controller=EmitterIntervalWithTime(0.04, 2.0),
 81        particle_factory=lambda emitter: FadeParticle(
 82            filename_or_texture=ROCKET_SMOKE_TEXTURE,
 83            change_xy=rand_in_circle((0.0, 0.0), 0.08),
 84            scale=0.5,
 85            lifetime=random.uniform(1.0, 1.5),
 86            start_alpha=100,
 87            end_alpha=0,
 88            mutation_callback=rocket_smoke_mutator,
 89        ),
 90        emit_done_cb=emit_done_cb,
 91    )
 92    rocket.change_x = random.uniform(-1.0, 1.0)
 93    rocket.change_y = random.uniform(5.0, 7.25)
 94    return rocket
 95
 96
 97def make_flash(prev_emitter):
 98    """Return emitter that displays the brief flash when a firework shell explodes"""
 99    return Emitter(
100        center_xy=prev_emitter.get_pos(),
101        emit_controller=EmitBurst(3),
102        particle_factory=lambda emitter: FadeParticle(
103            filename_or_texture=FLASH_TEXTURE,
104            change_xy=rand_in_circle((0.0, 0.0), 3.5),
105            lifetime=0.15,
106        ),
107    )
108
109
110def make_puff(prev_emitter):
111    """
112    Return emitter that generates the subtle smoke cloud
113    left after a firework shell explodes.
114    """
115    return Emitter(
116        center_xy=prev_emitter.get_pos(),
117        emit_controller=EmitBurst(4),
118        particle_factory=lambda emitter: FadeParticle(
119            filename_or_texture=PUFF_TEXTURE,
120            change_xy=Vec2(*rand_in_circle((0.0, 0.0), 0.4)) + Vec2(0.3, 0.0),
121            lifetime=4.0,
122        ),
123    )
124
125
126class AnimatedAlphaParticle(LifetimeParticle):
127    """A custom particle that animates between three different alpha levels"""
128
129    def __init__(
130        self,
131        filename_or_texture: PathOrTexture | None,
132        change_xy: Vec2,
133        start_alpha: int = 0,
134        duration1: float = 1.0,
135        mid_alpha: int = 255,
136        duration2: float = 1.0,
137        end_alpha: int = 0,
138        center_xy: Point = (0.0, 0.0),
139        angle: float = 0,
140        change_angle: float = 0,
141        scale: float = 1.0,
142        mutation_callback=None,
143    ):
144        super().__init__(
145            filename_or_texture,
146            change_xy,
147            duration1 + duration2,
148            center_xy,
149            angle,
150            change_angle,
151            scale,
152            start_alpha,
153            mutation_callback,
154        )
155        self.start_alpha = start_alpha
156        self.in_duration = duration1
157        self.mid_alpha = mid_alpha
158        self.out_duration = duration2
159        self.end_alpha = end_alpha
160
161    def update(self, delta_time: float = 1 / 60):
162        super().update(delta_time)
163        if self.lifetime_elapsed <= self.in_duration:
164            u = self.lifetime_elapsed / self.in_duration
165            self.alpha = int(clamp(lerp(self.start_alpha, self.mid_alpha, u), 0, 255))
166        else:
167            u = (self.lifetime_elapsed - self.in_duration) / self.out_duration
168            self.alpha = int(clamp(lerp(self.mid_alpha, self.end_alpha, u), 0, 255))
169
170
171class RocketEmitter(Emitter):
172    """
173    Custom emitter class to add gravity to the emitter to
174    represent gravity on the firework shell.
175    """
176    def update(self, delta_time: float = 1 / 60):
177        super().update(delta_time)
178        # gravity
179        self.change_y += -0.05 * (60 * delta_time)
180
181
182class GameView(arcade.View):
183    def __init__(self):
184        super().__init__()
185
186        self.background_color = arcade.color.BLACK
187        self.emitters: list[Emitter] = []
188
189        self.launch_firework(0)
190        arcade.schedule(self.launch_spinner, 4.0)
191
192        stars = Emitter(
193            center_xy=(0.0, 0.0),
194            emit_controller=EmitMaintainCount(20),
195            particle_factory=lambda emitter: AnimatedAlphaParticle(
196                filename_or_texture=random.choice(STAR_TEXTURES),
197                change_xy=(0.0, 0.0),
198                start_alpha=0,
199                duration1=random.uniform(2.0, 6.0),
200                mid_alpha=128,
201                duration2=random.uniform(2.0, 6.0),
202                end_alpha=0,
203                center_xy=rand_in_rect(LBWH(0.0, 0.0, WINDOW_WIDTH, WINDOW_HEIGHT)),
204            ),
205        )
206        self.emitters.append(stars)
207
208        x, y = rand_in_circle(center=(0.0, 0.0), radius=0.04)
209        change_vec2 = Vec2(x, y) + Vec2(0.1, 0)
210        change_tuple = change_vec2.x, change_vec2.y
211        self.cloud = Emitter(
212            center_xy=(50, 500),
213            change_xy=(0.15, 0),
214            emit_controller=EmitMaintainCount(60),
215            particle_factory=lambda emitter: AnimatedAlphaParticle(
216                filename_or_texture=random.choice(CLOUD_TEXTURES),
217                change_xy=change_tuple,
218                start_alpha=0,
219                duration1=random.uniform(5.0, 10.0),
220                mid_alpha=255,
221                duration2=random.uniform(5.0, 10.0),
222                end_alpha=0,
223                center_xy=rand_in_circle((0.0, 0.0), 50),
224            ),
225        )
226        self.emitters.append(self.cloud)
227
228    def launch_firework(self, delta_time):
229        launchers = (
230            self.launch_random_firework,
231            self.launch_ringed_firework,
232            self.launch_sparkle_firework,
233        )
234        random.choice(launchers)(delta_time)
235        pyglet.clock.schedule_once(
236            self.launch_firework,
237            random.uniform(LAUNCH_INTERVAL_MIN, LAUNCH_INTERVAL_MAX),
238        )
239
240    def launch_random_firework(self, _delta_time):
241        """Simple firework that explodes in a random color"""
242        rocket = make_rocket(self.explode_firework)
243        self.emitters.append(rocket)
244
245    def launch_ringed_firework(self, _delta_time):
246        """ "Firework that has a basic explosion and a ring of sparks of a different color"""
247        rocket = make_rocket(self.explode_ringed_firework)
248        self.emitters.append(rocket)
249
250    def launch_sparkle_firework(self, _delta_time):
251        """Firework which has sparks that sparkle"""
252        rocket = make_rocket(self.explode_sparkle_firework)
253        self.emitters.append(rocket)
254
255    def launch_spinner(self, _delta_time):
256        """Start the spinner that throws sparks"""
257        spinner1 = make_spinner()
258        spinner2 = make_spinner()
259        spinner2.angle = 180
260        self.emitters.append(spinner1)
261        self.emitters.append(spinner2)
262
263    def explode_firework(self, prev_emitter):
264        """
265        Actions that happen when a firework shell explodes,
266        resulting in a typical firework
267        """
268        self.emitters.append(make_puff(prev_emitter))
269        self.emitters.append(make_flash(prev_emitter))
270
271        spark_texture = random.choice(SPARK_TEXTURES)
272        sparks = Emitter(
273            center_xy=prev_emitter.get_pos(),
274            emit_controller=EmitBurst(random.randint(30, 40)),
275            particle_factory=lambda emitter: FadeParticle(
276                filename_or_texture=spark_texture,
277                change_xy=rand_in_circle(center=(0.0, 0.0), radius=9.0),
278                lifetime=random.uniform(0.5, 1.2),
279                mutation_callback=firework_spark_mutator,
280            ),
281        )
282        self.emitters.append(sparks)
283
284    def explode_ringed_firework(self, prev_emitter):
285        """
286        Actions that happen when a firework shell explodes,
287        resulting in a ringed firework.
288        """
289        self.emitters.append(make_puff(prev_emitter))
290        self.emitters.append(make_flash(prev_emitter))
291
292        spark_texture, ring_texture = random.choice(SPARK_PAIRS)
293        sparks = Emitter(
294            center_xy=prev_emitter.get_pos(),
295            emit_controller=EmitBurst(25),
296            particle_factory=lambda emitter: FadeParticle(
297                filename_or_texture=spark_texture,
298                change_xy=rand_in_circle((0.0, 0.0), 8.0),
299                lifetime=random.uniform(0.55, 0.8),
300                mutation_callback=firework_spark_mutator,
301            ),
302        )
303        self.emitters.append(sparks)
304
305        ring = Emitter(
306            center_xy=prev_emitter.get_pos(),
307            emit_controller=EmitBurst(20),
308            particle_factory=lambda emitter: FadeParticle(
309                filename_or_texture=ring_texture,
310                change_xy=rand_on_circle(center=(0.0, 0.0), radius=5.0),
311                lifetime=random.uniform(1.0, 1.6),
312                mutation_callback=firework_spark_mutator,
313            ),
314        )
315        self.emitters.append(ring)
316
317    def explode_sparkle_firework(self, prev_emitter):
318        """
319        Actions that happen when a firework shell explodes,
320        resulting in a sparkling firework.
321        """
322        self.emitters.append(make_puff(prev_emitter))
323        self.emitters.append(make_flash(prev_emitter))
324
325        spark_texture = random.choice(SPARK_TEXTURES)
326        sparks = Emitter(
327            center_xy=prev_emitter.get_pos(),
328            emit_controller=EmitBurst(random.randint(30, 40)),
329            particle_factory=lambda emitter: AnimatedAlphaParticle(
330                filename_or_texture=spark_texture,
331                change_xy=rand_in_circle(center=(0.0, 0.0), radius=9.0),
332                start_alpha=255,
333                duration1=random.uniform(0.6, 1.0),
334                mid_alpha=0,
335                duration2=random.uniform(0.1, 0.2),
336                end_alpha=255,
337                mutation_callback=firework_spark_mutator,
338            ),
339        )
340        self.emitters.append(sparks)
341
342    def on_update(self, delta_time):
343        # prevent list from being mutated (often by callbacks) while iterating over it
344        emitters_to_update = self.emitters.copy()
345        # update cloud
346        if self.cloud.center_x > WINDOW_WIDTH:
347            self.cloud.center_x = 0
348        # update
349        for e in emitters_to_update:
350            e.update(delta_time)
351        # remove emitters that can be reaped
352        to_del = [e for e in emitters_to_update if e.can_reap()]
353        for e in to_del:
354            self.emitters.remove(e)
355
356    def on_draw(self):
357        self.clear()
358        for e in self.emitters:
359            e.draw()
360        arcade.draw_lrbt_rectangle_filled(0, WINDOW_WIDTH, 0, 25, arcade.color.DARK_GREEN)
361        mid = WINDOW_WIDTH / 2
362        arcade.draw_lrbt_rectangle_filled(
363            mid - 2,
364            mid + 2,
365            10,
366            SPINNER_HEIGHT,
367            arcade.color.DARK_BROWN,
368        )
369
370    def on_key_press(self, key, modifiers):
371        if key == arcade.key.ESCAPE:
372            self.window.close()
373
374
375def firework_spark_mutator(particle: FadeParticle):
376    """mutation_callback shared by all fireworks sparks"""
377    # gravity
378    particle.change_y += -0.03
379    # drag
380    particle.change_x *= 0.92
381    particle.change_y *= 0.92
382
383
384def rocket_smoke_mutator(particle: LifetimeParticle):
385    particle.scale = lerp(
386        0.5,
387        3.0,
388        particle.lifetime_elapsed / particle.lifetime_original,  # type: ignore
389    )
390
391
392
393def main():
394    """ Main function """
395    # Create a window class. This is what actually shows up on screen
396    window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
397
398    # Create and setup the GameView
399    game = GameView()
400
401    # Show GameView on screen
402    window.show_view(game)
403
404    # Start the arcade game loop
405    arcade.run()
406
407
408if __name__ == "__main__":
409    main()