'use strict';
console.clear();// This is a prime example of what starts out as a simple project// and snowballs way beyond its intended size. It's a little clunky// reading/working on this single file, but here it is anyways :)constIS_MOBILE= window.innerWidth <=640;constIS_DESKTOP= window.innerWidth >800;constIS_HEADER=IS_DESKTOP&& window.innerHeight <300;// Detect high end devices. This will be a moving target.constIS_HIGH_END_DEVICE=(()=>{const hwConcurrency = navigator.hardwareConcurrency;if(!hwConcurrency){returnfalse;}// Large screens indicate a full size computer, which often have hyper threading these days.// So a quad core desktop machine has 8 cores. We'll place a higher min threshold there.const minCount = window.innerWidth <=1024?4:8;return hwConcurrency >= minCount;})();// Prevent canvases from getting too large on ridiculous screen sizes.// 8K - can restrict this if neededconstMAX_WIDTH=7680;constMAX_HEIGHT=4320;constGRAVITY=0.9;// Acceleration in px/slet simSpeed =1;functiongetDefaultScaleFactor(){if(IS_MOBILE)return0.9;if(IS_HEADER)return0.75;return1;}// Width/height values that take scale into account.// USE THESE FOR DRAWING POSITIONSlet stageW, stageH;// All quality globals will be overwritten and updated via `configDidUpdate`.let quality =1;let isLowQuality =false;let isNormalQuality =true;let isHighQuality =false;constQUALITY_LOW=1;constQUALITY_NORMAL=2;constQUALITY_HIGH=3;constSKY_LIGHT_NONE=0;constSKY_LIGHT_DIM=1;constSKY_LIGHT_NORMAL=2;constCOLOR={Red:'#ff0043',Green:'#14fc56',Blue:'#1e7fff',Purple:'#e60aff',Gold:'#ffbf36',White:'#ffffff'};// Special invisible color (not rendered, and therefore not in COLOR map)constINVISIBLE='_INVISIBLE_';constPI_2= Math.PI*2;constPI_HALF= Math.PI*0.5;// Stage.disableHighDPI = true;const trailsStage =newStage('trails-canvas');const mainStage =newStage('main-canvas');const stages =[trailsStage,mainStage
];// Fullscreen helpers, using Fscreen for prefixes.functionfullscreenEnabled(){return fscreen.fullscreenEnabled;}// Note that fullscreen state is synced to store, and the store should be the source// of truth for whether the app is in fullscreen mode or not.functionisFullscreen(){return!!fscreen.fullscreenElement;}// Attempt to toggle fullscreen mode.functiontoggleFullscreen(){if(fullscreenEnabled()){if(isFullscreen()){fscreen.exitFullscreen();}else{fscreen.requestFullscreen(document.documentElement);}}}// Sync fullscreen changes with store. An event listener is necessary because the user can// toggle fullscreen mode directly through the browser, and we want to react to that.
fscreen.addEventListener('fullscreenchange',()=>{store.setState({fullscreen:isFullscreen()});});// Simple state container; the source of truth.const store ={_listeners:newSet(),_dispatch(prevState){this._listeners.forEach(listener=>listener(this.state, prevState))},state:{// will be unpaused in init()paused:true,soundEnabled:false,menuOpen:false,openHelpTopic:null,fullscreen:isFullscreen(),// Note that config values used for <select>s must be strings, unless manually converting values to strings// at render time, and parsing on change.config:{quality:String(IS_HIGH_END_DEVICE?QUALITY_HIGH:QUALITY_NORMAL),// will be mirrored to a global variable named `quality` in `configDidUpdate`, for perf.shell:'Random',size:IS_DESKTOP?'3'// Desktop default:IS_HEADER?'1.2'// Profile header default (doesn't need to be an int):'2',// Mobile defaultautoLaunch:true,finale:false,skyLighting:SKY_LIGHT_NORMAL+'',hideControls:IS_HEADER,longExposure:false,scaleFactor:getDefaultScaleFactor()}},setState(nextState){const prevState =this.state;this.state = Object.assign({},this.state, nextState);this._dispatch(prevState);this.persist();},subscribe(listener){this._listeners.add(listener);return()=>this._listeners.remove(listener);},// Load / persist select state to localStorage// Mutates state because `store.load()` should only be called once immediately after store is created, before any subscriptions.load(){const serializedData = localStorage.getItem('cm_fireworks_data');if(serializedData){const{schemaVersion,data}=JSON.parse(serializedData);const config =this.state.config;switch(schemaVersion){case'1.1':config.quality = data.quality;config.size = data.size;config.skyLighting = data.skyLighting;break;case'1.2':config.quality = data.quality;config.size = data.size;config.skyLighting = data.skyLighting;config.scaleFactor = data.scaleFactor;break;default:thrownewError('version switch should be exhaustive');}console.log(`Loaded config (schema version ${schemaVersion})`);}// Deprecated data format. Checked with care (it's not namespaced).elseif(localStorage.getItem('schemaVersion')==='1'){let size;// Attempt to parse data, ignoring if there is an error.try{const sizeRaw = localStorage.getItem('configSize');size =typeof sizeRaw ==='string'&&JSON.parse(sizeRaw);}catch(e){console.log('Recovered from error parsing saved config:');console.error(e);return;}// Only restore validated valuesconst sizeInt =parseInt(size,10);if(sizeInt >=0&& sizeInt <=4){this.state.config.size =String(sizeInt);}}},persist(){const config =this.state.config;localStorage.setItem('cm_fireworks_data',JSON.stringify({schemaVersion:'1.2',data:{quality: config.quality,size: config.size,skyLighting: config.skyLighting,scaleFactor: config.scaleFactor}}));}};if(!IS_HEADER){store.load();}// Actions// ---------functiontogglePause(toggle){const paused = store.state.paused;let newValue;if(typeof toggle ==='boolean'){newValue = toggle;}else{newValue =!paused;}if(paused !== newValue){store.setState({paused: newValue });}}functiontoggleSound(toggle){if(typeof toggle ==='boolean'){store.setState({soundEnabled: toggle });}else{store.setState({soundEnabled:!store.state.soundEnabled });}}functiontoggleMenu(toggle){if(typeof toggle ==='boolean'){store.setState({menuOpen: toggle });}else{store.setState({menuOpen:!store.state.menuOpen });}}functionupdateConfig(nextConfig){nextConfig = nextConfig ||getConfigFromDOM();store.setState({config: Object.assign({}, store.state.config, nextConfig)});configDidUpdate();}// Map config to various properties & apply side effectsfunctionconfigDidUpdate(){const config = store.state.config;quality =qualitySelector();isLowQuality = quality ===QUALITY_LOW;isNormalQuality = quality ===QUALITY_NORMAL;isHighQuality = quality ===QUALITY_HIGH;if(skyLightingSelector()===SKY_LIGHT_NONE){appNodes.canvasContainer.style.backgroundColor ='#000';}Spark.drawWidth = quality ===QUALITY_HIGH?0.75:1;}// Selectors// -----------constisRunning=(state=store.state)=>!state.paused &&!state.menuOpen;// Whether user has enabled sound.constsoundEnabledSelector=(state=store.state)=> state.soundEnabled;// Whether any sounds are allowed, taking into account multiple factors.constcanPlaySoundSelector=(state=store.state)=>isRunning(state)&&soundEnabledSelector(state);// Convert quality to number.constqualitySelector=()=>+store.state.config.quality;constshellNameSelector=()=> store.state.config.shell;// Convert shell size to number.constshellSizeSelector=()=>+store.state.config.size;constfinaleSelector=()=> store.state.config.finale;constskyLightingSelector=()=>+store.state.config.skyLighting;constscaleFactorSelector=()=> store.state.config.scaleFactor;// Help Contentconst helpContent ={shellType:{header:'Shell Type',body:'The type of firework that will be launched. Select "Random" for a nice assortment!'},shellSize:{header:'Shell Size',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.'},quality:{header:'Quality',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.'},skyLighting:{header:'Sky Lighting',body:'Illuminates the background as fireworks explode. If the background looks too bright on your screen, try setting it to "Dim" or "None".'},scaleFactor:{header:'Scale',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.'},autoLaunch:{header:'Auto Fire',body:'Launches sequences of fireworks automatically. Sit back and enjoy the show, or disable to have full control.'},finaleMode:{header:'Finale Mode',body:'Launches intense bursts of fireworks. May cause lag. Requires "Auto Fire" to be enabled.'},hideControls:{header:'Hide Controls',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.'},fullscreen:{header:'Fullscreen',body:'Toggles fullscreen mode.'},longExposure:{header:'Open Shutter',body:'Experimental effect that preserves long streaks of light, similar to leaving a camera shutter open.'}};const nodeKeyToHelpKey ={shellTypeLabel:'shellType',shellSizeLabel:'shellSize',qualityLabel:'quality',skyLightingLabel:'skyLighting',scaleFactorLabel:'scaleFactor',autoLaunchLabel:'autoLaunch',finaleModeLabel:'finaleMode',hideControlsLabel:'hideControls',fullscreenLabel:'fullscreen',longExposureLabel:'longExposure'};// Render app UI / keep in sync with stateconst appNodes ={stageContainer:'.stage-container',canvasContainer:'.canvas-container',controls:'.controls',menu:'.menu',menuInnerWrap:'.menu__inner-wrap',pauseBtn:'.pause-btn',pauseBtnSVG:'.pause-btn use',soundBtn:'.sound-btn',soundBtnSVG:'.sound-btn use',shellType:'.shell-type',shellTypeLabel:'.shell-type-label',shellSize:'.shell-size',shellSizeLabel:'.shell-size-label',quality:'.quality-ui',qualityLabel:'.quality-ui-label',skyLighting:'.sky-lighting',skyLightingLabel:'.sky-lighting-label',scaleFactor:'.scaleFactor',scaleFactorLabel:'.scaleFactor-label',autoLaunch:'.auto-launch',autoLaunchLabel:'.auto-launch-label',finaleModeFormOption:'.form-option--finale-mode',finaleMode:'.finale-mode',finaleModeLabel:'.finale-mode-label',hideControls:'.hide-controls',hideControlsLabel:'.hide-controls-label',fullscreenFormOption:'.form-option--fullscreen',fullscreen:'.fullscreen',fullscreenLabel:'.fullscreen-label',longExposure:'.long-exposure',longExposureLabel:'.long-exposure-label',// Help UIhelpModal:'.help-modal',helpModalOverlay:'.help-modal__overlay',helpModalHeader:'.help-modal__header',helpModalBody:'.help-modal__body',helpModalCloseBtn:'.help-modal__close-btn'};// Convert appNodes selectors to dom nodes
Object.keys(appNodes).forEach(key=>{appNodes[key]= document.querySelector(appNodes[key]);});// Remove fullscreen control if not supported.if(!fullscreenEnabled()){appNodes.fullscreenFormOption.classList.add('remove');}// First render is called in init()functionrenderApp(state){const pauseBtnIcon =`#icon-${state.paused ?'play':'pause'}`;const soundBtnIcon =`#icon-sound-${soundEnabledSelector()?'on':'off'}`;appNodes.pauseBtnSVG.setAttribute('href', pauseBtnIcon);appNodes.pauseBtnSVG.setAttribute('xlink:href', pauseBtnIcon);appNodes.soundBtnSVG.setAttribute('href', soundBtnIcon);appNodes.soundBtnSVG.setAttribute('xlink:href', soundBtnIcon);appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);appNodes.menu.classList.toggle('hide',!state.menuOpen);appNodes.finaleModeFormOption.style.opacity = state.config.autoLaunch ?1:0.32;appNodes.quality.value = state.config.quality;appNodes.shellType.value = state.config.shell;appNodes.shellSize.value = state.config.size;appNodes.autoLaunch.checked = state.config.autoLaunch;appNodes.finaleMode.checked = state.config.finale;appNodes.skyLighting.value = state.config.skyLighting;appNodes.hideControls.checked = state.config.hideControls;appNodes.fullscreen.checked = state.fullscreen;appNodes.longExposure.checked = state.config.longExposure;appNodes.scaleFactor.value = state.config.scaleFactor.toFixed(2);appNodes.menuInnerWrap.style.opacity = state.openHelpTopic ?0.12:1;appNodes.helpModal.classList.toggle('active',!!state.openHelpTopic);if(state.openHelpTopic){const{ header, body }= helpContent[state.openHelpTopic];appNodes.helpModalHeader.textContent = header;appNodes.helpModalBody.textContent = body;}}store.subscribe(renderApp);// Perform side effects on state changesfunctionhandleStateChange(state, prevState){const canPlaySound =canPlaySoundSelector(state);const canPlaySoundPrev =canPlaySoundSelector(prevState);if(canPlaySound !== canPlaySoundPrev){if(canPlaySound){soundManager.resumeAll();}else{soundManager.pauseAll();}}}store.subscribe(handleStateChange);functiongetConfigFromDOM(){return{quality: appNodes.quality.value,shell: appNodes.shellType.value,size: appNodes.shellSize.value,autoLaunch: appNodes.autoLaunch.checked,finale: appNodes.finaleMode.checked,skyLighting: appNodes.skyLighting.value,longExposure: appNodes.longExposure.checked,hideControls: appNodes.hideControls.checked,// Store value as number.scaleFactor:parseFloat(appNodes.scaleFactor.value)};};constupdateConfigNoEvent=()=>updateConfig();
appNodes.quality.addEventListener('input', updateConfigNoEvent);
appNodes.shellType.addEventListener('input', updateConfigNoEvent);
appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
appNodes.autoLaunch.addEventListener('click',()=>setTimeout(updateConfig,0));
appNodes.finaleMode.addEventListener('click',()=>setTimeout(updateConfig,0));
appNodes.skyLighting.addEventListener('input', updateConfigNoEvent);
appNodes.longExposure.addEventListener('click',()=>setTimeout(updateConfig,0));
appNodes.hideControls.addEventListener('click',()=>setTimeout(updateConfig,0));
appNodes.fullscreen.addEventListener('click',()=>setTimeout(toggleFullscreen,0));// Changing scaleFactor requires triggering resize handling code as well.
appNodes.scaleFactor.addEventListener('input',()=>{updateConfig();handleResize();});Object.keys(nodeKeyToHelpKey).forEach(nodeKey=>{const helpKey = nodeKeyToHelpKey[nodeKey];appNodes[nodeKey].addEventListener('click',()=>{store.setState({openHelpTopic: helpKey });});});appNodes.helpModalCloseBtn.addEventListener('click',()=>{store.setState({openHelpTopic:null});});appNodes.helpModalOverlay.addEventListener('click',()=>{store.setState({openHelpTopic:null});});// Constant derivationsconstCOLOR_NAMES= Object.keys(COLOR);constCOLOR_CODES=COLOR_NAMES.map(colorName=>COLOR[colorName]);// Invisible stars need an indentifier, even through they won't be rendered - physics still apply.constCOLOR_CODES_W_INVIS=[...COLOR_CODES,INVISIBLE];// Map of color codes to their index in the array. Useful for quickly determining if a color has already been updated in a loop.constCOLOR_CODE_INDEXES=COLOR_CODES_W_INVIS.reduce((obj, code, i)=>{obj[code]= i;return obj;},{});// Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).constCOLOR_TUPLES={};COLOR_CODES.forEach(hex=>{COLOR_TUPLES[hex]={r:parseInt(hex.substr(1,2),16),g:parseInt(hex.substr(3,2),16),b:parseInt(hex.substr(5,2),16),};});// Get a random color.functionrandomColorSimple(){returnCOLOR_CODES[Math.random()*COLOR_CODES.length |0];}// Get a random color, with some customization options available.let lastColor;functionrandomColor(options){const notSame = options && options.notSame;const notColor = options && options.notColor;const limitWhite = options && options.limitWhite;let color =randomColorSimple();// limit the amount of white chosen randomlyif(limitWhite && color ===COLOR.White && Math.random()<0.6){color =randomColorSimple();}if(notSame){while(color === lastColor){color =randomColorSimple();}}elseif(notColor){while(color === notColor){color =randomColorSimple();}}lastColor = color;return color;}functionwhiteOrGold(){return Math.random()<0.5?COLOR.Gold :COLOR.White;}// Shell helpersfunctionmakePistilColor(shellColor){return(shellColor ===COLOR.White || shellColor ===COLOR.Gold)?randomColor({notColor: shellColor }):whiteOrGold();}// Unique shell typesconstcrysanthemumShell=(size=1)=>{const glitter = Math.random()<0.25;const singleColor = Math.random()<0.72;const color = singleColor ?randomColor({limitWhite:true}):[randomColor(),randomColor({notSame:true})];const pistil = singleColor && Math.random()<0.42;const pistilColor = pistil &&makePistilColor(color);const secondColor = singleColor &&(Math.random()<0.2|| color ===COLOR.White)? pistilColor ||randomColor({notColor: color,limitWhite:true}):null;const streamers =!pistil && color !==COLOR.White && Math.random()<0.42;let starDensity = glitter ?1.1:1.25;if(isLowQuality) starDensity *=0.8;if(isHighQuality) starDensity =1.2;return{shellSize: size,spreadSize:300+ size *100,starLife:900+ size *200,starDensity,color,secondColor,glitter: glitter ?'light':'',glitterColor:whiteOrGold(),pistil,pistilColor,streamers};};constghostShell=(size=1)=>{// Extend crysanthemum shellconst shell =crysanthemumShell(size);// Ghost effect can be fast, so extend star lifeshell.starLife *=1.5;// Ensure we always have a single color other than whitelet ghostColor =randomColor({notColor:COLOR.White });// Always use streamers, and sometimes a pistilshell.streamers =true;const pistil = Math.random()<0.42;const pistilColor = pistil &&makePistilColor(ghostColor);// Ghost effect - transition from invisible to chosen colorshell.color =INVISIBLE;shell.secondColor = ghostColor;// We don't want glitter to be spewed by invisible stars, and we don't currently// have a way to transition glitter state. So we'll disable it.shell.glitter ='';return shell;};conststrobeShell=(size=1)=>{const color =randomColor({limitWhite:true});return{shellSize: size,spreadSize:280+ size *92,starLife:1100+ size *200,starLifeVariation:0.40,starDensity:1.1,color,glitter:'light',glitterColor:COLOR.White,strobe:true,strobeColor: Math.random()<0.5?COLOR.White :null,pistil: Math.random()<0.5,pistilColor:makePistilColor(color)};};constpalmShell=(size=1)=>{const color =randomColor();const thick = Math.random()<0.5;return{shellSize: size,color,spreadSize:250+ size *75,starDensity: thick ?0.15:0.4,starLife:1800+ size *200,glitter: thick ?'thick':'heavy'};};constringShell=(size=1)=>{const color =randomColor();const pistil = Math.random()<0.75;return{shellSize: size,ring:true,color,spreadSize:300+ size *100,starLife:900+ size *200,starCount:2.2*PI_2*(size+1),pistil,pistilColor:makePistilColor(color),glitter:!pistil ?'light':'',glitterColor: color ===COLOR.Gold ?COLOR.Gold :COLOR.White,streamers: Math.random()<0.3};// return Object.assign({}, defaultShell, config);};constcrossetteShell=(size=1)=>{const color =randomColor({limitWhite:true});return{shellSize: size,spreadSize:300+ size *100,starLife:750+ size *160,starLifeVariation:0.4,starDensity:0.85,color,crossette:true,pistil: Math.random()<0.5,pistilColor:makePistilColor(color)};};constfloralShell=(size=1)=>({shellSize: size,spreadSize:300+ size *120,starDensity:0.12,starLife:500+ size *50,starLifeVariation:0.5,color: Math.random()<0.65?'random':(Math.random()<0.15?randomColor():[randomColor(),randomColor({notSame:true})]),floral:true});constfallingLeavesShell=(size=1)=>({shellSize: size,color:INVISIBLE,spreadSize:300+ size *120,starDensity:0.12,starLife:500+ size *50,starLifeVariation:0.5,glitter:'medium',glitterColor:COLOR.Gold,fallingLeaves:true});constwillowShell=(size=1)=>({shellSize: size,spreadSize:300+ size *100,starDensity:0.6,starLife:3000+ size *300,glitter:'willow',glitterColor:COLOR.Gold,color:INVISIBLE});constcrackleShell=(size=1)=>{// favor goldconst color = Math.random()<0.75?COLOR.Gold :randomColor();return{shellSize: size,spreadSize:380+ size *75,starDensity: isLowQuality ?0.65:1,starLife:600+ size *100,starLifeVariation:0.32,glitter:'light',glitterColor:COLOR.Gold,color,crackle:true,pistil: Math.random()<0.65,pistilColor:makePistilColor(color)};};consthorsetailShell=(size=1)=>{const color =randomColor();return{shellSize: size,horsetail:true,color,spreadSize:250+ size *38,starDensity:0.9,starLife:2500+ size *300,glitter:'medium',glitterColor: Math.random()<0.5?whiteOrGold(): color,// Add strobe effect to white horsetails, to make them more interestingstrobe: color ===COLOR.White};};functionrandomShellName(){return Math.random()<0.5?'Crysanthemum': shellNames[(Math.random()*(shellNames.length -1)+1)|0];}functionrandomShell(size){// Special selection for codepen header.if(IS_HEADER)returnrandomFastShell()(size);// Normal operationreturn shellTypes[randomShellName()](size);}functionshellFromConfig(size){return shellTypes[shellNameSelector()](size);}// Get a random shell, not including processing intensive varients// Note this is only random when "Random" shell is selected in config.// Also, this does not create the shell, only returns the factory function.const fastShellBlacklist =['Falling Leaves','Floral','Willow'];functionrandomFastShell(){const isRandom =shellNameSelector()==='Random';let shellName = isRandom ?randomShellName():shellNameSelector();if(isRandom){while(fastShellBlacklist.includes(shellName)){shellName =randomShellName();}}return shellTypes[shellName];}const shellTypes ={'Random': randomShell,'Crackle': crackleShell,'Crossette': crossetteShell,'Crysanthemum': crysanthemumShell,'Falling Leaves': fallingLeavesShell,'Floral': floralShell,'Ghost': ghostShell,'Horse Tail': horsetailShell,'Palm': palmShell,'Ring': ringShell,'Strobe': strobeShell,'Willow': willowShell
};const shellNames = Object.keys(shellTypes);functioninit(){// Remove loading statedocument.querySelector('.loading-init').remove();appNodes.stageContainer.classList.remove('remove');// Populate dropdownsfunctionsetOptionsForSelect(node, options){node.innerHTML = options.reduce((acc, opt)=> acc +=`<option value="${opt.value}">${opt.label}</option>`,'');}// shell typelet options ='';shellNames.forEach(opt=> options +=`<option value="${opt}">${opt}</option>`);appNodes.shellType.innerHTML = options;// shell sizeoptions ='';['3"','4"','6"','8"','12"','16"'].forEach((opt, i)=> options +=`<option value="${i}">${opt}</option>`);appNodes.shellSize.innerHTML = options;setOptionsForSelect(appNodes.quality,[{label:'Low',value:QUALITY_LOW},{label:'Normal',value:QUALITY_NORMAL},{label:'High',value:QUALITY_HIGH}]);setOptionsForSelect(appNodes.skyLighting,[{label:'None',value:SKY_LIGHT_NONE},{label:'Dim',value:SKY_LIGHT_DIM},{label:'Normal',value:SKY_LIGHT_NORMAL}]);// 0.9 is mobile defaultsetOptionsForSelect(appNodes.scaleFactor,[0.5,0.62,0.75,0.9,1.0,1.5,2.0].map(value=>({value: value.toFixed(2),label:`${value*100}%`})));// Begin simulationtogglePause(false);// initial renderrenderApp(store.state);// Apply initial configconfigDidUpdate();}functionfitShellPositionInBoundsH(position){const edge =0.18;return(1- edge*2)* position + edge;}functionfitShellPositionInBoundsV(position){return position *0.75;}functiongetRandomShellPositionH(){returnfitShellPositionInBoundsH(Math.random());}functiongetRandomShellPositionV(){returnfitShellPositionInBoundsV(Math.random());}functiongetRandomShellSize(){const baseSize =shellSizeSelector();const maxVariance = Math.min(2.5, baseSize);const variance = Math.random()* maxVariance;const size = baseSize - variance;const height = maxVariance ===0? Math.random():1-(variance / maxVariance);const centerOffset = Math.random()*(1- height *0.65)*0.5;const x = Math.random()<0.5?0.5- centerOffset :0.5+ centerOffset;return{size,x:fitShellPositionInBoundsH(x),height:fitShellPositionInBoundsV(height)};}// Launches a shell from a user pointer event, based on state.configfunctionlaunchShellFromConfig(event){const shell =newShell(shellFromConfig(shellSizeSelector()));const w = mainStage.width;const h = mainStage.height;shell.launch(event ? event.x / w :getRandomShellPositionH(),event ?1- event.y / h :getRandomShellPositionV());}// Sequences// -----------functionseqRandomShell(){const size =getRandomShellSize();const shell =newShell(shellFromConfig(size.size));shell.launch(size.x, size.height);let extraDelay = shell.starLife;if(shell.fallingLeaves){extraDelay =4600;}return900+ Math.random()*600+ extraDelay;}functionseqRandomFastShell(){const shellType =randomFastShell();const size =getRandomShellSize();const shell =newShell(shellType(size.size));shell.launch(size.x, size.height);let extraDelay = shell.starLife;return900+ Math.random()*600+ extraDelay;}functionseqTwoRandom(){const size1 =getRandomShellSize();const size2 =getRandomShellSize();const shell1 =newShell(shellFromConfig(size1.size));const shell2 =newShell(shellFromConfig(size2.size));const leftOffset = Math.random()*0.2-0.1;const rightOffset = Math.random()*0.2-0.1;shell1.launch(0.3+ leftOffset, size1.height);setTimeout(()=>{shell2.launch(0.7+ rightOffset, size2.height);},100);let extraDelay = Math.max(shell1.starLife, shell2.starLife);if(shell1.fallingLeaves || shell2.fallingLeaves){extraDelay =4600;}return900+ Math.random()*600+ extraDelay;}functionseqTriple(){const shellType =randomFastShell();const baseSize =shellSizeSelector();const smallSize = Math.max(0, baseSize -1.25);const offset = Math.random()*0.08-0.04;const shell1 =newShell(shellType(baseSize));shell1.launch(0.5+ offset,0.7);const leftDelay =1000+ Math.random()*400;const rightDelay =1000+ Math.random()*400;setTimeout(()=>{const offset = Math.random()*0.08-0.04;const shell2 =newShell(shellType(smallSize));shell2.launch(0.2+ offset,0.1);}, leftDelay);setTimeout(()=>{const offset = Math.random()*0.08-0.04;const shell3 =newShell(shellType(smallSize));shell3.launch(0.8+ offset,0.1);}, rightDelay);return4000;}functionseqPyramid(){const barrageCountHalf =IS_DESKTOP?7:4;const largeSize =shellSizeSelector();const smallSize = Math.max(0, largeSize -3);const randomMainShell = Math.random()<0.78? crysanthemumShell : ringShell;const randomSpecialShell = randomShell;functionlaunchShell(x, useSpecial){const isRandom =shellNameSelector()==='Random';let shellType = isRandom? useSpecial ? randomSpecialShell : randomMainShell: shellTypes[shellNameSelector()];const shell =newShell(shellType(useSpecial ? largeSize : smallSize));const height = x <=0.5? x /0.5:(1- x)/0.5;shell.launch(x, useSpecial ?0.75: height *0.42);}let count =0;let delay =0;while(count <= barrageCountHalf){if(count === barrageCountHalf){setTimeout(()=>{launchShell(0.5,true);}, delay);}else{const offset = count / barrageCountHalf *0.5;const delayOffset = Math.random()*30+30;setTimeout(()=>{launchShell(offset,false);}, delay);setTimeout(()=>{launchShell(1- offset,false);}, delay + delayOffset);}count++;delay +=200;}return3400+ barrageCountHalf *250;}functionseqSmallBarrage(){seqSmallBarrage.lastCalled = Date.now();const barrageCount =IS_DESKTOP?11:5;const specialIndex =IS_DESKTOP?3:1;const shellSize = Math.max(0,shellSizeSelector()-2);const randomMainShell = Math.random()<0.78? crysanthemumShell : ringShell;const randomSpecialShell =randomFastShell();// (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heightsfunctionlaunchShell(x, useSpecial){const isRandom =shellNameSelector()==='Random';let shellType = isRandom? useSpecial ? randomSpecialShell : randomMainShell: shellTypes[shellNameSelector()];const shell =newShell(shellType(shellSize));const height =(Math.cos(x*5*Math.PI+PI_HALF)+1)/2;shell.launch(x, height *0.75);}let count =0;let delay =0;while(count < barrageCount){if(count ===0){launchShell(0.5,false)count +=1;}else{const offset =(count +1)/ barrageCount /2;const delayOffset = Math.random()*30+30;const useSpecial = count === specialIndex;setTimeout(()=>{launchShell(0.5+ offset, useSpecial);}, delay);setTimeout(()=>{launchShell(0.5- offset, useSpecial);}, delay + delayOffset);count +=2;}delay +=200;}return3400+ barrageCount *120;}
seqSmallBarrage.cooldown =15000;
seqSmallBarrage.lastCalled = Date.now();const sequences =[seqRandomShell,seqTwoRandom,seqTriple,seqPyramid,seqSmallBarrage
];let isFirstSeq =true;const finaleCount =32;let currentFinaleCount =0;functionstartSequence(){if(isFirstSeq){isFirstSeq =false;if(IS_HEADER){returnseqTwoRandom();}else{const shell =newShell(crysanthemumShell(shellSizeSelector()));shell.launch(0.5,0.5);return2400;}}if(finaleSelector()){seqRandomFastShell();if(currentFinaleCount < finaleCount){currentFinaleCount++;return170;}else{currentFinaleCount =0;return6000;}}const rand = Math.random();if(rand <0.08&& Date.now()- seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown){returnseqSmallBarrage();}if(rand <0.1){returnseqPyramid();}if(rand <0.6&&!IS_HEADER){returnseqRandomShell();}elseif(rand <0.8){returnseqTwoRandom();}elseif(rand <1){returnseqTriple();}}let activePointerCount =0;let isUpdatingSpeed =false;functionhandlePointerStart(event){activePointerCount++;const btnSize =50;if(event.y < btnSize){if(event.x < btnSize){togglePause();return;}if(event.x > mainStage.width/2- btnSize/2&& event.x < mainStage.width/2+ btnSize/2){toggleSound();return;}if(event.x > mainStage.width - btnSize){toggleMenu();return;}}if(!isRunning())return;if(updateSpeedFromEvent(event)){isUpdatingSpeed =true;}elseif(event.onCanvas){launchShellFromConfig(event);}}functionhandlePointerEnd(event){activePointerCount--;isUpdatingSpeed =false;}functionhandlePointerMove(event){if(!isRunning())return;if(isUpdatingSpeed){updateSpeedFromEvent(event);}}functionhandleKeydown(event){// Pif(event.keyCode ===80){togglePause();}// Oelseif(event.keyCode ===79){toggleMenu();}// Escelseif(event.keyCode ===27){toggleMenu(false);}}mainStage.addEventListener('pointerstart', handlePointerStart);
mainStage.addEventListener('pointerend', handlePointerEnd);
mainStage.addEventListener('pointermove', handlePointerMove);
window.addEventListener('keydown', handleKeydown);// Account for window resize and custom scale changes.functionhandleResize(){const w = window.innerWidth;const h = window.innerHeight;// Try to adopt screen size, heeding maximum sizes specifiedconst containerW = Math.min(w,MAX_WIDTH);// On small screens, use full device heightconst containerH = w <=420? h : Math.min(h,MAX_HEIGHT);appNodes.stageContainer.style.width = containerW +'px';appNodes.stageContainer.style.height = containerH +'px';stages.forEach(stage=> stage.resize(containerW, containerH));// Account for scaleconst scaleFactor =scaleFactorSelector();stageW = containerW / scaleFactor;stageH = containerH / scaleFactor;}// Compute initial dimensionshandleResize();window.addEventListener('resize', handleResize);// Dynamic globalslet currentFrame =0;let speedBarOpacity =0;let autoLaunchTime =0;functionupdateSpeedFromEvent(event){if(isUpdatingSpeed || event.y >= mainStage.height -44){// 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.const edge =16;const newSpeed =(event.x - edge)/(mainStage.width - edge *2);simSpeed = Math.min(Math.max(newSpeed,0),1);// show speed bar after an updatespeedBarOpacity =1;// If we updated the speed, return truereturntrue;}// Return false if the speed wasn't updatedreturnfalse;}// Extracted function to keep `update()` optimizedfunctionupdateGlobals(timeStep, lag){currentFrame++;// Always try to fade out speed barif(!isUpdatingSpeed){speedBarOpacity -= lag /30;// half a secondif(speedBarOpacity <0){speedBarOpacity =0;}}// auto launch shellsif(store.state.config.autoLaunch){autoLaunchTime -= timeStep;if(autoLaunchTime <=0){autoLaunchTime =startSequence()*1.25;}}}functionupdate(frameTime, lag){if(!isRunning())return;const width = stageW;const height = stageH;const timeStep = frameTime * simSpeed;const speed = simSpeed * lag;updateGlobals(timeStep, lag);const starDrag =1-(1- Star.airDrag)* speed;const starDragHeavy =1-(1- Star.airDragHeavy)* speed;const sparkDrag =1-(1- Spark.airDrag)* speed;const gAcc = timeStep /1000*GRAVITY;COLOR_CODES_W_INVIS.forEach(color=>{// Starsconst stars = Star.active[color];for(let i=stars.length-1; i>=0; i=i-1){const star = stars[i];// 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".if(star.updateFrame === currentFrame){continue;}star.updateFrame = currentFrame;star.life -= timeStep;if(star.life <=0){stars.splice(i,1);Star.returnInstance(star);}else{const burnRate = Math.pow(star.life / star.fullLife,0.5);const burnRateInverse =1- burnRate;star.prevX = star.x;star.prevY = star.y;star.x += star.speedX * speed;star.y += star.speedY * speed;// Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.if(!star.heavy){star.speedX *= starDrag;star.speedY *= starDrag;}else{star.speedX *= starDragHeavy;star.speedY *= starDragHeavy;}star.speedY += gAcc;if(star.spinRadius){star.spinAngle += star.spinSpeed * speed;star.x += Math.sin(star.spinAngle)* star.spinRadius * speed;star.y += Math.cos(star.spinAngle)* star.spinRadius * speed;}if(star.sparkFreq){star.sparkTimer -= timeStep;while(star.sparkTimer <0){star.sparkTimer += star.sparkFreq *0.75+ star.sparkFreq * burnRateInverse *4;Spark.add(star.x,star.y,star.sparkColor,Math.random()*PI_2,Math.random()* star.sparkSpeed * burnRate,star.sparkLife *0.8+ Math.random()* star.sparkLifeVariation * star.sparkLife);}}// Handle star transitionsif(star.life < star.transitionTime){if(star.secondColor &&!star.colorChanged){star.colorChanged =true;star.color = star.secondColor;stars.splice(i,1);Star.active[star.secondColor].push(star);if(star.secondColor ===INVISIBLE){star.sparkFreq =0;}}if(star.strobe){// Strobes in the following pattern: on:off:off:on:off:off in increments of `strobeFreq` ms.star.visible = Math.floor(star.life / star.strobeFreq)%3===0;}}}}// Sparksconst sparks = Spark.active[color];for(let i=sparks.length-1; i>=0; i=i-1){const spark = sparks[i];spark.life -= timeStep;if(spark.life <=0){sparks.splice(i,1);Spark.returnInstance(spark);}else{spark.prevX = spark.x;spark.prevY = spark.y;spark.x += spark.speedX * speed;spark.y += spark.speedY * speed;spark.speedX *= sparkDrag;spark.speedY *= sparkDrag;spark.speedY += gAcc;}}});render(speed);}functionrender(speed){const{ dpr }= mainStage;const width = stageW;const height = stageH;const trailsCtx = trailsStage.ctx;const mainCtx = mainStage.ctx;if(skyLightingSelector()!==SKY_LIGHT_NONE){colorSky(speed);}// Account for high DPI screens, and custom scale factor.const scaleFactor =scaleFactorSelector();trailsCtx.scale(dpr * scaleFactor, dpr * scaleFactor);mainCtx.scale(dpr * scaleFactor, dpr * scaleFactor);trailsCtx.globalCompositeOperation ='source-over';trailsCtx.fillStyle =`rgba(0, 0, 0, ${store.state.config.longExposure ?0.0025:0.175* speed})`;trailsCtx.fillRect(0,0, width, height);mainCtx.clearRect(0,0, width, height);// Draw queued burst flashes// These must also be drawn using source-over due to Safari. Seems rendering the gradients using lighten draws large black boxes instead.// Thankfully, these burst flashes look pretty much the same either way.while(BurstFlash.active.length){const bf = BurstFlash.active.pop();const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y,0, bf.x, bf.y, bf.radius);burstGradient.addColorStop(0.024,'rgba(255, 255, 255, 1)');burstGradient.addColorStop(0.125,'rgba(255, 160, 20, 0.2)');burstGradient.addColorStop(0.32,'rgba(255, 140, 20, 0.11)');burstGradient.addColorStop(1,'rgba(255, 120, 20, 0)');trailsCtx.fillStyle = burstGradient;trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius *2, bf.radius *2);BurstFlash.returnInstance(bf);}// Remaining drawing on trails canvas will use 'lighten' blend modetrailsCtx.globalCompositeOperation ='lighten';// Draw starstrailsCtx.lineWidth = Star.drawWidth;trailsCtx.lineCap = isLowQuality ?'square':'round';mainCtx.strokeStyle ='#fff';mainCtx.lineWidth =1;mainCtx.beginPath();COLOR_CODES.forEach(color=>{const stars = Star.active[color];trailsCtx.strokeStyle = color;trailsCtx.beginPath();stars.forEach(star=>{if(star.visible){trailsCtx.moveTo(star.x, star.y);trailsCtx.lineTo(star.prevX, star.prevY);mainCtx.moveTo(star.x, star.y);mainCtx.lineTo(star.x - star.speedX *1.6, star.y - star.speedY *1.6);}});trailsCtx.stroke();});mainCtx.stroke();// Draw sparkstrailsCtx.lineWidth = Spark.drawWidth;trailsCtx.lineCap ='butt';COLOR_CODES.forEach(color=>{const sparks = Spark.active[color];trailsCtx.strokeStyle = color;trailsCtx.beginPath();sparks.forEach(spark=>{trailsCtx.moveTo(spark.x, spark.y);trailsCtx.lineTo(spark.prevX, spark.prevY);});trailsCtx.stroke();});// Render speed bar if visibleif(speedBarOpacity){const speedBarHeight =6;mainCtx.globalAlpha = speedBarOpacity;mainCtx.fillStyle =COLOR.Blue;mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight);mainCtx.globalAlpha =1;}trailsCtx.setTransform(1,0,0,1,0,0);mainCtx.setTransform(1,0,0,1,0,0);}// Draw colored overlay based on combined brightness of stars (light up the sky!)// Note: this is applied to the canvas container's background-color, so it's behind the particlesconst currentSkyColor ={r:0,g:0,b:0};const targetSkyColor ={r:0,g:0,b:0};functioncolorSky(speed){// The maximum r, g, or b value that will be used (255 would represent no maximum)const maxSkySaturation =skyLightingSelector()*15;// How many stars are required in total to reach maximum sky brightnessconst maxStarCount =500;let totalStarCount =0;// Initialize sky as blacktargetSkyColor.r =0;targetSkyColor.g =0;targetSkyColor.b =0;// 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.// Also add up total star count.COLOR_CODES.forEach(color=>{const tuple =COLOR_TUPLES[color];const count = Star.active[color].length;totalStarCount += count;targetSkyColor.r += tuple.r * count;targetSkyColor.g += tuple.g * count;targetSkyColor.b += tuple.b * count;});// 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.const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount),0.3);// Figure out which color component has the highest value, so we can scale them without affecting the ratios.// Prevent 0 from being used, so we don't divide by zero in the next step.const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b);// Scale all color components to a max of `maxSkySaturation`, and apply intensity.targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity;targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity;targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity;// Animate changes to color to smooth out transitions.const colorChange =10;currentSkyColor.r +=(targetSkyColor.r - currentSkyColor.r)/ colorChange * speed;currentSkyColor.g +=(targetSkyColor.g - currentSkyColor.g)/ colorChange * speed;currentSkyColor.b +=(targetSkyColor.b - currentSkyColor.b)/ colorChange * speed;appNodes.canvasContainer.style.backgroundColor =`rgb(${currentSkyColor.r |0}, ${currentSkyColor.g |0}, ${currentSkyColor.b |0})`;}mainStage.addEventListener('ticker', update);// Helper used to semi-randomly spread particles over an arc// Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.functioncreateParticleArc(start, arcLength, count, randomness, particleFactory){const angleDelta = arcLength / count;// Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.// Would be nice to fix this a better way.const end = start + arcLength -(angleDelta *0.5);if(end > start){// Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta// V8 deoptimises with let compound assignmentfor(let angle=start; angle<end; angle=angle+angleDelta){particleFactory(angle + Math.random()* angleDelta * randomness);}}else{for(let angle=start; angle>end; angle=angle+angleDelta){particleFactory(angle + Math.random()* angleDelta * randomness);}}}/*** Helper used to create a spherical burst of particles.** @param {Number} count The desired number of stars/particles. This value is a suggestion, and the* created burst may have more particles. The current algorithm can't perfectly* distribute a specific number of points evenly on a sphere's surface.* @param {Function} particleFactory Called once per star/particle generated. Passed two arguments:* `angle`: The direction of the star/particle.* `speed`: A multipler for the particle speed, from 0.0 to 1.0.* @param {Number} startAngle=0 For segmented bursts, you can generate only a partial arc of particles. This* allows setting the starting arc angle (radians).* @param {Number} arcLength=TAU The length of the arc (radians). Defaults to a full circle.** @return {void} Returns nothing; it's up to `particleFactory` to use the given data.*/functioncreateBurst(count, particleFactory, startAngle=0, arcLength=PI_2){// Assuming sphere with surface area of `count`, calculate various// properties of said sphere (unit is stars).// RadiusconstR=0.5* Math.sqrt(count/Math.PI);// CircumferenceconstC=2*R* Math.PI;// Half CircumferenceconstC_HALF=C/2;// Make a series of rings, sizing them as if they were spaced evenly// along the curved surface of a sphere.for(let i=0; i<=C_HALF; i++){const ringAngle = i /C_HALF*PI_HALF;const ringSize = Math.cos(ringAngle);const partsPerFullRing =C* ringSize;const partsPerArc = partsPerFullRing *(arcLength /PI_2);const angleInc =PI_2/ partsPerFullRing;const angleOffset = Math.random()* angleInc + startAngle;// Each particle needs a bit of randomness to improve appearance.const maxRandomAngleOffset = angleInc *0.33;for(let i=0; i<partsPerArc; i++){const randomAngleOffset = Math.random()* maxRandomAngleOffset;let angle = angleInc * i + angleOffset + randomAngleOffset;particleFactory(angle, ringSize);}}}// Various star effects.// These are designed to be attached to a star's `onDeath` event.// Crossette breaks star into four same-color pieces which branch in a cross-like shape.functioncrossetteEffect(star){const startAngle = Math.random()*PI_HALF;createParticleArc(startAngle,PI_2,4,0.5,(angle)=>{Star.add(star.x,star.y,star.color,angle,Math.random()*0.6+0.75,600);});}// Flower is like a mini shellfunctionfloralEffect(star){const count =12+6* quality;createBurst(count,(angle, speedMult)=>{Star.add(star.x,star.y,star.color,angle,speedMult *2.4,1000+ Math.random()*300,star.speedX,star.speedY);});// Queue burst flash renderBurstFlash.add(star.x, star.y,46);soundManager.playSound('burstSmall');}// Floral burst with willow starsfunctionfallingLeavesEffect(star){createBurst(7,(angle, speedMult)=>{const newStar = Star.add(star.x,star.y,INVISIBLE,angle,speedMult *2.4,2400+ Math.random()*600,star.speedX,star.speedY);newStar.sparkColor =COLOR.Gold;newStar.sparkFreq =144/ quality;newStar.sparkSpeed =0.28;newStar.sparkLife =750;newStar.sparkLifeVariation =3.2;});// Queue burst flash renderBurstFlash.add(star.x, star.y,46);soundManager.playSound('burstSmall');}// Crackle pops into a small cloud of golden sparks.functioncrackleEffect(star){const count = isHighQuality ?32:16;createParticleArc(0,PI_2, count,1.8,(angle)=>{Spark.add(star.x,star.y,COLOR.Gold,angle,// apply near cubic falloff to speed (places more particles towards outside)Math.pow(Math.random(),0.45)*2.4,300+ Math.random()*200);});}/*** Shell can be constructed with options:** spreadSize: Size of the burst.* starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.* starLife:* starLifeVariation:* color:* glitterColor:* glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'* pistil:* pistilColor:* streamers:* crossette:* floral:* crackle:*/classShell{constructor(options){Object.assign(this, options);this.starLifeVariation = options.starLifeVariation ||0.125;this.color = options.color ||randomColor();this.glitterColor = options.glitterColor ||this.color;// Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.if(!this.starCount){const density = options.starDensity ||1;const scaledSize =this.spreadSize /54;this.starCount = Math.max(6, scaledSize * scaledSize * density);}}launch(position, launchHeight){const width = stageW;const height = stageH;// Distance from sides of screen to keep shells.const hpad =60;// Distance from top of screen to keep shell bursts.const vpad =50;// Minimum burst height, as a percentage of stage heightconst minHeightPercent =0.45;// Minimum burst height in pxconst minHeight = height - height * minHeightPercent;const launchX = position *(width - hpad *2)+ hpad;const launchY = height;const burstY = minHeight -(launchHeight *(minHeight - vpad));const launchDistance = launchY - burstY;// Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.// Magic numbers came from testing.const launchVelocity = Math.pow(launchDistance *0.04,0.64);const comet =this.comet = Star.add(launchX,launchY,typeofthis.color ==='string'&&this.color !=='random'?this.color :COLOR.White,Math.PI,launchVelocity *(this.horsetail ?1.2:1),// Hang time is derived linearly from Vi; exact number came from testinglaunchVelocity *(this.horsetail ?100:400));// making comet "heavy" limits air dragcomet.heavy =true;// comet spark trailcomet.spinRadius = MyMath.random(0.32,0.85);comet.sparkFreq =32/ quality;if(isHighQuality) comet.sparkFreq =8;comet.sparkLife =320;comet.sparkLifeVariation =3;if(this.glitter ==='willow'||this.fallingLeaves){comet.sparkFreq =20/ quality;comet.sparkSpeed =0.5;comet.sparkLife =500;}if(this.color ===INVISIBLE){comet.sparkColor =COLOR.Gold;}// Randomly make comet "burn out" a bit early.// This is disabled for horsetail shells, due to their very short airtime.if(Math.random()>0.4&&!this.horsetail){comet.secondColor =INVISIBLE;comet.transitionTime = Math.pow(Math.random(),1.5)*700+500;}comet.onDeath=comet=>this.burst(comet.x, comet.y);soundManager.playSound('lift');}burst(x, y){// Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.const speed =this.spreadSize /96;let color, onDeath, sparkFreq, sparkSpeed, sparkLife;let sparkLifeVariation =0.25;// Some death effects, like crackle, play a sound, but should only be played once.let playedDeathSound =false;if(this.crossette)onDeath=(star)=>{if(!playedDeathSound){soundManager.playSound('crackleSmall');playedDeathSound =true;}crossetteEffect(star);}if(this.crackle)onDeath=(star)=>{if(!playedDeathSound){soundManager.playSound('crackle');playedDeathSound =true;}crackleEffect(star);}if(this.floral) onDeath = floralEffect;if(this.fallingLeaves) onDeath = fallingLeavesEffect;if(this.glitter ==='light'){sparkFreq =400;sparkSpeed =0.3;sparkLife =300;sparkLifeVariation =2;}elseif(this.glitter ==='medium'){sparkFreq =200;sparkSpeed =0.44;sparkLife =700;sparkLifeVariation =2;}elseif(this.glitter ==='heavy'){sparkFreq =80;sparkSpeed =0.8;sparkLife =1400;sparkLifeVariation =2;}elseif(this.glitter ==='thick'){sparkFreq =16;sparkSpeed = isHighQuality ?1.65:1.5;sparkLife =1400;sparkLifeVariation =3;}elseif(this.glitter ==='streamer'){sparkFreq =32;sparkSpeed =1.05;sparkLife =620;sparkLifeVariation =2;}elseif(this.glitter ==='willow'){sparkFreq =120;sparkSpeed =0.34;sparkLife =1400;sparkLifeVariation =3.8;}// Apply quality to spark countsparkFreq = sparkFreq / quality;// Star factory for primary burst, pistils, and streamers.let firstStar =true;conststarFactory=(angle, speedMult)=>{// For non-horsetail shells, compute an initial vertical speed to add to star burst.// The magic number comes from testing what looks best. The ideal is that all shell// bursts appear visually centered for the majority of the star life (excl. willows etc.)const standardInitialSpeed =this.spreadSize /1800;const star = Star.add(x,y,color ||randomColor(),angle,speedMult * speed,// add minor variation to star lifethis.starLife + Math.random()*this.starLife *this.starLifeVariation,this.horsetail ?this.comet &&this.comet.speedX :0,this.horsetail ?this.comet &&this.comet.speedY :-standardInitialSpeed);if(this.secondColor){star.transitionTime =this.starLife *(Math.random()*0.05+0.32);star.secondColor =this.secondColor;}if(this.strobe){star.transitionTime =this.starLife *(Math.random()*0.08+0.46);star.strobe =true;// How many milliseconds between switch of strobe state "tick". Note that the strobe pattern// is on:off:off, so this is the "on" duration, while the "off" duration is twice as long.star.strobeFreq = Math.random()*20+40;if(this.strobeColor){star.secondColor =this.strobeColor;}}star.onDeath = onDeath;if(this.glitter){star.sparkFreq = sparkFreq;star.sparkSpeed = sparkSpeed;star.sparkLife = sparkLife;star.sparkLifeVariation = sparkLifeVariation;star.sparkColor =this.glitterColor;star.sparkTimer = Math.random()* star.sparkFreq;}};if(typeofthis.color ==='string'){if(this.color ==='random'){color =null;// falsey value creates random color in starFactory}else{color =this.color;}// Rings have positional randomness, but are rotated randomlyif(this.ring){const ringStartAngle = Math.random()* Math.PI;const ringSquash = Math.pow(Math.random(),2)*0.85+0.15;;createParticleArc(0,PI_2,this.starCount,0,angle=>{// Create a ring, squashed horizontallyconst initSpeedX = Math.sin(angle)* speed * ringSquash;const initSpeedY = Math.cos(angle)* speed;// Rotate ringconst newSpeed = MyMath.pointDist(0,0, initSpeedX, initSpeedY);const newAngle = MyMath.pointAngle(0,0, initSpeedX, initSpeedY)+ ringStartAngle;const star = Star.add(x,y,color,newAngle,// apply near cubic falloff to speed (places more particles towards outside)newSpeed,//speed,// add minor variation to star lifethis.starLife + Math.random()*this.starLife *this.starLifeVariation);if(this.glitter){star.sparkFreq = sparkFreq;star.sparkSpeed = sparkSpeed;star.sparkLife = sparkLife;star.sparkLifeVariation = sparkLifeVariation;star.sparkColor =this.glitterColor;star.sparkTimer = Math.random()* star.sparkFreq;}});}// Normal burstelse{createBurst(this.starCount, starFactory);}}elseif(Array.isArray(this.color)){if(Math.random()<0.5){const start = Math.random()* Math.PI;const start2 = start + Math.PI;const arc = Math.PI;color =this.color[0];// Not creating a full arc automatically reduces star count.createBurst(this.starCount, starFactory, start, arc);color =this.color[1];createBurst(this.starCount, starFactory, start2, arc);}else{color =this.color[0];createBurst(this.starCount /2, starFactory);color =this.color[1];createBurst(this.starCount /2, starFactory);}}else{thrownewError('Invalid shell color. Expected string or array of strings, but got: '+this.color);}if(this.pistil){const innerShell =newShell({spreadSize:this.spreadSize *0.5,starLife:this.starLife *0.6,starLifeVariation:this.starLifeVariation,starDensity:1.4,color:this.pistilColor,glitter:'light',glitterColor:this.pistilColor ===COLOR.Gold ?COLOR.Gold :COLOR.White});innerShell.burst(x, y);}if(this.streamers){const innerShell =newShell({spreadSize:this.spreadSize *0.9,starLife:this.starLife *0.8,starLifeVariation:this.starLifeVariation,starCount: Math.floor(Math.max(6,this.spreadSize /45)),color:COLOR.White,glitter:'streamer'});innerShell.burst(x, y);}// Queue burst flash renderBurstFlash.add(x, y,this.spreadSize /4);// Play sound, but only for "original" shell, the one that was launched.// We don't want multiple sounds from pistil or streamer "sub-shells".// This can be detected by the presence of a comet.if(this.comet){// Scale explosion sound based on current shell size and selected (max) shell size.// Shooting selected shell size will always sound the same no matter the selected size,// but when smaller shells are auto-fired, they will sound smaller. It doesn't sound great// when a value too small is given though, so instead of basing it on proportions, we just// look at the difference in size and map it to a range known to sound good.const maxDiff =2;const sizeDifferenceFromMaxSize = Math.min(maxDiff,shellSizeSelector()-this.shellSize);const soundScale =(1- sizeDifferenceFromMaxSize / maxDiff)*0.3+0.7;soundManager.playSound('burst', soundScale);}}}const BurstFlash ={active:[],_pool:[],_new(){return{}},add(x, y, radius){const instance =this._pool.pop()||this._new();instance.x = x;instance.y = y;instance.radius = radius;this.active.push(instance);return instance;},returnInstance(instance){this._pool.push(instance);}};// Helper to generate objects for storing active particles.// Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.functioncreateParticleCollection(){const collection ={};COLOR_CODES_W_INVIS.forEach(color=>{collection[color]=[];});return collection;}// Star properties (WIP)// -----------------------// transitionTime - how close to end of life that star transition happensconst Star ={// Visual propertiesdrawWidth:3,airDrag:0.98,airDragHeavy:0.992,// Star particles will be keyed by coloractive:createParticleCollection(),_pool:[],_new(){return{};},add(x, y, color, angle, speed, life, speedOffX, speedOffY){const instance =this._pool.pop()||this._new();instance.visible =true;instance.heavy =false;instance.x = x;instance.y = y;instance.prevX = x;instance.prevY = y;instance.color = color;instance.speedX = Math.sin(angle)* speed +(speedOffX ||0);instance.speedY = Math.cos(angle)* speed +(speedOffY ||0);instance.life = life;instance.fullLife = life;instance.spinAngle = Math.random()*PI_2;instance.spinSpeed =0.8;instance.spinRadius =0;instance.sparkFreq =0;// ms between spark emissionsinstance.sparkSpeed =1;instance.sparkTimer =0;instance.sparkColor = color;instance.sparkLife =750;instance.sparkLifeVariation =0.25;instance.strobe =false;this.active[color].push(instance);return instance;},// Public method for cleaning up and returning an instance back to the pool.returnInstance(instance){// Call onDeath handler if available (and pass it current star instance)instance.onDeath && instance.onDeath(instance);// Clean upinstance.onDeath =null;instance.secondColor =null;instance.transitionTime =0;instance.colorChanged =false;// Add back to the pool.this._pool.push(instance);}};const Spark ={// Visual propertiesdrawWidth:0,// set in `configDidUpdate()`airDrag:0.9,// Star particles will be keyed by coloractive:createParticleCollection(),_pool:[],_new(){return{};},add(x, y, color, angle, speed, life){const instance =this._pool.pop()||this._new();instance.x = x;instance.y = y;instance.prevX = x;instance.prevY = y;instance.color = color;instance.speedX = Math.sin(angle)* speed;instance.speedY = Math.cos(angle)* speed;instance.life = life;this.active[color].push(instance);return instance;},// Public method for cleaning up and returning an instance back to the pool.returnInstance(instance){// Add back to the pool.this._pool.push(instance);}};const soundManager ={baseURL:'https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/',ctx:new(window.AudioContext || window.webkitAudioContext),sources:{lift:{volume:1,playbackRateMin:0.85,playbackRateMax:0.95,fileNames:['lift1.mp3','lift2.mp3','lift3.mp3']},burst:{volume:1,playbackRateMin:0.8,playbackRateMax:0.9,fileNames:['burst1.mp3','burst2.mp3']},burstSmall:{volume:0.25,playbackRateMin:0.8,playbackRateMax:1,fileNames:['burst-sm-1.mp3','burst-sm-2.mp3']},crackle:{volume:0.2,playbackRateMin:1,playbackRateMax:1,fileNames:['crackle1.mp3']},crackleSmall:{volume:0.3,playbackRateMin:1,playbackRateMax:1,fileNames:['crackle-sm-1.mp3']}},preload(){const allFilePromises =[];functioncheckStatus(response){if(response.status >=200&& response.status <300){return response;}const customError =newError(response.statusText);customError.response = response;throw customError;}const types = Object.keys(this.sources);types.forEach(type=>{const source =this.sources[type];const{ fileNames }= source;const filePromises =[];fileNames.forEach(fileName=>{const fileURL =this.baseURL + fileName;// Promise will resolve with decoded audio buffer.const promise =fetch(fileURL).then(checkStatus).then(response=> response.arrayBuffer()).then(data=>newPromise(resolve=>{this.ctx.decodeAudioData(data, resolve);}));filePromises.push(promise);allFilePromises.push(promise);});Promise.all(filePromises).then(buffers=>{source.buffers = buffers;});});return Promise.all(allFilePromises);},pauseAll(){this.ctx.suspend();},resumeAll(){// Play a sound with no volume for iOS. This 'unlocks' the audio context when the user first enables sound.this.playSound('lift',0);// Chrome mobile requires interaction before starting audio context.// The sound toggle button is triggered on 'touchstart', which doesn't seem to count as a full// interaction to Chrome. I guess it needs a click? At any rate if the first thing the user does// is enable audio, it doesn't work. Using a setTimeout allows the first interaction to be registered.// Perhaps a better solution is to track whether the user has interacted, and if not but they try enabling// sound, show a tooltip that they should tap again to enable sound.setTimeout(()=>{this.ctx.resume();},250);},// Private property used to throttle small burst sounds._lastSmallBurstTime:0,/*** Play a sound of `type`. Will randomly pick a file associated with type, and play it at the specified volume* and play speed, with a bit of random variance in play speed. This is all based on `sources` config.** @param {string} type - The type of sound to play.* @param {?number} scale=1 - Value between 0 and 1 (values outside range will be clamped). Scales less than one* descrease volume and increase playback speed. This is because large explosions are* louder, deeper, and reverberate longer than small explosions.* Note that a scale of 0 will mute the sound.*/playSound(type, scale=1){// Ensure `scale` is within valid range.scale = MyMath.clamp(scale,0,1);// Disallow starting new sounds if sound is disabled, app is running in slow motion, or paused.// Slow motion check has some wiggle room in case user doesn't finish dragging the speed bar// *all* the way back.if(!canPlaySoundSelector()|| simSpeed <0.95){return;}// Throttle small bursts, since floral/falling leaves shells have a lot of them.if(type ==='burstSmall'){const now = Date.now();if(now -this._lastSmallBurstTime <20){return;}this._lastSmallBurstTime = now;}const source =this.sources[type];if(!source){thrownewError(`Sound of type "${type}" doesn't exist.`);}const initialVolume = source.volume;const initialPlaybackRate = MyMath.random(source.playbackRateMin,source.playbackRateMax);// Volume descreases with scale.const scaledVolume = initialVolume * scale;// Playback rate increases with scale. For this, we map the scale of 0-1 to a scale of 2-1.// So at a scale of 1, sound plays normally, but as scale approaches 0 speed approaches double.const scaledPlaybackRate = initialPlaybackRate *(2- scale);const gainNode =this.ctx.createGain();gainNode.gain.value = scaledVolume;const buffer = MyMath.randomChoice(source.buffers);const bufferSource =this.ctx.createBufferSource();bufferSource.playbackRate.value = scaledPlaybackRate;bufferSource.buffer = buffer;bufferSource.connect(gainNode);gainNode.connect(this.ctx.destination);bufferSource.start(0);}};// Kick things off.functionsetLoadingStatus(status){document.querySelector('.loading-init__status').textContent = status;}// CodePen profile header doesn't need audio, just initialize.if(IS_HEADER){init();}else{// Allow status to render, then preload assets and start app.setLoadingStatus('Lighting Fuses');setTimeout(()=>{soundManager.preload().then(init,reason=>{// Codepen preview doesn't like to load the audio, so just init to fix the preview for now.init();// setLoadingStatus('Error Loading Audio');return Promise.reject(reason);});},0);}