Back to Components
Fireworker Simulator | Realistic Fireworks Show Experience with Explosive Visual Effects
Component

Fireworker Simulator | Realistic Fireworks Show Experience with Explosive Visual Effects

CodewithLord
October 9, 2025

Fireworker Simulator lets you design, launch, and control dazzling fireworks displays with lifelike motion and vibrant particle effects.

🧠 Description

Fireworker Simulator lets you design, launch, and control dazzling fireworks displays with lifelike motion and vibrant particle effects. Built to simulate real pyrotechnics, it blends physics-based animation, colorful explosions, and interactive controls to create a stunning visual spectacle. Whether for fun, learning, or creative expression, this simulator delivers an immersive experience where every burst lights up the virtual sky in perfect sync and style.


💻 HTML Code


1<!DOCTYPE html> 2<html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <title>Firework Simulator v2</title> 7 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> 8<meta name="mobile-web-app-capable" content="yes"> 9<meta name="apple-mobile-web-app-capable" content="yes"> 10<meta name="theme-color" content="#000000"> 11<link rel="shortcut icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png"> 12<link rel="icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png"> 13<link rel="apple-touch-icon-precomposed" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png"> 14<meta name="msapplication-TileColor" content="#000000"> 15<meta name="msapplication-TileImage" content="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png"> 16<link href="https://fonts.googleapis.com/css?family=Russo+One" rel="stylesheet"><link rel="stylesheet" href="https://public.codepenassets.com/css/reset-2.0.min.css"> 17<link rel="stylesheet" href="./style.css"> 18 19 </head> 20 21 <body> 22 <!-- SVG Spritesheet --> 23<div style="height: 0; width: 0; position: absolute; visibility: hidden;"> 24 <svg xmlns="http://www.w3.org/2000/svg"> 25 <symbol id="icon-play" viewBox="0 0 24 24"> 26 <path d="M8 5v14l11-7z"/> 27 </symbol> 28 <symbol id="icon-pause" viewBox="0 0 24 24"> 29 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/> 30 </symbol> 31 <symbol id="icon-close" viewBox="0 0 24 24"> 32 <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> 33 </symbol> 34 <symbol id="icon-settings" viewBox="0 0 24 24"> 35 <path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/> 36 </symbol> 37 <symbol id="icon-sound-on" viewBox="0 0 24 24"> 38 <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/> 39 </symbol> 40 <symbol id="icon-sound-off" viewBox="0 0 24 24"> 41 <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/> 42 </symbol> 43 </svg> 44</div> 45 46<!-- App --> 47<div class="container"> 48 <div class="loading-init"> 49 <div class="loading-init__header">Loading</div> 50 <div class="loading-init__status">Assembling Shells</div> 51 </div> 52 <div class="stage-container remove"> 53 <div class="canvas-container"> 54 <canvas id="trails-canvas"></canvas> 55 <canvas id="main-canvas"></canvas> 56 </div> 57 <div class="controls"> 58 <div class="btn pause-btn"> 59 <svg fill="white" width="24" height="24"><use href="#icon-pause" xlink:href="#icon-pause"></use></svg> 60 </div> 61 <div class="btn sound-btn"> 62 <svg fill="white" width="24" height="24"><use href="#icon-sound-off" xlink:href="#icon-sound-off"></use></svg> 63 </div> 64 <div class="btn settings-btn"> 65 <svg fill="white" width="24" height="24"><use href="#icon-settings" xlink:href="#icon-settings"></use></svg> 66 </div> 67 </div> 68 <div class="menu hide"> 69 <div class="menu__inner-wrap"> 70 <div class="btn btn--bright close-menu-btn"> 71 <svg fill="white" width="24" height="24"><use href="#icon-close" xlink:href="#icon-close"></use></svg> 72 </div> 73 <div class="menu__header">Settings</div> 74 <div class="menu__subheader">For more info, click any label.</div> 75 <form> 76 <div class="form-option form-option--select"> 77 <label class="shell-type-label">Shell Type</label> 78 <select class="shell-type"></select> 79 </div> 80 <div class="form-option form-option--select"> 81 <label class="shell-size-label">Shell Size</label> 82 <select class="shell-size"></select> 83 </div> 84 <div class="form-option form-option--select"> 85 <label class="quality-ui-label">Quality</label> 86 <select class="quality-ui"></select> 87 </div> 88 <div class="form-option form-option--select"> 89 <label class="sky-lighting-label">Sky Lighting</label> 90 <select class="sky-lighting"></select> 91 </div> 92 <div class="form-option form-option--select"> 93 <label class="scaleFactor-label">Scale</label> 94 <select class="scaleFactor"></select> 95 </div> 96 <div class="form-option form-option--checkbox"> 97 <label class="auto-launch-label">Auto Fire</label> 98 <input class="auto-launch" type="checkbox" /> 99 </div> 100 <div class="form-option form-option--checkbox form-option--finale-mode"> 101 <label class="finale-mode-label">Finale Mode</label> 102 <input class="finale-mode" type="checkbox" /> 103 </div> 104 <div class="form-option form-option--checkbox"> 105 <label class="hide-controls-label">Hide Controls</label> 106 <input class="hide-controls" type="checkbox" /> 107 </div> 108 <div class="form-option form-option--checkbox form-option--fullscreen"> 109 <label class="fullscreen-label">Fullscreen</label> 110 <input class="fullscreen" type="checkbox" /> 111 </div> 112 <div class="form-option form-option--checkbox"> 113 <label class="long-exposure-label">Open Shutter</label> 114 <input class="long-exposure" type="checkbox" /> 115 </div> 116 </form> 117 <div class="credits"> 118 Passionately built by <a href="https://cmiller.tech/" target="_blank">Caleb Miller</a>. 119 </div> 120 </div> 121 </div> 122 </div> 123 <div class="help-modal"> 124 <div class="help-modal__overlay"></div> 125 <div class="help-modal__dialog"> 126 <div class="help-modal__header"></div> 127 <div class="help-modal__body"></div> 128 <button type="button" class="help-modal__close-btn">Close</button> 129 </div> 130 </div> 131</div> 132 <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/fscreen%401.0.1.js'></script> 133<script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Stage%400.1.4.js'></script> 134<script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/MyMath.js'></script><script src="./script.js"></script> 135 136 </body> 137 138</html> 139

CSS Code

