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