Particle System - 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()