1* { 2 position: relative; 3 box-sizing: border-box; 4} 5 6html, 7body { 8 height: 100%; 9} 10 11html { 12 background-color: #000; 13} 14 15body { 16 overflow: hidden; 17 color: rgba(255, 255, 255, 0.5); 18 font-family: "Russo One", arial, sans-serif; 19 line-height: 1.25; 20 letter-spacing: 0.06em; 21} 22 23.hide { 24 opacity: 0; 25 visibility: hidden; 26} 27 28.remove { 29 display: none !important; 30} 31 32.blur { 33 filter: blur(12px); 34} 35 36.container { 37 height: 100%; 38 display: flex; 39 justify-content: center; 40 align-items: center; 41} 42 43.loading-init { 44 width: 100%; 45 align-self: center; 46 text-align: center; 47 text-transform: uppercase; 48} 49.loading-init__header { 50 font-size: 2.2em; 51} 52.loading-init__status { 53 margin-top: 1em; 54 font-size: 0.8em; 55 opacity: 0.75; 56} 57 58.stage-container { 59 overflow: hidden; 60 box-sizing: initial; 61 border: 1px solid #222; 62 margin: -1px; 63} 64@media (max-width: 840px) { 65 .stage-container { 66 border: none; 67 margin: 0; 68 } 69} 70 71.canvas-container { 72 width: 100%; 73 height: 100%; 74 transition: filter 0.3s; 75} 76.canvas-container canvas { 77 position: absolute; 78 mix-blend-mode: lighten; 79 transform: translateZ(0); 80} 81 82.controls { 83 position: absolute; 84 top: 0; 85 width: 100%; 86 padding-bottom: 50px; 87 display: flex; 88 justify-content: space-between; 89 transition: opacity 0.3s, visibility 0.3s; 90} 91@media (min-width: 840px) { 92 .controls { 93 visibility: visible; 94 } 95 .controls.hide:hover { 96 opacity: 1; 97 } 98} 99 100.menu { 101 position: absolute; 102 top: 0; 103 bottom: 0; 104 left: 0; 105 right: 0; 106 background-color: rgba(0, 0, 0, 0.42); 107 transition: opacity 0.3s, visibility 0.3s; 108} 109.menu__inner-wrap { 110 display: flex; 111 flex-direction: column; 112 justify-content: center; 113 align-items: center; 114 position: absolute; 115 top: 0; 116 bottom: 0; 117 left: 0; 118 right: 0; 119 transition: opacity 0.3s; 120} 121.menu__header { 122 margin-top: auto; 123 margin-bottom: 8px; 124 padding-top: 16px; 125 font-size: 2em; 126 text-transform: uppercase; 127} 128.menu__subheader { 129 margin-bottom: auto; 130 padding-bottom: 12px; 131 font-size: 0.86em; 132 opacity: 0.8; 133} 134.menu form { 135 width: 100%; 136 max-width: 400px; 137 padding: 0 10px; 138 overflow: auto; 139 -webkit-overflow-scrolling: touch; 140} 141.menu .form-option { 142 display: flex; 143 align-items: center; 144 margin: 16px 0; 145 transition: opacity 0.3s; 146} 147.menu .form-option label { 148 display: block; 149 width: 50%; 150 padding-right: 12px; 151 text-align: right; 152 text-transform: uppercase; 153 -webkit-user-select: none; 154 -moz-user-select: none; 155 -ms-user-select: none; 156 user-select: none; 157} 158.menu .form-option--select select { 159 display: block; 160 width: 50%; 161 height: 30px; 162 font-size: 1rem; 163 font-family: "Russo One", arial, sans-serif; 164 color: rgba(255, 255, 255, 0.5); 165 letter-spacing: 0.06em; 166 background-color: transparent; 167 border: 1px solid rgba(255, 255, 255, 0.5); 168} 169.menu .form-option--select select option { 170 background-color: black; 171} 172.menu .form-option--checkbox input { 173 display: block; 174 width: 26px; 175 height: 26px; 176 margin: 0; 177 opacity: 0.5; 178} 179@media (max-width: 840px) { 180 .menu .form-option select, .menu .form-option input { 181 outline: none; 182 } 183} 184 185.close-menu-btn { 186 position: absolute; 187 top: 0; 188 right: 0; 189} 190 191.btn { 192 opacity: 0.16; 193 width: 50px; 194 height: 50px; 195 display: flex; 196 -webkit-user-select: none; 197 -moz-user-select: none; 198 -ms-user-select: none; 199 user-select: none; 200 cursor: default; 201 transition: opacity 0.3s; 202} 203.btn--bright { 204 opacity: 0.5; 205} 206@media (min-width: 840px) { 207 .btn:hover { 208 opacity: 0.32; 209 } 210 .btn--bright:hover { 211 opacity: 0.75; 212 } 213} 214.btn svg { 215 display: block; 216 margin: auto; 217} 218 219.credits { 220 margin-top: auto; 221 margin-bottom: 10px; 222 padding-top: 6px; 223 font-size: 0.8em; 224 opacity: 0.75; 225} 226.credits a { 227 color: rgba(255, 255, 255, 0.5); 228 text-decoration: none; 229} 230.credits a:hover, .credits a:active { 231 color: rgba(255, 255, 255, 0.75); 232 text-decoration: underline; 233} 234 235.help-modal { 236 display: flex; 237 justify-content: center; 238 align-items: center; 239 position: fixed; 240 top: 0; 241 bottom: 0; 242 left: 0; 243 right: 0; 244 visibility: hidden; 245 transition-property: visibility; 246 transition-duration: 0.25s; 247} 248.help-modal__overlay { 249 position: absolute; 250 top: 0; 251 bottom: 0; 252 left: 0; 253 right: 0; 254 opacity: 0; 255 transition-property: opacity; 256 transition-timing-function: ease-in; 257 transition-duration: 0.25s; 258} 259.help-modal__dialog { 260 display: flex; 261 flex-direction: column; 262 align-items: center; 263 max-width: 400px; 264 max-height: calc(100vh - 100px); 265 margin: 10px; 266 padding: 20px; 267 border-radius: 0.3em; 268 background-color: rgba(0, 0, 0, 0.4); 269 opacity: 0; 270 transform: scale(0.9, 0.9); 271 transition-property: opacity, transform; 272 transition-timing-function: ease-in; 273 transition-duration: 0.25s; 274} 275@media (min-width: 840px) { 276 .help-modal__dialog { 277 font-size: 1.25rem; 278 max-width: 500px; 279 } 280} 281.help-modal__header { 282 font-size: 1.75em; 283 text-transform: uppercase; 284 text-align: center; 285} 286.help-modal__body { 287 overflow-y: auto; 288 -webkit-overflow-scrolling: touch; 289 margin: 1em 0; 290 padding: 1em 0; 291 border-top: 1px solid rgba(255, 255, 255, 0.25); 292 border-bottom: 1px solid rgba(255, 255, 255, 0.25); 293 line-height: 1.5; 294 color: rgba(255, 255, 255, 0.75); 295} 296.help-modal__close-btn { 297 flex-shrink: 0; 298 outline: none; 299 border: none; 300 border-radius: 2px; 301 padding: 0.25em 0.75em; 302 margin-top: 0.36em; 303 font-family: "Russo One", arial, sans-serif; 304 font-size: 1em; 305 color: rgba(255, 255, 255, 0.5); 306 text-transform: uppercase; 307 letter-spacing: 0.06em; 308 background-color: rgba(255, 255, 255, 0.25); 309 transition: color 0.3s, background-color 0.3s; 310} 311.help-modal__close-btn:hover, .help-modal__close-btn:active, .help-modal__close-btn:focus { 312 color: #FFF; 313 background-color: #09F; 314} 315.help-modal.active { 316 visibility: visible; 317 transition-duration: 0.4s; 318} 319.help-modal.active .help-modal__overlay { 320 opacity: 1; 321 transition-timing-function: ease-out; 322 transition-duration: 0.4s; 323} 324.help-modal.active .help-modal__dialog { 325 opacity: 1; 326 transform: scale(1, 1); 327 transition-timing-function: ease-out; 328 transition-duration: 0.4s; 329}

Javascipt Code


1'use strict'; 2console.clear(); 3 4// This is a prime example of what starts out as a simple project 5// and snowballs way beyond its intended size. It's a little clunky 6// reading/working on this single file, but here it is anyways :) 7 8const IS_MOBILE = window.innerWidth <= 640; 9const IS_DESKTOP = window.innerWidth > 800; 10const IS_HEADER = IS_DESKTOP && window.innerHeight < 300; 11// Detect high end devices. This will be a moving target. 12const IS_HIGH_END_DEVICE = (() => { 13 const hwConcurrency = navigator.hardwareConcurrency; 14 if (!hwConcurrency) { 15 return false; 16 } 17 // Large screens indicate a full size computer, which often have hyper threading these days. 18 // So a quad core desktop machine has 8 cores. We'll place a higher min threshold there. 19 const minCount = window.innerWidth <= 1024 ? 4 : 8; 20 return hwConcurrency >= minCount; 21})(); 22// Prevent canvases from getting too large on ridiculous screen sizes. 23// 8K - can restrict this if needed 24const MAX_WIDTH = 7680; 25const MAX_HEIGHT = 4320; 26const GRAVITY = 0.9; // Acceleration in px/s 27let simSpeed = 1; 28 29function getDefaultScaleFactor() { 30 if (IS_MOBILE) return 0.9; 31 if (IS_HEADER) return 0.75; 32 return 1; 33} 34 35// Width/height values that take scale into account. 36// USE THESE FOR DRAWING POSITIONS 37let stageW, stageH; 38 39// All quality globals will be overwritten and updated via `configDidUpdate`. 40let quality = 1; 41let isLowQuality = false; 42let isNormalQuality = true; 43let isHighQuality = false; 44 45const QUALITY_LOW = 1; 46const QUALITY_NORMAL = 2; 47const QUALITY_HIGH = 3; 48 49const SKY_LIGHT_NONE = 0; 50const SKY_LIGHT_DIM = 1; 51const SKY_LIGHT_NORMAL = 2; 52 53const COLOR = { 54 Red: '#ff0043', 55 Green: '#14fc56', 56 Blue: '#1e7fff', 57 Purple: '#e60aff', 58 Gold: '#ffbf36', 59 White: '#ffffff' 60}; 61 62// Special invisible color (not rendered, and therefore not in COLOR map) 63const INVISIBLE = '_INVISIBLE_'; 64 65const PI_2 = Math.PI * 2; 66const PI_HALF = Math.PI * 0.5; 67 68// Stage.disableHighDPI = true; 69const trailsStage = new Stage('trails-canvas'); 70const mainStage = new Stage('main-canvas'); 71const stages = [ 72 trailsStage, 73 mainStage 74]; 75 76 77 78// Fullscreen helpers, using Fscreen for prefixes. 79function fullscreenEnabled() { 80 return fscreen.fullscreenEnabled; 81} 82 83// Note that fullscreen state is synced to store, and the store should be the source 84// of truth for whether the app is in fullscreen mode or not. 85function isFullscreen() { 86 return !!fscreen.fullscreenElement; 87} 88 89// Attempt to toggle fullscreen mode. 90function toggleFullscreen() { 91 if (fullscreenEnabled()) { 92 if (isFullscreen()) { 93 fscreen.exitFullscreen(); 94 } else { 95 fscreen.requestFullscreen(document.documentElement); 96 } 97 } 98} 99 100// Sync fullscreen changes with store. An event listener is necessary because the user can 101// toggle fullscreen mode directly through the browser, and we want to react to that. 102fscreen.addEventListener('fullscreenchange', () => { 103 store.setState({ fullscreen: isFullscreen() }); 104}); 105 106 107 108 109// Simple state container; the source of truth. 110const store = { 111 _listeners: new Set(), 112 _dispatch(prevState) { 113 this._listeners.forEach(listener => listener(this.state, prevState)) 114 }, 115 116 state: { 117 // will be unpaused in init() 118 paused: true, 119 soundEnabled: false, 120 menuOpen: false, 121 openHelpTopic: null, 122 fullscreen: isFullscreen(), 123 // Note that config values used for <select>s must be strings, unless manually converting values to strings 124 // at render time, and parsing on change. 125 config: { 126 quality: String(IS_HIGH_END_DEVICE ? QUALITY_HIGH : QUALITY_NORMAL), // will be mirrored to a global variable named `quality` in `configDidUpdate`, for perf. 127 shell: 'Random', 128 size: IS_DESKTOP 129 ? '3' // Desktop default 130 : IS_HEADER 131 ? '1.2' // Profile header default (doesn't need to be an int) 132 : '2', // Mobile default 133 autoLaunch: true, 134 finale: false, 135 skyLighting: SKY_LIGHT_NORMAL + '', 136 hideControls: IS_HEADER, 137 longExposure: false, 138 scaleFactor: getDefaultScaleFactor() 139 } 140 }, 141 142 setState(nextState) { 143 const prevState = this.state; 144 this.state = Object.assign({}, this.state, nextState); 145 this._dispatch(prevState); 146 this.persist(); 147 }, 148 149 subscribe(listener) { 150 this._listeners.add(listener); 151 return () => this._listeners.remove(listener); 152 }, 153 154 // Load / persist select state to localStorage 155 // Mutates state because `store.load()` should only be called once immediately after store is created, before any subscriptions. 156 load() { 157 const serializedData = localStorage.getItem('cm_fireworks_data'); 158 if (serializedData) { 159 const { 160 schemaVersion, 161 data 162 } = JSON.parse(serializedData); 163 164 const config = this.state.config; 165 switch(schemaVersion) { 166 case '1.1': 167 config.quality = data.quality; 168 config.size = data.size; 169 config.skyLighting = data.skyLighting; 170 break; 171 case '1.2': 172 config.quality = data.quality; 173 config.size = data.size; 174 config.skyLighting = data.skyLighting; 175 config.scaleFactor = data.scaleFactor; 176 break; 177 default: 178 throw new Error('version switch should be exhaustive'); 179 } 180 console.log(`Loaded config (schema version ${schemaVersion})`); 181 } 182 // Deprecated data format. Checked with care (it's not namespaced). 183 else if (localStorage.getItem('schemaVersion') === '1') { 184 let size; 185 // Attempt to parse data, ignoring if there is an error. 186 try { 187 const sizeRaw = localStorage.getItem('configSize'); 188 size = typeof sizeRaw === 'string' && JSON.parse(sizeRaw); 189 } 190 catch(e) { 191 console.log('Recovered from error parsing saved config:'); 192 console.error(e); 193 return; 194 } 195 // Only restore validated values 196 const sizeInt = parseInt(size, 10); 197 if (sizeInt >= 0 && sizeInt <= 4) { 198 this.state.config.size = String(sizeInt); 199 } 200 } 201 }, 202 203 persist() { 204 const config = this.state.config; 205 localStorage.setItem('cm_fireworks_data', JSON.stringify({ 206 schemaVersion: '1.2', 207 data: { 208 quality: config.quality, 209 size: config.size, 210 skyLighting: config.skyLighting, 211 scaleFactor: config.scaleFactor 212 } 213 })); 214 } 215}; 216 217 218if (!IS_HEADER) { 219 store.load(); 220} 221 222// Actions 223// --------- 224 225function togglePause(toggle) { 226 const paused = store.state.paused; 227 let newValue; 228 if (typeof toggle === 'boolean') { 229 newValue = toggle; 230 } else { 231 newValue = !paused; 232 } 233 234 if (paused !== newValue) { 235 store.setState({ paused: newValue }); 236 } 237} 238 239function toggleSound(toggle) { 240 if (typeof toggle === 'boolean') { 241 store.setState({ soundEnabled: toggle }); 242 } else { 243 store.setState({ soundEnabled: !store.state.soundEnabled }); 244 } 245} 246 247function toggleMenu(toggle) { 248 if (typeof toggle === 'boolean') { 249 store.setState({ menuOpen: toggle }); 250 } else { 251 store.setState({ menuOpen: !store.state.menuOpen }); 252 } 253} 254 255function updateConfig(nextConfig) { 256 nextConfig = nextConfig || getConfigFromDOM(); 257 store.setState({ 258 config: Object.assign({}, store.state.config, nextConfig) 259 }); 260 261 configDidUpdate(); 262} 263 264// Map config to various properties & apply side effects 265function configDidUpdate() { 266 const config = store.state.config; 267 268 quality = qualitySelector(); 269 isLowQuality = quality === QUALITY_LOW; 270 isNormalQuality = quality === QUALITY_NORMAL; 271 isHighQuality = quality === QUALITY_HIGH; 272 273 if (skyLightingSelector() === SKY_LIGHT_NONE) { 274 appNodes.canvasContainer.style.backgroundColor = '#000'; 275 } 276 277 Spark.drawWidth = quality === QUALITY_HIGH ? 0.75 : 1; 278} 279 280// Selectors 281// ----------- 282 283const isRunning = (state=store.state) => !state.paused && !state.menuOpen; 284// Whether user has enabled sound. 285const soundEnabledSelector = (state=store.state) => state.soundEnabled; 286// Whether any sounds are allowed, taking into account multiple factors. 287const canPlaySoundSelector = (state=store.state) => isRunning(state) && soundEnabledSelector(state); 288// Convert quality to number. 289const qualitySelector = () => +store.state.config.quality; 290const shellNameSelector = () => store.state.config.shell; 291// Convert shell size to number. 292const shellSizeSelector = () => +store.state.config.size; 293const finaleSelector = () => store.state.config.finale; 294const skyLightingSelector = () => +store.state.config.skyLighting; 295const scaleFactorSelector = () => store.state.config.scaleFactor; 296 297 298 299// Help Content 300const helpContent = { 301 shellType: { 302 header: 'Shell Type', 303 body: 'The type of firework that will be launched. Select "Random" for a nice assortment!' 304 }, 305 shellSize: { 306 header: 'Shell Size', 307 body: 'The size of the fireworks. Modeled after real firework shell sizes, larger shells have bigger bursts with more stars, and sometimes more complex effects. However, larger shells also require more processing power and may cause lag.' 308 }, 309 quality: { 310 header: 'Quality', 311 body: 'Overall graphics quality. If the animation is not running smoothly, try lowering the quality. High quality greatly increases the amount of sparks rendered and may cause lag.' 312 }, 313 skyLighting: { 314 header: 'Sky Lighting', 315 body: 'Illuminates the background as fireworks explode. If the background looks too bright on your screen, try setting it to "Dim" or "None".' 316 }, 317 scaleFactor: { 318 header: 'Scale', 319 body: 'Allows scaling the size of all fireworks, essentially moving you closer or farther away. For larger shell sizes, it can be convenient to decrease the scale a bit, especially on phones or tablets.' 320 }, 321 autoLaunch: { 322 header: 'Auto Fire', 323 body: 'Launches sequences of fireworks automatically. Sit back and enjoy the show, or disable to have full control.' 324 }, 325 finaleMode: { 326 header: 'Finale Mode', 327 body: 'Launches intense bursts of fireworks. May cause lag. Requires "Auto Fire" to be enabled.' 328 }, 329 hideControls: { 330 header: 'Hide Controls', 331 body: 'Hides the translucent controls along the top of the screen. Useful for screenshots, or just a more seamless experience. While hidden, you can still tap the top-right corner to re-open this menu.' 332 }, 333 fullscreen: { 334 header: 'Fullscreen', 335 body: 'Toggles fullscreen mode.' 336 }, 337 longExposure: { 338 header: 'Open Shutter', 339 body: 'Experimental effect that preserves long streaks of light, similar to leaving a camera shutter open.' 340 } 341}; 342 343const nodeKeyToHelpKey = { 344 shellTypeLabel: 'shellType', 345 shellSizeLabel: 'shellSize', 346 qualityLabel: 'quality', 347 skyLightingLabel: 'skyLighting', 348 scaleFactorLabel: 'scaleFactor', 349 autoLaunchLabel: 'autoLaunch', 350 finaleModeLabel: 'finaleMode', 351 hideControlsLabel: 'hideControls', 352 fullscreenLabel: 'fullscreen', 353 longExposureLabel: 'longExposure' 354}; 355 356 357// Render app UI / keep in sync with state 358const appNodes = { 359 stageContainer: '.stage-container', 360 canvasContainer: '.canvas-container', 361 controls: '.controls', 362 menu: '.menu', 363 menuInnerWrap: '.menu__inner-wrap', 364 pauseBtn: '.pause-btn', 365 pauseBtnSVG: '.pause-btn use', 366 soundBtn: '.sound-btn', 367 soundBtnSVG: '.sound-btn use', 368 shellType: '.shell-type', 369 shellTypeLabel: '.shell-type-label', 370 shellSize: '.shell-size', 371 shellSizeLabel: '.shell-size-label', 372 quality: '.quality-ui', 373 qualityLabel: '.quality-ui-label', 374 skyLighting: '.sky-lighting', 375 skyLightingLabel: '.sky-lighting-label', 376 scaleFactor: '.scaleFactor', 377 scaleFactorLabel: '.scaleFactor-label', 378 autoLaunch: '.auto-launch', 379 autoLaunchLabel: '.auto-launch-label', 380 finaleModeFormOption: '.form-option--finale-mode', 381 finaleMode: '.finale-mode', 382 finaleModeLabel: '.finale-mode-label', 383 hideControls: '.hide-controls', 384 hideControlsLabel: '.hide-controls-label', 385 fullscreenFormOption: '.form-option--fullscreen', 386 fullscreen: '.fullscreen', 387 fullscreenLabel: '.fullscreen-label', 388 longExposure: '.long-exposure', 389 longExposureLabel: '.long-exposure-label', 390 391 // Help UI 392 helpModal: '.help-modal', 393 helpModalOverlay: '.help-modal__overlay', 394 helpModalHeader: '.help-modal__header', 395 helpModalBody: '.help-modal__body', 396 helpModalCloseBtn: '.help-modal__close-btn' 397}; 398 399// Convert appNodes selectors to dom nodes 400Object.keys(appNodes).forEach(key => { 401 appNodes[key] = document.querySelector(appNodes[key]); 402}); 403 404// Remove fullscreen control if not supported. 405if (!fullscreenEnabled()) { 406 appNodes.fullscreenFormOption.classList.add('remove'); 407} 408 409// First render is called in init() 410function renderApp(state) { 411 const pauseBtnIcon = `#icon-${state.paused ? 'play' : 'pause'}`; 412 const soundBtnIcon = `#icon-sound-${soundEnabledSelector() ? 'on' : 'off'}`; 413 appNodes.pauseBtnSVG.setAttribute('href', pauseBtnIcon); 414 appNodes.pauseBtnSVG.setAttribute('xlink:href', pauseBtnIcon); 415 appNodes.soundBtnSVG.setAttribute('href', soundBtnIcon); 416 appNodes.soundBtnSVG.setAttribute('xlink:href', soundBtnIcon); 417 appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls); 418 appNodes.canvasContainer.classList.toggle('blur', state.menuOpen); 419 appNodes.menu.classList.toggle('hide', !state.menuOpen); 420 appNodes.finaleModeFormOption.style.opacity = state.config.autoLaunch ? 1 : 0.32; 421 422 appNodes.quality.value = state.config.quality; 423 appNodes.shellType.value = state.config.shell; 424 appNodes.shellSize.value = state.config.size; 425 appNodes.autoLaunch.checked = state.config.autoLaunch; 426 appNodes.finaleMode.checked = state.config.finale; 427 appNodes.skyLighting.value = state.config.skyLighting; 428 appNodes.hideControls.checked = state.config.hideControls; 429 appNodes.fullscreen.checked = state.fullscreen; 430 appNodes.longExposure.checked = state.config.longExposure; 431 appNodes.scaleFactor.value = state.config.scaleFactor.toFixed(2); 432 433 appNodes.menuInnerWrap.style.opacity = state.openHelpTopic ? 0.12 : 1; 434 appNodes.helpModal.classList.toggle('active', !!state.openHelpTopic); 435 if (state.openHelpTopic) { 436 const { header, body } = helpContent[state.openHelpTopic]; 437 appNodes.helpModalHeader.textContent = header; 438 appNodes.helpModalBody.textContent = body; 439 } 440} 441 442store.subscribe(renderApp); 443 444// Perform side effects on state changes 445function handleStateChange(state, prevState) { 446 const canPlaySound = canPlaySoundSelector(state); 447 const canPlaySoundPrev = canPlaySoundSelector(prevState); 448 449 if (canPlaySound !== canPlaySoundPrev) { 450 if (canPlaySound) { 451 soundManager.resumeAll(); 452 } else { 453 soundManager.pauseAll(); 454 } 455 } 456} 457 458store.subscribe(handleStateChange); 459 460 461function getConfigFromDOM() { 462 return { 463 quality: appNodes.quality.value, 464 shell: appNodes.shellType.value, 465 size: appNodes.shellSize.value, 466 autoLaunch: appNodes.autoLaunch.checked, 467 finale: appNodes.finaleMode.checked, 468 skyLighting: appNodes.skyLighting.value, 469 longExposure: appNodes.longExposure.checked, 470 hideControls: appNodes.hideControls.checked, 471 // Store value as number. 472 scaleFactor: parseFloat(appNodes.scaleFactor.value) 473 }; 474}; 475 476const updateConfigNoEvent = () => updateConfig(); 477appNodes.quality.addEventListener('input', updateConfigNoEvent); 478appNodes.shellType.addEventListener('input', updateConfigNoEvent); 479appNodes.shellSize.addEventListener('input', updateConfigNoEvent); 480appNodes.autoLaunch.addEventListener('click', () => setTimeout(updateConfig, 0)); 481appNodes.finaleMode.addEventListener('click', () => setTimeout(updateConfig, 0)); 482appNodes.skyLighting.addEventListener('input', updateConfigNoEvent); 483appNodes.longExposure.addEventListener('click', () => setTimeout(updateConfig, 0)); 484appNodes.hideControls.addEventListener('click', () => setTimeout(updateConfig, 0)); 485appNodes.fullscreen.addEventListener('click', () => setTimeout(toggleFullscreen, 0)); 486// Changing scaleFactor requires triggering resize handling code as well. 487appNodes.scaleFactor.addEventListener('input', () => { 488 updateConfig(); 489 handleResize(); 490}); 491 492Object.keys(nodeKeyToHelpKey).forEach(nodeKey => { 493 const helpKey = nodeKeyToHelpKey[nodeKey]; 494 appNodes[nodeKey].addEventListener('click', () => { 495 store.setState({ openHelpTopic: helpKey }); 496 }); 497}); 498 499appNodes.helpModalCloseBtn.addEventListener('click', () => { 500 store.setState({ openHelpTopic: null }); 501}); 502 503appNodes.helpModalOverlay.addEventListener('click', () => { 504 store.setState({ openHelpTopic: null }); 505}); 506 507 508 509// Constant derivations 510const COLOR_NAMES = Object.keys(COLOR); 511const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]); 512// Invisible stars need an indentifier, even through they won't be rendered - physics still apply. 513const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE]; 514// Map of color codes to their index in the array. Useful for quickly determining if a color has already been updated in a loop. 515const COLOR_CODE_INDEXES = COLOR_CODES_W_INVIS.reduce((obj, code, i) => { 516 obj[code] = i; 517 return obj; 518}, {}); 519// Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects). 520const COLOR_TUPLES = {}; 521COLOR_CODES.forEach(hex => { 522 COLOR_TUPLES[hex] = { 523 r: parseInt(hex.substr(1, 2), 16), 524 g: parseInt(hex.substr(3, 2), 16), 525 b: parseInt(hex.substr(5, 2), 16), 526 }; 527}); 528 529// Get a random color. 530function randomColorSimple() { 531 return COLOR_CODES[Math.random() * COLOR_CODES.length | 0]; 532} 533 534// Get a random color, with some customization options available. 535let lastColor; 536function randomColor(options) { 537 const notSame = options && options.notSame; 538 const notColor = options && options.notColor; 539 const limitWhite = options && options.limitWhite; 540 let color = randomColorSimple(); 541 542 // limit the amount of white chosen randomly 543 if (limitWhite && color === COLOR.White && Math.random() < 0.6) { 544 color = randomColorSimple(); 545 } 546 547 if (notSame) { 548 while (color === lastColor) { 549 color = randomColorSimple(); 550 } 551 } 552 else if (notColor) { 553 while (color === notColor) { 554 color = randomColorSimple(); 555 } 556 } 557 558 lastColor = color; 559 return color; 560} 561 562function whiteOrGold() { 563 return Math.random() < 0.5 ? COLOR.Gold : COLOR.White; 564} 565 566 567// Shell helpers 568function makePistilColor(shellColor) { 569 return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({ notColor: shellColor }) : whiteOrGold(); 570} 571 572// Unique shell types 573const crysanthemumShell = (size=1) => { 574 const glitter = Math.random() < 0.25; 575 const singleColor = Math.random() < 0.72; 576 const color = singleColor ? randomColor({ limitWhite: true }) : [randomColor(), randomColor({ notSame: true })]; 577 const pistil = singleColor && Math.random() < 0.42; 578 const pistilColor = pistil && makePistilColor(color); 579 const secondColor = singleColor && (Math.random() < 0.2 || color === COLOR.White) ? pistilColor || randomColor({ notColor: color, limitWhite: true }) : null; 580 const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42; 581 let starDensity = glitter ? 1.1 : 1.25; 582 if (isLowQuality) starDensity *= 0.8; 583 if (isHighQuality) starDensity = 1.2; 584 return { 585 shellSize: size, 586 spreadSize: 300 + size * 100, 587 starLife: 900 + size * 200, 588 starDensity, 589 color, 590 secondColor, 591 glitter: glitter ? 'light' : '', 592 glitterColor: whiteOrGold(), 593 pistil, 594 pistilColor, 595 streamers 596 }; 597}; 598 599 600const ghostShell = (size=1) => { 601 // Extend crysanthemum shell 602 const shell = crysanthemumShell(size); 603 // Ghost effect can be fast, so extend star life 604 shell.starLife *= 1.5; 605 // Ensure we always have a single color other than white 606 let ghostColor = randomColor({ notColor: COLOR.White }); 607 // Always use streamers, and sometimes a pistil 608 shell.streamers = true; 609 const pistil = Math.random() < 0.42; 610 const pistilColor = pistil && makePistilColor(ghostColor); 611 // Ghost effect - transition from invisible to chosen color 612 shell.color = INVISIBLE; 613 shell.secondColor = ghostColor; 614 // We don't want glitter to be spewed by invisible stars, and we don't currently 615 // have a way to transition glitter state. So we'll disable it. 616 shell.glitter = ''; 617 618 return shell; 619}; 620 621 622const strobeShell = (size=1) => { 623 const color = randomColor({ limitWhite: true }); 624 return { 625 shellSize: size, 626 spreadSize: 280 + size * 92, 627 starLife: 1100 + size * 200, 628 starLifeVariation: 0.40, 629 starDensity: 1.1, 630 color, 631 glitter: 'light', 632 glitterColor: COLOR.White, 633 strobe: true, 634 strobeColor: Math.random() < 0.5 ? COLOR.White : null, 635 pistil: Math.random() < 0.5, 636 pistilColor: makePistilColor(color) 637 }; 638}; 639 640 641const palmShell = (size=1) => { 642 const color = randomColor(); 643 const thick = Math.random() < 0.5; 644 return { 645 shellSize: size, 646 color, 647 spreadSize: 250 + size * 75, 648 starDensity: thick ? 0.15 : 0.4, 649 starLife: 1800 + size * 200, 650 glitter: thick ? 'thick' : 'heavy' 651 }; 652}; 653 654const ringShell = (size=1) => { 655 const color = randomColor(); 656 const pistil = Math.random() < 0.75; 657 return { 658 shellSize: size, 659 ring: true, 660 color, 661 spreadSize: 300 + size * 100, 662 starLife: 900 + size * 200, 663 starCount: 2.2 * PI_2 * (size+1), 664 pistil, 665 pistilColor: makePistilColor(color), 666 glitter: !pistil ? 'light' : '', 667 glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White, 668 streamers: Math.random() < 0.3 669 }; 670 // return Object.assign({}, defaultShell, config); 671}; 672 673const crossetteShell = (size=1) => { 674 const color = randomColor({ limitWhite: true }); 675 return { 676 shellSize: size, 677 spreadSize: 300 + size * 100, 678 starLife: 750 + size * 160, 679 starLifeVariation: 0.4, 680 starDensity: 0.85, 681 color, 682 crossette: true, 683 pistil: Math.random() < 0.5, 684 pistilColor: makePistilColor(color) 685 }; 686}; 687 688const floralShell = (size=1) => ({ 689 shellSize: size, 690 spreadSize: 300 + size * 120, 691 starDensity: 0.12, 692 starLife: 500 + size * 50, 693 starLifeVariation: 0.5, 694 color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(), randomColor({ notSame: true })]), 695 floral: true 696}); 697 698const fallingLeavesShell = (size=1) => ({ 699 shellSize: size, 700 color: INVISIBLE, 701 spreadSize: 300 + size * 120, 702 starDensity: 0.12, 703 starLife: 500 + size * 50, 704 starLifeVariation: 0.5, 705 glitter: 'medium', 706 glitterColor: COLOR.Gold, 707 fallingLeaves: true 708}); 709 710const willowShell = (size=1) => ({ 711 shellSize: size, 712 spreadSize: 300 + size * 100, 713 starDensity: 0.6, 714 starLife: 3000 + size * 300, 715 glitter: 'willow', 716 glitterColor: COLOR.Gold, 717 color: INVISIBLE 718}); 719 720const crackleShell = (size=1) => { 721 // favor gold 722 const color = Math.random() < 0.75 ? COLOR.Gold : randomColor(); 723 return { 724 shellSize: size, 725 spreadSize: 380 + size * 75, 726 starDensity: isLowQuality ? 0.65 : 1, 727 starLife: 600 + size * 100, 728 starLifeVariation: 0.32, 729 glitter: 'light', 730 glitterColor: COLOR.Gold, 731 color, 732 crackle: true, 733 pistil: Math.random() < 0.65, 734 pistilColor: makePistilColor(color) 735 }; 736}; 737 738const horsetailShell = (size=1) => { 739 const color = randomColor(); 740 return { 741 shellSize: size, 742 horsetail: true, 743 color, 744 spreadSize: 250 + size * 38, 745 starDensity: 0.9, 746 starLife: 2500 + size * 300, 747 glitter: 'medium', 748 glitterColor: Math.random() < 0.5 ? whiteOrGold() : color, 749 // Add strobe effect to white horsetails, to make them more interesting 750 strobe: color === COLOR.White 751 }; 752}; 753 754function randomShellName() { 755 return Math.random() < 0.5 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0 ]; 756} 757 758function randomShell(size) { 759 // Special selection for codepen header. 760 if (IS_HEADER) return randomFastShell()(size); 761 // Normal operation 762 return shellTypes[randomShellName()](size); 763} 764 765function shellFromConfig(size) { 766 return shellTypes[shellNameSelector()](size); 767} 768 769// Get a random shell, not including processing intensive varients 770// Note this is only random when "Random" shell is selected in config. 771// Also, this does not create the shell, only returns the factory function. 772const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow']; 773function randomFastShell() { 774 const isRandom = shellNameSelector() === 'Random'; 775 let shellName = isRandom ? randomShellName() : shellNameSelector(); 776 if (isRandom) { 777 while (fastShellBlacklist.includes(shellName)) { 778 shellName = randomShellName(); 779 } 780 } 781 return shellTypes[shellName]; 782} 783 784 785const shellTypes = { 786 'Random': randomShell, 787 'Crackle': crackleShell, 788 'Crossette': crossetteShell, 789 'Crysanthemum': crysanthemumShell, 790 'Falling Leaves': fallingLeavesShell, 791 'Floral': floralShell, 792 'Ghost': ghostShell, 793 'Horse Tail': horsetailShell, 794 'Palm': palmShell, 795 'Ring': ringShell, 796 'Strobe': strobeShell, 797 'Willow': willowShell 798}; 799 800const shellNames = Object.keys(shellTypes); 801 802function init() { 803 // Remove loading state 804 document.querySelector('.loading-init').remove(); 805 appNodes.stageContainer.classList.remove('remove'); 806 807 // Populate dropdowns 808 function setOptionsForSelect(node, options) { 809 node.innerHTML = options.reduce((acc, opt) => acc += `<option value="${opt.value}">${opt.label}</option>`, ''); 810 } 811 812 // shell type 813 let options = ''; 814 shellNames.forEach(opt => options += `<option value="${opt}">${opt}</option>`); 815 appNodes.shellType.innerHTML = options; 816 // shell size 817 options = ''; 818 ['3"', '4"', '6"', '8"', '12"', '16"'].forEach((opt, i) => options += `<option value="${i}">${opt}</option>`); 819 appNodes.shellSize.innerHTML = options; 820 821 setOptionsForSelect(appNodes.quality, [ 822 { label: 'Low', value: QUALITY_LOW }, 823 { label: 'Normal', value: QUALITY_NORMAL }, 824 { label: 'High', value: QUALITY_HIGH } 825 ]); 826 827 setOptionsForSelect(appNodes.skyLighting, [ 828 { label: 'None', value: SKY_LIGHT_NONE }, 829 { label: 'Dim', value: SKY_LIGHT_DIM }, 830 { label: 'Normal', value: SKY_LIGHT_NORMAL } 831 ]); 832 833 // 0.9 is mobile default 834 setOptionsForSelect( 835 appNodes.scaleFactor, 836 [0.5, 0.62, 0.75, 0.9, 1.0, 1.5, 2.0] 837 .map(value => ({ value: value.toFixed(2), label: `${value*100}%` })) 838 ); 839 840 // Begin simulation 841 togglePause(false); 842 843 // initial render 844 renderApp(store.state); 845 846 // Apply initial config 847 configDidUpdate(); 848} 849 850 851function fitShellPositionInBoundsH(position) { 852 const edge = 0.18; 853 return (1 - edge*2) * position + edge; 854} 855 856function fitShellPositionInBoundsV(position) { 857 return position * 0.75; 858} 859 860function getRandomShellPositionH() { 861 return fitShellPositionInBoundsH(Math.random()); 862} 863 864function getRandomShellPositionV() { 865 return fitShellPositionInBoundsV(Math.random()); 866} 867 868function getRandomShellSize() { 869 const baseSize = shellSizeSelector(); 870 const maxVariance = Math.min(2.5, baseSize); 871 const variance = Math.random() * maxVariance; 872 const size = baseSize - variance; 873 const height = maxVariance === 0 ? Math.random() : 1 - (variance / maxVariance); 874 const centerOffset = Math.random() * (1 - height * 0.65) * 0.5; 875 const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset; 876 return { 877 size, 878 x: fitShellPositionInBoundsH(x), 879 height: fitShellPositionInBoundsV(height) 880 }; 881} 882 883 884// Launches a shell from a user pointer event, based on state.config 885function launchShellFromConfig(event) { 886 const shell = new Shell(shellFromConfig(shellSizeSelector())); 887 const w = mainStage.width; 888 const h = mainStage.height; 889 890 shell.launch( 891 event ? event.x / w : getRandomShellPositionH(), 892 event ? 1 - event.y / h : getRandomShellPositionV() 893 ); 894} 895 896 897// Sequences 898// ----------- 899 900function seqRandomShell() { 901 const size = getRandomShellSize(); 902 const shell = new Shell(shellFromConfig(size.size)); 903 shell.launch(size.x, size.height); 904 905 let extraDelay = shell.starLife; 906 if (shell.fallingLeaves) { 907 extraDelay = 4600; 908 } 909 910 return 900 + Math.random() * 600 + extraDelay; 911} 912 913function seqRandomFastShell() { 914 const shellType = randomFastShell(); 915 const size = getRandomShellSize(); 916 const shell = new Shell(shellType(size.size)); 917 shell.launch(size.x, size.height); 918 919 let extraDelay = shell.starLife; 920 921 return 900 + Math.random() * 600 + extraDelay; 922} 923 924function seqTwoRandom() { 925 const size1 = getRandomShellSize(); 926 const size2 = getRandomShellSize(); 927 const shell1 = new Shell(shellFromConfig(size1.size)); 928 const shell2 = new Shell(shellFromConfig(size2.size)); 929 const leftOffset = Math.random() * 0.2 - 0.1; 930 const rightOffset = Math.random() * 0.2 - 0.1; 931 shell1.launch(0.3 + leftOffset, size1.height); 932 setTimeout(() => { 933 shell2.launch(0.7 + rightOffset, size2.height); 934 }, 100); 935 936 let extraDelay = Math.max(shell1.starLife, shell2.starLife); 937 if (shell1.fallingLeaves || shell2.fallingLeaves) { 938 extraDelay = 4600; 939 } 940 941 return 900 + Math.random() * 600 + extraDelay; 942} 943 944function seqTriple() { 945 const shellType = randomFastShell(); 946 const baseSize = shellSizeSelector(); 947 const smallSize = Math.max(0, baseSize - 1.25); 948 949 const offset = Math.random() * 0.08 - 0.04; 950 const shell1 = new Shell(shellType(baseSize)); 951 shell1.launch(0.5 + offset, 0.7); 952 953 const leftDelay = 1000 + Math.random() * 400; 954 const rightDelay = 1000 + Math.random() * 400; 955 956 setTimeout(() => { 957 const offset = Math.random() * 0.08 - 0.04; 958 const shell2 = new Shell(shellType(smallSize)); 959 shell2.launch(0.2 + offset, 0.1); 960 }, leftDelay); 961 962 setTimeout(() => { 963 const offset = Math.random() * 0.08 - 0.04; 964 const shell3 = new Shell(shellType(smallSize)); 965 shell3.launch(0.8 + offset, 0.1); 966 }, rightDelay); 967 968 return 4000; 969} 970 971function seqPyramid() { 972 const barrageCountHalf = IS_DESKTOP ? 7 : 4; 973 const largeSize = shellSizeSelector(); 974 const smallSize = Math.max(0, largeSize - 3); 975 const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell; 976 const randomSpecialShell = randomShell; 977 978 function launchShell(x, useSpecial) { 979 const isRandom = shellNameSelector() === 'Random'; 980 let shellType = isRandom 981 ? useSpecial ? randomSpecialShell : randomMainShell 982 : shellTypes[shellNameSelector()]; 983 const shell = new Shell(shellType(useSpecial ? largeSize : smallSize)); 984 const height = x <= 0.5 ? x / 0.5 : (1 - x) / 0.5; 985 shell.launch(x, useSpecial ? 0.75 : height * 0.42); 986 } 987 988 let count = 0; 989 let delay = 0; 990 while(count <= barrageCountHalf) { 991 if (count === barrageCountHalf) { 992 setTimeout(() => { 993 launchShell(0.5, true); 994 }, delay); 995 } else { 996 const offset = count / barrageCountHalf * 0.5; 997 const delayOffset = Math.random() * 30 + 30; 998 setTimeout(() => { 999 launchShell(offset, false); 1000 }, delay); 1001 setTimeout(() => { 1002 launchShell(1 - offset, false); 1003 }, delay + delayOffset); 1004 } 1005 1006 count++; 1007 delay += 200; 1008 } 1009 1010 return 3400 + barrageCountHalf * 250; 1011} 1012 1013function seqSmallBarrage() { 1014 seqSmallBarrage.lastCalled = Date.now(); 1015 const barrageCount = IS_DESKTOP ? 11 : 5; 1016 const specialIndex = IS_DESKTOP ? 3 : 1; 1017 const shellSize = Math.max(0, shellSizeSelector() - 2); 1018 const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell; 1019 const randomSpecialShell = randomFastShell(); 1020 1021 // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights 1022 function launchShell(x, useSpecial) { 1023 const isRandom = shellNameSelector() === 'Random'; 1024 let shellType = isRandom 1025 ? useSpecial ? randomSpecialShell : randomMainShell 1026 : shellTypes[shellNameSelector()]; 1027 const shell = new Shell(shellType(shellSize)); 1028 const height = (Math.cos(x*5*Math.PI + PI_HALF) + 1) / 2; 1029 shell.launch(x, height * 0.75); 1030 } 1031 1032 let count = 0; 1033 let delay = 0; 1034 while(count < barrageCount) { 1035 if (count === 0) { 1036 launchShell(0.5, false) 1037 count += 1; 1038 } 1039 else { 1040 const offset = (count + 1) / barrageCount / 2; 1041 const delayOffset = Math.random() * 30 + 30; 1042 const useSpecial = count === specialIndex; 1043 setTimeout(() => { 1044 launchShell(0.5 + offset, useSpecial); 1045 }, delay); 1046 setTimeout(() => { 1047 launchShell(0.5 - offset, useSpecial); 1048 }, delay + delayOffset); 1049 count += 2; 1050 } 1051 delay += 200; 1052 } 1053 1054 return 3400 + barrageCount * 120; 1055} 1056seqSmallBarrage.cooldown = 15000; 1057seqSmallBarrage.lastCalled = Date.now(); 1058 1059 1060const sequences = [ 1061 seqRandomShell, 1062 seqTwoRandom, 1063 seqTriple, 1064 seqPyramid, 1065 seqSmallBarrage 1066]; 1067 1068 1069let isFirstSeq = true; 1070const finaleCount = 32; 1071let currentFinaleCount = 0; 1072function startSequence() { 1073 if (isFirstSeq) { 1074 isFirstSeq = false; 1075 if (IS_HEADER) { 1076 return seqTwoRandom(); 1077 } 1078 else { 1079 const shell = new Shell(crysanthemumShell(shellSizeSelector())); 1080 shell.launch(0.5, 0.5); 1081 return 2400; 1082 } 1083 } 1084 1085 if (finaleSelector()) { 1086 seqRandomFastShell(); 1087 if (currentFinaleCount < finaleCount) { 1088 currentFinaleCount++; 1089 return 170; 1090 } 1091 else { 1092 currentFinaleCount = 0; 1093 return 6000; 1094 } 1095 } 1096 1097 const rand = Math.random(); 1098 1099 if (rand < 0.08 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) { 1100 return seqSmallBarrage(); 1101 } 1102 1103 if (rand < 0.1) { 1104 return seqPyramid(); 1105 } 1106 1107 if (rand < 0.6 && !IS_HEADER) { 1108 return seqRandomShell(); 1109 } 1110 else if (rand < 0.8) { 1111 return seqTwoRandom(); 1112 } 1113 else if (rand < 1) { 1114 return seqTriple(); 1115 } 1116} 1117 1118 1119let activePointerCount = 0; 1120let isUpdatingSpeed = false; 1121 1122function handlePointerStart(event) { 1123 activePointerCount++; 1124 const btnSize = 50; 1125 1126 if (event.y < btnSize) { 1127 if (event.x < btnSize) { 1128 togglePause(); 1129 return; 1130 } 1131 if (event.x > mainStage.width/2 - btnSize/2 && event.x < mainStage.width/2 + btnSize/2) { 1132 toggleSound(); 1133 return; 1134 } 1135 if (event.x > mainStage.width - btnSize) { 1136 toggleMenu(); 1137 return; 1138 } 1139 } 1140 1141 if (!isRunning()) return; 1142 1143 if (updateSpeedFromEvent(event)) { 1144 isUpdatingSpeed = true; 1145 } 1146 else if (event.onCanvas) { 1147 launchShellFromConfig(event); 1148 } 1149} 1150 1151function handlePointerEnd(event) { 1152 activePointerCount--; 1153 isUpdatingSpeed = false; 1154} 1155 1156function handlePointerMove(event) { 1157 if (!isRunning()) return; 1158 1159 if (isUpdatingSpeed) { 1160 updateSpeedFromEvent(event); 1161 } 1162} 1163 1164function handleKeydown(event) { 1165 // P 1166 if (event.keyCode === 80) { 1167 togglePause(); 1168 } 1169 // O 1170 else if (event.keyCode === 79) { 1171 toggleMenu(); 1172 } 1173 // Esc 1174 else if (event.keyCode === 27) { 1175 toggleMenu(false); 1176 } 1177} 1178 1179mainStage.addEventListener('pointerstart', handlePointerStart); 1180mainStage.addEventListener('pointerend', handlePointerEnd); 1181mainStage.addEventListener('pointermove', handlePointerMove); 1182window.addEventListener('keydown', handleKeydown); 1183 1184 1185// Account for window resize and custom scale changes. 1186function handleResize() { 1187 const w = window.innerWidth; 1188 const h = window.innerHeight; 1189 // Try to adopt screen size, heeding maximum sizes specified 1190 const containerW = Math.min(w, MAX_WIDTH); 1191 // On small screens, use full device height 1192 const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT); 1193 appNodes.stageContainer.style.width = containerW + 'px'; 1194 appNodes.stageContainer.style.height = containerH + 'px'; 1195 stages.forEach(stage => stage.resize(containerW, containerH)); 1196 // Account for scale 1197 const scaleFactor = scaleFactorSelector(); 1198 stageW = containerW / scaleFactor; 1199 stageH = containerH / scaleFactor; 1200} 1201 1202// Compute initial dimensions 1203handleResize(); 1204 1205window.addEventListener('resize', handleResize); 1206 1207 1208// Dynamic globals 1209let currentFrame = 0; 1210let speedBarOpacity = 0; 1211let autoLaunchTime = 0; 1212 1213function updateSpeedFromEvent(event) { 1214 if (isUpdatingSpeed || event.y >= mainStage.height - 44) { 1215 // On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier. 1216 const edge = 16; 1217 const newSpeed = (event.x - edge) / (mainStage.width - edge * 2); 1218 simSpeed = Math.min(Math.max(newSpeed, 0), 1); 1219 // show speed bar after an update 1220 speedBarOpacity = 1; 1221 // If we updated the speed, return true 1222 return true; 1223 } 1224 // Return false if the speed wasn't updated 1225 return false; 1226} 1227 1228 1229// Extracted function to keep `update()` optimized 1230function updateGlobals(timeStep, lag) { 1231 currentFrame++; 1232 1233 // Always try to fade out speed bar 1234 if (!isUpdatingSpeed) { 1235 speedBarOpacity -= lag / 30; // half a second 1236 if (speedBarOpacity < 0) { 1237 speedBarOpacity = 0; 1238 } 1239 } 1240 1241 // auto launch shells 1242 if (store.state.config.autoLaunch) { 1243 autoLaunchTime -= timeStep; 1244 if (autoLaunchTime <= 0) { 1245 autoLaunchTime = startSequence() * 1.25; 1246 } 1247 } 1248} 1249 1250 1251function update(frameTime, lag) { 1252 if (!isRunning()) return; 1253 1254 const width = stageW; 1255 const height = stageH; 1256 const timeStep = frameTime * simSpeed; 1257 const speed = simSpeed * lag; 1258 1259 updateGlobals(timeStep, lag); 1260 1261 const starDrag = 1 - (1 - Star.airDrag) * speed; 1262 const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed; 1263 const sparkDrag = 1 - (1 - Spark.airDrag) * speed; 1264 const gAcc = timeStep / 1000 * GRAVITY; 1265 COLOR_CODES_W_INVIS.forEach(color => { 1266 // Stars 1267 const stars = Star.active[color]; 1268 for (let i=stars.length-1; i>=0; i=i-1) { 1269 const star = stars[i]; 1270 // Only update each star once per frame. Since color can change, it's possible a star could update twice without this, leading to a "jump". 1271 if (star.updateFrame === currentFrame) { 1272 continue; 1273 } 1274 star.updateFrame = currentFrame; 1275 1276 star.life -= timeStep; 1277 if (star.life <= 0) { 1278 stars.splice(i, 1); 1279 Star.returnInstance(star); 1280 } else { 1281 const burnRate = Math.pow(star.life / star.fullLife, 0.5); 1282 const burnRateInverse = 1 - burnRate; 1283 1284 star.prevX = star.x; 1285 star.prevY = star.y; 1286 star.x += star.speedX * speed; 1287 star.y += star.speedY * speed; 1288 // Apply air drag if star isn't "heavy". The heavy property is used for the shell comets. 1289 if (!star.heavy) { 1290 star.speedX *= starDrag; 1291 star.speedY *= starDrag; 1292 } 1293 else { 1294 star.speedX *= starDragHeavy; 1295 star.speedY *= starDragHeavy; 1296 } 1297 star.speedY += gAcc; 1298 1299 if (star.spinRadius) { 1300 star.spinAngle += star.spinSpeed * speed; 1301 star.x += Math.sin(star.spinAngle) * star.spinRadius * speed; 1302 star.y += Math.cos(star.spinAngle) * star.spinRadius * speed; 1303 } 1304 1305 if (star.sparkFreq) { 1306 star.sparkTimer -= timeStep; 1307 while (star.sparkTimer < 0) { 1308 star.sparkTimer += star.sparkFreq * 0.75 + star.sparkFreq * burnRateInverse * 4; 1309 Spark.add( 1310 star.x, 1311 star.y, 1312 star.sparkColor, 1313 Math.random() * PI_2, 1314 Math.random() * star.sparkSpeed * burnRate, 1315 star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation * star.sparkLife 1316 ); 1317 } 1318 } 1319 1320 // Handle star transitions 1321 if (star.life < star.transitionTime) { 1322 if (star.secondColor && !star.colorChanged) { 1323 star.colorChanged = true; 1324 star.color = star.secondColor; 1325 stars.splice(i, 1); 1326 Star.active[star.secondColor].push(star); 1327 if (star.secondColor === INVISIBLE) { 1328 star.sparkFreq = 0; 1329 } 1330 } 1331 1332 if (star.strobe) { 1333 // Strobes in the following pattern: on:off:off:on:off:off in increments of `strobeFreq` ms. 1334 star.visible = Math.floor(star.life / star.strobeFreq) % 3 === 0; 1335 } 1336 } 1337 } 1338 } 1339 1340 // Sparks 1341 const sparks = Spark.active[color]; 1342 for (let i=sparks.length-1; i>=0; i=i-1) { 1343 const spark = sparks[i]; 1344 spark.life -= timeStep; 1345 if (spark.life <= 0) { 1346 sparks.splice(i, 1); 1347 Spark.returnInstance(spark); 1348 } else { 1349 spark.prevX = spark.x; 1350 spark.prevY = spark.y; 1351 spark.x += spark.speedX * speed; 1352 spark.y += spark.speedY * speed; 1353 spark.speedX *= sparkDrag; 1354 spark.speedY *= sparkDrag; 1355 spark.speedY += gAcc; 1356 } 1357 } 1358 }); 1359 1360 render(speed); 1361} 1362 1363function render(speed) { 1364 const { dpr } = mainStage; 1365 const width = stageW; 1366 const height = stageH; 1367 const trailsCtx = trailsStage.ctx; 1368 const mainCtx = mainStage.ctx; 1369 1370 if (skyLightingSelector() !== SKY_LIGHT_NONE) { 1371 colorSky(speed); 1372 } 1373 1374 // Account for high DPI screens, and custom scale factor. 1375 const scaleFactor = scaleFactorSelector(); 1376 trailsCtx.scale(dpr * scaleFactor, dpr * scaleFactor); 1377 mainCtx.scale(dpr * scaleFactor, dpr * scaleFactor); 1378 1379 trailsCtx.globalCompositeOperation = 'source-over'; 1380 trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.config.longExposure ? 0.0025 : 0.175 * speed})`; 1381 trailsCtx.fillRect(0, 0, width, height); 1382 1383 mainCtx.clearRect(0, 0, width, height); 1384 1385 // Draw queued burst flashes 1386 // These must also be drawn using source-over due to Safari. Seems rendering the gradients using lighten draws large black boxes instead. 1387 // Thankfully, these burst flashes look pretty much the same either way. 1388 while (BurstFlash.active.length) { 1389 const bf = BurstFlash.active.pop(); 1390 1391 const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius); 1392 burstGradient.addColorStop(0.024, 'rgba(255, 255, 255, 1)'); 1393 burstGradient.addColorStop(0.125, 'rgba(255, 160, 20, 0.2)'); 1394 burstGradient.addColorStop(0.32, 'rgba(255, 140, 20, 0.11)'); 1395 burstGradient.addColorStop(1, 'rgba(255, 120, 20, 0)'); 1396 trailsCtx.fillStyle = burstGradient; 1397 trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2); 1398 1399 BurstFlash.returnInstance(bf); 1400 } 1401 1402 // Remaining drawing on trails canvas will use 'lighten' blend mode 1403 trailsCtx.globalCompositeOperation = 'lighten'; 1404 1405 // Draw stars 1406 trailsCtx.lineWidth = Star.drawWidth; 1407 trailsCtx.lineCap = isLowQuality ? 'square' : 'round'; 1408 mainCtx.strokeStyle = '#fff'; 1409  mainCtx.lineWidth = 1; 1410 mainCtx.beginPath(); 1411 COLOR_CODES.forEach(color => { 1412 const stars = Star.active[color]; 1413 trailsCtx.strokeStyle = color; 1414 trailsCtx.beginPath(); 1415 stars.forEach(star => { 1416 if (star.visible) { 1417 trailsCtx.moveTo(star.x, star.y); 1418 trailsCtx.lineTo(star.prevX, star.prevY); 1419 mainCtx.moveTo(star.x, star.y); 1420 mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6); 1421 } 1422 }); 1423 trailsCtx.stroke(); 1424 }); 1425 mainCtx.stroke(); 1426 1427 // Draw sparks 1428 trailsCtx.lineWidth = Spark.drawWidth; 1429 trailsCtx.lineCap = 'butt'; 1430 COLOR_CODES.forEach(color => { 1431 const sparks = Spark.active[color]; 1432 trailsCtx.strokeStyle = color; 1433 trailsCtx.beginPath(); 1434 sparks.forEach(spark => { 1435 trailsCtx.moveTo(spark.x, spark.y); 1436 trailsCtx.lineTo(spark.prevX, spark.prevY); 1437 }); 1438 trailsCtx.stroke(); 1439 }); 1440 1441 1442 // Render speed bar if visible 1443 if (speedBarOpacity) { 1444 const speedBarHeight = 6; 1445 mainCtx.globalAlpha = speedBarOpacity; 1446 mainCtx.fillStyle = COLOR.Blue; 1447 mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight); 1448 mainCtx.globalAlpha = 1; 1449 } 1450 1451 1452 trailsCtx.setTransform(1, 0, 0, 1, 0, 0); 1453 mainCtx.setTransform(1, 0, 0, 1, 0, 0); 1454} 1455 1456 1457// Draw colored overlay based on combined brightness of stars (light up the sky!) 1458// Note: this is applied to the canvas container's background-color, so it's behind the particles 1459const currentSkyColor = { r: 0, g: 0, b: 0 }; 1460const targetSkyColor = { r: 0, g: 0, b: 0 }; 1461function colorSky(speed) { 1462 // The maximum r, g, or b value that will be used (255 would represent no maximum) 1463 const maxSkySaturation = skyLightingSelector() * 15; 1464 // How many stars are required in total to reach maximum sky brightness 1465 const maxStarCount = 500; 1466 let totalStarCount = 0; 1467 // Initialize sky as black 1468 targetSkyColor.r = 0; 1469 targetSkyColor.g = 0; 1470 targetSkyColor.b = 0; 1471 // Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later. 1472 // Also add up total star count. 1473 COLOR_CODES.forEach(color => { 1474 const tuple = COLOR_TUPLES[color]; 1475 const count = Star.active[color].length; 1476 totalStarCount += count; 1477 targetSkyColor.r += tuple.r * count; 1478 targetSkyColor.g += tuple.g * count; 1479 targetSkyColor.b += tuple.b * count; 1480 }); 1481 1482 // Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception. 1483 const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3); 1484 // Figure out which color component has the highest value, so we can scale them without affecting the ratios. 1485 // Prevent 0 from being used, so we don't divide by zero in the next step. 1486 const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b); 1487 // Scale all color components to a max of `maxSkySaturation`, and apply intensity. 1488 targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity; 1489 targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity; 1490 targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity; 1491 1492 // Animate changes to color to smooth out transitions. 1493 const colorChange = 10; 1494 currentSkyColor.r += (targetSkyColor.r - currentSkyColor.r) / colorChange * speed; 1495 currentSkyColor.g += (targetSkyColor.g - currentSkyColor.g) / colorChange * speed; 1496 currentSkyColor.b += (targetSkyColor.b - currentSkyColor.b) / colorChange * speed; 1497 1498 appNodes.canvasContainer.style.backgroundColor = `rgb(${currentSkyColor.r | 0}, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`; 1499} 1500 1501mainStage.addEventListener('ticker', update); 1502 1503 1504// Helper used to semi-randomly spread particles over an arc 1505// Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition. 1506function createParticleArc(start, arcLength, count, randomness, particleFactory) { 1507 const angleDelta = arcLength / count; 1508 // Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped. 1509 // Would be nice to fix this a better way. 1510 const end = start + arcLength - (angleDelta * 0.5); 1511 1512 if (end > start) { 1513 // Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta 1514 // V8 deoptimises with let compound assignment 1515 for (let angle=start; angle<end; angle=angle+angleDelta) { 1516 particleFactory(angle + Math.random() * angleDelta * randomness); 1517 } 1518 } else { 1519 for (let angle=start; angle>end; angle=angle+angleDelta) { 1520 particleFactory(angle + Math.random() * angleDelta * randomness); 1521 } 1522 } 1523} 1524 1525 1526/** 1527 * Helper used to create a spherical burst of particles. 1528 * 1529 * @param {Number} count The desired number of stars/particles. This value is a suggestion, and the 1530 * created burst may have more particles. The current algorithm can't perfectly 1531 * distribute a specific number of points evenly on a sphere's surface. 1532 * @param {Function} particleFactory Called once per star/particle generated. Passed two arguments: 1533 * `angle`: The direction of the star/particle. 1534 * `speed`: A multipler for the particle speed, from 0.0 to 1.0. 1535 * @param {Number} startAngle=0 For segmented bursts, you can generate only a partial arc of particles. This 1536 * allows setting the starting arc angle (radians). 1537 * @param {Number} arcLength=TAU The length of the arc (radians). Defaults to a full circle. 1538 * 1539 * @return {void} Returns nothing; it's up to `particleFactory` to use the given data. 1540 */ 1541function createBurst(count, particleFactory, startAngle=0, arcLength=PI_2) { 1542 // Assuming sphere with surface area of `count`, calculate various 1543 // properties of said sphere (unit is stars). 1544 // Radius 1545 const R = 0.5 * Math.sqrt(count/Math.PI); 1546 // Circumference 1547 const C = 2 * R * Math.PI; 1548 // Half Circumference 1549 const C_HALF = C / 2; 1550 1551 // Make a series of rings, sizing them as if they were spaced evenly 1552 // along the curved surface of a sphere. 1553 for (let i=0; i<=C_HALF; i++) { 1554 const ringAngle = i / C_HALF * PI_HALF; 1555 const ringSize = Math.cos(ringAngle); 1556 const partsPerFullRing = C * ringSize; 1557 const partsPerArc = partsPerFullRing * (arcLength / PI_2); 1558 1559 const angleInc = PI_2 / partsPerFullRing; 1560 const angleOffset = Math.random() * angleInc + startAngle; 1561 // Each particle needs a bit of randomness to improve appearance. 1562 const maxRandomAngleOffset = angleInc * 0.33; 1563 1564 for (let i=0; i<partsPerArc; i++) { 1565 const randomAngleOffset = Math.random() * maxRandomAngleOffset; 1566 let angle = angleInc * i + angleOffset + randomAngleOffset; 1567 particleFactory(angle, ringSize); 1568 } 1569 } 1570} 1571 1572 1573 1574 1575// Various star effects. 1576// These are designed to be attached to a star's `onDeath` event. 1577 1578// Crossette breaks star into four same-color pieces which branch in a cross-like shape. 1579function crossetteEffect(star) { 1580 const startAngle = Math.random() * PI_HALF; 1581 createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => { 1582 Star.add( 1583 star.x, 1584 star.y, 1585 star.color, 1586 angle, 1587 Math.random() * 0.6 + 0.75, 1588 600 1589 ); 1590 }); 1591} 1592 1593// Flower is like a mini shell 1594function floralEffect(star) { 1595 const count = 12 + 6 * quality; 1596 createBurst(count, (angle, speedMult) => { 1597 Star.add( 1598 star.x, 1599 star.y, 1600 star.color, 1601 angle, 1602 speedMult * 2.4, 1603 1000 + Math.random() * 300, 1604 star.speedX, 1605 star.speedY 1606 ); 1607 }); 1608 // Queue burst flash render 1609 BurstFlash.add(star.x, star.y, 46); 1610 soundManager.playSound('burstSmall'); 1611} 1612 1613// Floral burst with willow stars 1614function fallingLeavesEffect(star) { 1615 createBurst(7, (angle, speedMult) => { 1616 const newStar = Star.add( 1617 star.x, 1618 star.y, 1619 INVISIBLE, 1620 angle, 1621 speedMult * 2.4, 1622 2400 + Math.random() * 600, 1623 star.speedX, 1624 star.speedY 1625 ); 1626 1627 newStar.sparkColor = COLOR.Gold; 1628 newStar.sparkFreq = 144 / quality; 1629 newStar.sparkSpeed = 0.28; 1630 newStar.sparkLife = 750; 1631 newStar.sparkLifeVariation = 3.2; 1632 }); 1633 // Queue burst flash render 1634 BurstFlash.add(star.x, star.y, 46); 1635 soundManager.playSound('burstSmall'); 1636} 1637 1638// Crackle pops into a small cloud of golden sparks. 1639function crackleEffect(star) { 1640 const count = isHighQuality ? 32 : 16; 1641 createParticleArc(0, PI_2, count, 1.8, (angle) => { 1642 Spark.add( 1643 star.x, 1644 star.y, 1645 COLOR.Gold, 1646 angle, 1647 // apply near cubic falloff to speed (places more particles towards outside) 1648 Math.pow(Math.random(), 0.45) * 2.4, 1649 300 + Math.random() * 200 1650 ); 1651 }); 1652} 1653 1654 1655 1656/** 1657 * Shell can be constructed with options: 1658 * 1659 * spreadSize: Size of the burst. 1660 * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted. 1661 * starLife: 1662 * starLifeVariation: 1663 * color: 1664 * glitterColor: 1665 * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow' 1666 * pistil: 1667 * pistilColor: 1668 * streamers: 1669 * crossette: 1670 * floral: 1671 * crackle: 1672 */ 1673class Shell { 1674 constructor(options) { 1675 Object.assign(this, options); 1676 this.starLifeVariation = options.starLifeVariation || 0.125; 1677 this.color = options.color || randomColor(); 1678 this.glitterColor = options.glitterColor || this.color; 1679 1680 // Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area. 1681 if (!this.starCount) { 1682 const density = options.starDensity || 1; 1683 const scaledSize = this.spreadSize / 54; 1684 this.starCount = Math.max(6, scaledSize * scaledSize * density); 1685 } 1686 } 1687 1688 launch(position, launchHeight) { 1689 const width = stageW; 1690 const height = stageH; 1691 // Distance from sides of screen to keep shells. 1692 const hpad = 60; 1693 // Distance from top of screen to keep shell bursts. 1694 const vpad = 50; 1695 // Minimum burst height, as a percentage of stage height 1696 const minHeightPercent = 0.45; 1697 // Minimum burst height in px 1698 const minHeight = height - height * minHeightPercent; 1699 1700 const launchX = position * (width - hpad * 2) + hpad; 1701 const launchY = height; 1702 const burstY = minHeight - (launchHeight * (minHeight - vpad)); 1703 1704 const launchDistance = launchY - burstY; 1705 // Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag. 1706 // Magic numbers came from testing. 1707 const launchVelocity = Math.pow(launchDistance * 0.04, 0.64); 1708 1709 const comet = this.comet = Star.add( 1710 launchX, 1711 launchY, 1712 typeof this.color === 'string' && this.color !== 'random' ? this.color : COLOR.White, 1713 Math.PI, 1714 launchVelocity * (this.horsetail ? 1.2 : 1), 1715 // Hang time is derived linearly from Vi; exact number came from testing 1716 launchVelocity * (this.horsetail ? 100 : 400) 1717 ); 1718 1719 // making comet "heavy" limits air drag 1720 comet.heavy = true; 1721 // comet spark trail 1722 comet.spinRadius = MyMath.random(0.32, 0.85); 1723 comet.sparkFreq = 32 / quality; 1724 if (isHighQuality) comet.sparkFreq = 8; 1725 comet.sparkLife = 320; 1726 comet.sparkLifeVariation = 3; 1727 if (this.glitter === 'willow' || this.fallingLeaves) { 1728 comet.sparkFreq = 20 / quality; 1729 comet.sparkSpeed = 0.5; 1730 comet.sparkLife = 500; 1731 } 1732 if (this.color === INVISIBLE) { 1733 comet.sparkColor = COLOR.Gold; 1734 } 1735 1736 // Randomly make comet "burn out" a bit early. 1737 // This is disabled for horsetail shells, due to their very short airtime. 1738 if (Math.random() > 0.4 && !this.horsetail) { 1739 comet.secondColor = INVISIBLE; 1740 comet.transitionTime = Math.pow(Math.random(), 1.5) * 700 + 500; 1741 } 1742 1743 comet.onDeath = comet => this.burst(comet.x, comet.y); 1744 1745 soundManager.playSound('lift'); 1746 } 1747 1748 burst(x, y) { 1749 // Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag. 1750 const speed = this.spreadSize / 96; 1751 1752 let color, onDeath, sparkFreq, sparkSpeed, sparkLife; 1753 let sparkLifeVariation = 0.25; 1754 // Some death effects, like crackle, play a sound, but should only be played once. 1755 let playedDeathSound = false; 1756 1757 if (this.crossette) onDeath = (star) => { 1758 if (!playedDeathSound) { 1759 soundManager.playSound('crackleSmall'); 1760 playedDeathSound = true; 1761 } 1762 crossetteEffect(star); 1763 } 1764 if (this.crackle) onDeath = (star) => { 1765 if (!playedDeathSound) { 1766 soundManager.playSound('crackle'); 1767 playedDeathSound = true; 1768 } 1769 crackleEffect(star); 1770 } 1771 if (this.floral) onDeath = floralEffect; 1772 if (this.fallingLeaves) onDeath = fallingLeavesEffect; 1773 1774 if (this.glitter === 'light') { 1775 sparkFreq = 400; 1776 sparkSpeed = 0.3; 1777 sparkLife = 300; 1778 sparkLifeVariation = 2; 1779 } 1780 else if (this.glitter === 'medium') { 1781 sparkFreq = 200; 1782 sparkSpeed = 0.44; 1783 sparkLife = 700; 1784 sparkLifeVariation = 2; 1785 } 1786 else if (this.glitter === 'heavy') { 1787 sparkFreq = 80; 1788 sparkSpeed = 0.8; 1789 sparkLife = 1400; 1790 sparkLifeVariation = 2; 1791 } 1792 else if (this.glitter === 'thick') { 1793 sparkFreq = 16; 1794 sparkSpeed = isHighQuality ? 1.65 : 1.5; 1795 sparkLife = 1400; 1796 sparkLifeVariation = 3; 1797 } 1798 else if (this.glitter === 'streamer') { 1799 sparkFreq = 32; 1800 sparkSpeed = 1.05; 1801 sparkLife = 620; 1802 sparkLifeVariation = 2; 1803 } 1804 else if (this.glitter === 'willow') { 1805 sparkFreq = 120; 1806 sparkSpeed = 0.34; 1807 sparkLife = 1400; 1808 sparkLifeVariation = 3.8; 1809 } 1810 1811 // Apply quality to spark count 1812 sparkFreq = sparkFreq / quality; 1813 1814 // Star factory for primary burst, pistils, and streamers. 1815 let firstStar = true; 1816 const starFactory = (angle, speedMult) => { 1817 // For non-horsetail shells, compute an initial vertical speed to add to star burst. 1818 // The magic number comes from testing what looks best. The ideal is that all shell 1819 // bursts appear visually centered for the majority of the star life (excl. willows etc.) 1820 const standardInitialSpeed = this.spreadSize / 1800; 1821 1822 const star = Star.add( 1823 x, 1824 y, 1825 color || randomColor(), 1826 angle, 1827 speedMult * speed, 1828 // add minor variation to star life 1829 this.starLife + Math.random() * this.starLife * this.starLifeVariation, 1830 this.horsetail ? this.comet && this.comet.speedX : 0, 1831 this.horsetail ? this.comet && this.comet.speedY : -standardInitialSpeed 1832 ); 1833 1834 if (this.secondColor) { 1835 star.transitionTime = this.starLife * (Math.random() * 0.05 + 0.32); 1836 star.secondColor = this.secondColor; 1837 } 1838 1839 if (this.strobe) { 1840 star.transitionTime = this.starLife * (Math.random() * 0.08 + 0.46); 1841 star.strobe = true; 1842 // How many milliseconds between switch of strobe state "tick". Note that the strobe pattern 1843 // is on:off:off, so this is the "on" duration, while the "off" duration is twice as long. 1844 star.strobeFreq = Math.random() * 20 + 40; 1845 if (this.strobeColor) { 1846 star.secondColor = this.strobeColor; 1847 } 1848 } 1849 1850 star.onDeath = onDeath; 1851 1852 if (this.glitter) { 1853 star.sparkFreq = sparkFreq; 1854 star.sparkSpeed = sparkSpeed; 1855 star.sparkLife = sparkLife; 1856 star.sparkLifeVariation = sparkLifeVariation; 1857 star.sparkColor = this.glitterColor; 1858 star.sparkTimer = Math.random() * star.sparkFreq; 1859 } 1860 }; 1861 1862 1863 if (typeof this.color === 'string') { 1864 if (this.color === 'random') { 1865 color = null; // falsey value creates random color in starFactory 1866 } else { 1867 color = this.color; 1868 } 1869 1870 // Rings have positional randomness, but are rotated randomly 1871 if (this.ring) { 1872 const ringStartAngle = Math.random() * Math.PI; 1873 const ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15;; 1874 1875 createParticleArc(0, PI_2, this.starCount, 0, angle => { 1876 // Create a ring, squashed horizontally 1877 const initSpeedX = Math.sin(angle) * speed * ringSquash; 1878 const initSpeedY = Math.cos(angle) * speed; 1879 // Rotate ring 1880 const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY); 1881 const newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle; 1882 const star = Star.add( 1883 x, 1884 y, 1885 color, 1886 newAngle, 1887 // apply near cubic falloff to speed (places more particles towards outside) 1888 newSpeed,//speed, 1889 // add minor variation to star life 1890 this.starLife + Math.random() * this.starLife * this.starLifeVariation 1891 ); 1892 1893 if (this.glitter) { 1894 star.sparkFreq = sparkFreq; 1895 star.sparkSpeed = sparkSpeed; 1896 star.sparkLife = sparkLife; 1897 star.sparkLifeVariation = sparkLifeVariation; 1898 star.sparkColor = this.glitterColor; 1899 star.sparkTimer = Math.random() * star.sparkFreq; 1900 } 1901 }); 1902 } 1903 // Normal burst 1904 else { 1905 createBurst(this.starCount, starFactory); 1906 } 1907 } 1908 else if (Array.isArray(this.color)) { 1909 if (Math.random() < 0.5) { 1910 const start = Math.random() * Math.PI; 1911 const start2 = start + Math.PI; 1912 const arc = Math.PI; 1913 color = this.color[0]; 1914 // Not creating a full arc automatically reduces star count. 1915 createBurst(this.starCount, starFactory, start, arc); 1916 color = this.color[1]; 1917 createBurst(this.starCount, starFactory, start2, arc); 1918 } else { 1919 color = this.color[0]; 1920 createBurst(this.starCount / 2, starFactory); 1921 color = this.color[1]; 1922 createBurst(this.starCount / 2, starFactory); 1923 } 1924 } 1925 else { 1926 throw new Error('Invalid shell color. Expected string or array of strings, but got: ' + this.color); 1927 } 1928 1929 if (this.pistil) { 1930 const innerShell = new Shell({ 1931 spreadSize: this.spreadSize * 0.5, 1932 starLife: this.starLife * 0.6, 1933 starLifeVariation: this.starLifeVariation, 1934 starDensity: 1.4, 1935 color: this.pistilColor, 1936 glitter: 'light', 1937 glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White 1938 }); 1939 innerShell.burst(x, y); 1940 } 1941 1942 if (this.streamers) { 1943 const innerShell = new Shell({ 1944 spreadSize: this.spreadSize * 0.9, 1945 starLife: this.starLife * 0.8, 1946 starLifeVariation: this.starLifeVariation, 1947 starCount: Math.floor(Math.max(6, this.spreadSize / 45)), 1948 color: COLOR.White, 1949 glitter: 'streamer' 1950 }); 1951 innerShell.burst(x, y); 1952 } 1953 1954 // Queue burst flash render 1955 BurstFlash.add(x, y, this.spreadSize / 4); 1956 1957 // Play sound, but only for "original" shell, the one that was launched. 1958 // We don't want multiple sounds from pistil or streamer "sub-shells". 1959 // This can be detected by the presence of a comet. 1960 if (this.comet) { 1961 // Scale explosion sound based on current shell size and selected (max) shell size. 1962 // Shooting selected shell size will always sound the same no matter the selected size, 1963 // but when smaller shells are auto-fired, they will sound smaller. It doesn't sound great 1964 // when a value too small is given though, so instead of basing it on proportions, we just 1965 // look at the difference in size and map it to a range known to sound good. 1966 const maxDiff = 2; 1967 const sizeDifferenceFromMaxSize = Math.min(maxDiff, shellSizeSelector() - this.shellSize); 1968 const soundScale = (1 - sizeDifferenceFromMaxSize / maxDiff) * 0.3 + 0.7; 1969 soundManager.playSound('burst', soundScale); 1970 } 1971 } 1972} 1973 1974 1975 1976const BurstFlash = { 1977 active: [], 1978 _pool: [], 1979 1980 _new() { 1981 return {} 1982 }, 1983 1984 add(x, y, radius) { 1985 const instance = this._pool.pop() || this._new(); 1986 1987 instance.x = x; 1988 instance.y = y; 1989 instance.radius = radius; 1990 1991 this.active.push(instance); 1992 return instance; 1993 }, 1994 1995 returnInstance(instance) { 1996 this._pool.push(instance); 1997 } 1998}; 1999 2000 2001 2002// Helper to generate objects for storing active particles. 2003// Particles are stored in arrays keyed by color (code, not name) for improved rendering performance. 2004function createParticleCollection() { 2005 const collection = {}; 2006 COLOR_CODES_W_INVIS.forEach(color => { 2007 collection[color] = []; 2008 }); 2009 return collection; 2010} 2011 2012 2013// Star properties (WIP) 2014// ----------------------- 2015// transitionTime - how close to end of life that star transition happens 2016 2017const Star = { 2018 // Visual properties 2019 drawWidth: 3, 2020 airDrag: 0.98, 2021 airDragHeavy: 0.992, 2022 2023 // Star particles will be keyed by color 2024 active: createParticleCollection(), 2025 _pool: [], 2026 2027 _new() { 2028 return {}; 2029 }, 2030 2031 add(x, y, color, angle, speed, life, speedOffX, speedOffY) { 2032 const instance = this._pool.pop() || this._new(); 2033 2034 instance.visible = true; 2035 instance.heavy = false; 2036 instance.x = x; 2037 instance.y = y; 2038 instance.prevX = x; 2039 instance.prevY = y; 2040 instance.color = color; 2041 instance.speedX = Math.sin(angle) * speed + (speedOffX || 0); 2042 instance.speedY = Math.cos(angle) * speed + (speedOffY || 0); 2043 instance.life = life; 2044 instance.fullLife = life; 2045 instance.spinAngle = Math.random() * PI_2; 2046 instance.spinSpeed = 0.8; 2047 instance.spinRadius = 0; 2048 instance.sparkFreq = 0; // ms between spark emissions 2049 instance.sparkSpeed = 1; 2050 instance.sparkTimer = 0; 2051 instance.sparkColor = color; 2052 instance.sparkLife = 750; 2053 instance.sparkLifeVariation = 0.25; 2054 instance.strobe = false; 2055 2056 this.active[color].push(instance); 2057 return instance; 2058 }, 2059 2060 // Public method for cleaning up and returning an instance back to the pool. 2061 returnInstance(instance) { 2062 // Call onDeath handler if available (and pass it current star instance) 2063 instance.onDeath && instance.onDeath(instance); 2064 // Clean up 2065 instance.onDeath = null; 2066 instance.secondColor = null; 2067 instance.transitionTime = 0; 2068 instance.colorChanged = false; 2069 // Add back to the pool. 2070 this._pool.push(instance); 2071 } 2072}; 2073 2074 2075const Spark = { 2076 // Visual properties 2077 drawWidth: 0, // set in `configDidUpdate()` 2078 airDrag: 0.9, 2079 2080 // Star particles will be keyed by color 2081 active: createParticleCollection(), 2082 _pool: [], 2083 2084 _new() { 2085 return {}; 2086 }, 2087 2088 add(x, y, color, angle, speed, life) { 2089 const instance = this._pool.pop() || this._new(); 2090 2091 instance.x = x; 2092 instance.y = y; 2093 instance.prevX = x; 2094 instance.prevY = y; 2095 instance.color = color; 2096 instance.speedX = Math.sin(angle) * speed; 2097 instance.speedY = Math.cos(angle) * speed; 2098 instance.life = life; 2099 2100 this.active[color].push(instance); 2101 return instance; 2102 }, 2103 2104 // Public method for cleaning up and returning an instance back to the pool. 2105 returnInstance(instance) { 2106 // Add back to the pool. 2107 this._pool.push(instance); 2108 } 2109}; 2110 2111 2112 2113const soundManager = { 2114 baseURL: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/', 2115 ctx: new (window.AudioContext || window.webkitAudioContext), 2116 sources: { 2117 lift: { 2118 volume: 1, 2119 playbackRateMin: 0.85, 2120 playbackRateMax: 0.95, 2121 fileNames: [ 2122 'lift1.mp3', 2123 'lift2.mp3', 2124 'lift3.mp3' 2125 ] 2126 }, 2127 burst: { 2128 volume: 1, 2129 playbackRateMin: 0.8, 2130 playbackRateMax: 0.9, 2131 fileNames: [ 2132 'burst1.mp3', 2133 'burst2.mp3' 2134 ] 2135 }, 2136 burstSmall: { 2137 volume: 0.25, 2138 playbackRateMin: 0.8, 2139 playbackRateMax: 1, 2140 fileNames: [ 2141 'burst-sm-1.mp3', 2142 'burst-sm-2.mp3' 2143 ] 2144 }, 2145 crackle: { 2146 volume: 0.2, 2147 playbackRateMin: 1, 2148 playbackRateMax: 1, 2149 fileNames: ['crackle1.mp3'] 2150 }, 2151 crackleSmall: { 2152 volume: 0.3, 2153 playbackRateMin: 1, 2154 playbackRateMax: 1, 2155 fileNames: ['crackle-sm-1.mp3'] 2156 } 2157 }, 2158 2159 preload() { 2160 const allFilePromises = []; 2161 2162 function checkStatus(response) { 2163 if (response.status >= 200 && response.status < 300) { 2164 return response; 2165 } 2166 const customError = new Error(response.statusText); 2167 customError.response = response; 2168 throw customError; 2169 } 2170 2171 const types = Object.keys(this.sources); 2172 types.forEach(type => { 2173 const source = this.sources[type]; 2174 const { fileNames } = source; 2175 const filePromises = []; 2176 fileNames.forEach(fileName => { 2177 const fileURL = this.baseURL + fileName; 2178 // Promise will resolve with decoded audio buffer. 2179 const promise = fetch(fileURL) 2180 .then(checkStatus) 2181 .then(response => response.arrayBuffer()) 2182 .then(data => new Promise(resolve => { 2183 this.ctx.decodeAudioData(data, resolve); 2184 })); 2185 2186 filePromises.push(promise); 2187 allFilePromises.push(promise); 2188 }); 2189 2190 Promise.all(filePromises) 2191 .then(buffers => { 2192 source.buffers = buffers; 2193 }); 2194 }); 2195 2196 return Promise.all(allFilePromises); 2197 }, 2198 2199 pauseAll() { 2200 this.ctx.suspend(); 2201 }, 2202 2203 resumeAll() { 2204 // Play a sound with no volume for iOS. This 'unlocks' the audio context when the user first enables sound. 2205 this.playSound('lift', 0); 2206 // Chrome mobile requires interaction before starting audio context. 2207 // The sound toggle button is triggered on 'touchstart', which doesn't seem to count as a full 2208 // interaction to Chrome. I guess it needs a click? At any rate if the first thing the user does 2209 // is enable audio, it doesn't work. Using a setTimeout allows the first interaction to be registered. 2210 // Perhaps a better solution is to track whether the user has interacted, and if not but they try enabling 2211 // sound, show a tooltip that they should tap again to enable sound. 2212 setTimeout(() => { 2213 this.ctx.resume(); 2214 }, 250); 2215 }, 2216 2217 // Private property used to throttle small burst sounds. 2218 _lastSmallBurstTime: 0, 2219 2220 /** 2221 * Play a sound of `type`. Will randomly pick a file associated with type, and play it at the specified volume 2222 * and play speed, with a bit of random variance in play speed. This is all based on `sources` config. 2223 * 2224 * @param {string} type - The type of sound to play. 2225 * @param {?number} scale=1 - Value between 0 and 1 (values outside range will be clamped). Scales less than one 2226 * descrease volume and increase playback speed. This is because large explosions are 2227 * louder, deeper, and reverberate longer than small explosions. 2228 * Note that a scale of 0 will mute the sound. 2229 */ 2230 playSound(type, scale=1) { 2231 // Ensure `scale` is within valid range. 2232 scale = MyMath.clamp(scale, 0, 1); 2233 2234 // Disallow starting new sounds if sound is disabled, app is running in slow motion, or paused. 2235 // Slow motion check has some wiggle room in case user doesn't finish dragging the speed bar 2236 // *all* the way back. 2237 if (!canPlaySoundSelector() || simSpeed < 0.95) { 2238 return; 2239 } 2240 2241 // Throttle small bursts, since floral/falling leaves shells have a lot of them. 2242 if (type === 'burstSmall') { 2243 const now = Date.now(); 2244 if (now - this._lastSmallBurstTime < 20) { 2245 return; 2246 } 2247 this._lastSmallBurstTime = now; 2248 } 2249 2250 const source = this.sources[type]; 2251 2252 if (!source) { 2253 throw new Error(`Sound of type "${type}" doesn't exist.`); 2254 } 2255 2256 const initialVolume = source.volume; 2257 const initialPlaybackRate = MyMath.random( 2258 source.playbackRateMin, 2259 source.playbackRateMax 2260 ); 2261 2262 // Volume descreases with scale. 2263 const scaledVolume = initialVolume * scale; 2264 // Playback rate increases with scale. For this, we map the scale of 0-1 to a scale of 2-1. 2265 // So at a scale of 1, sound plays normally, but as scale approaches 0 speed approaches double. 2266 const scaledPlaybackRate = initialPlaybackRate * (2 - scale); 2267 2268 const gainNode = this.ctx.createGain(); 2269 gainNode.gain.value = scaledVolume; 2270 2271 const buffer = MyMath.randomChoice(source.buffers); 2272 const bufferSource = this.ctx.createBufferSource(); 2273 bufferSource.playbackRate.value = scaledPlaybackRate; 2274 bufferSource.buffer = buffer; 2275 bufferSource.connect(gainNode); 2276 gainNode.connect(this.ctx.destination); 2277 bufferSource.start(0); 2278 } 2279}; 2280 2281 2282 2283 2284// Kick things off. 2285 2286function setLoadingStatus(status) { 2287 document.querySelector('.loading-init__status').textContent = status; 2288} 2289 2290// CodePen profile header doesn't need audio, just initialize. 2291if (IS_HEADER) { 2292 init(); 2293} else { 2294 // Allow status to render, then preload assets and start app. 2295 setLoadingStatus('Lighting Fuses'); 2296 setTimeout(() => { 2297 soundManager.preload() 2298 .then( 2299 init, 2300 reason => { 2301 // Codepen preview doesn't like to load the audio, so just init to fix the preview for now. 2302 init(); 2303 // setLoadingStatus('Error Loading Audio'); 2304 return Promise.reject(reason); 2305 } 2306 ); 2307 }, 0); 2308} 2309

Love this component?

Explore more components and build amazing UIs.

View All Components