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"""
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()