新春快乐跨年烟花代码
http://www.shanhubei.com/archives/13642.html
直接上代码:
<!DOCTYPE html> <html lang="en" > <head> <meta charset="UTF-8"> <title>2024新年快乐!万事如意!</title> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <meta name="mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="theme-color" content="#000000"> <link rel="shortcut icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png"> <link rel="icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png"> <link rel="apple-touch-icon-precomposed" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png"> <meta name="msapplication-TileColor" content="#000000"> <meta name="msapplication-TileImage" content="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png"> <link href="https://fonts.googleapis.com/css?family=Russo+One" rel="stylesheet"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css"> <link rel="stylesheet" href="./style.css"> <style> * { position: relative; box-sizing: border-box; } html,body { height: 100%; } html { background-color: #000; } body { overflow: hidden; color: rgba(255, 255, 255, 0.5); font-family: "Russo One", arial, sans-serif; line-height: 1.25; letter-spacing: 0.06em; } .hide { opacity: 0; visibility: hidden; } .remove { display: none; } .blur { filter: blur(12px); } .container { height: 100%; display: flex; justify-content: center; align-items: center; } #loading-init { width: 100%; align-self: center; text-align: center; font-size: 2em; } #stage-container { overflow: hidden; box-sizing: initial; border: 1px solid #222; margin: -1px; } #canvas-container { width: 100%; height: 100%; transition: filter 0.3s; } #canvas-container canvas { position: absolute; mix-blend-mode: lighten; } #controls { position: absolute; top: 0; width: 100%; padding-bottom: 50px; display: flex; justify-content: space-between; transition: opacity 0.3s, visibility 0.3s; } @media (min-width: 800px) { #controls { visibility: visible; } #controls.hide:hover { opacity: 1; } } #menu { display: flex; flex-direction: column; justify-content: center; align-items: center; position: absolute; top: 0; bottom: 0; width: 100%; background-color: rgba(0, 0, 0, 0.42); transition: opacity 0.3s, visibility 0.3s; } #menu__header { padding: 20px 0 44px; font-size: 2em; text-transform: uppercase; } #menu form { width: 240px; padding: 0 20px; overflow: auto; } #menu .form-option { margin: 20px 0; } #menu .form-option label { text-transform: uppercase; } #menu .form-option--select label { display: block; margin-bottom: 6px; } #menu .form-option--select select { display: block; width: 100%; height: 30px; font-size: 1rem; font-family: "Russo One", arial, sans-serif; color: rgba(255, 255, 255, 0.5); letter-spacing: 0.06em; background-color: transparent; border: 1px solid rgba(255, 255, 255, 0.5); } #menu .form-option--select select option { background-color: black; } #menu .form-option--checkbox label { display: flex; align-items: center; transition: opacity 0.3s; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } #menu .form-option--checkbox input { display: block; width: 20px; height: 20px; margin-right: 8px; opacity: 0.5; } @media (max-width: 800px) { #menu .form-option select, #menu .form-option input { outline: none; } } #close-menu-btn { position: absolute; top: 0; right: 0; } .btn { opacity: 0.16; width: 44px; height: 44px; display: flex; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; cursor: default; transition: opacity 0.3s; } .btn--bright { opacity: 0.5; } @media (min-width: 800px) { .btn:hover { opacity: 0.32; } .btn--bright:hover { opacity: 0.75; } } .btn svg { display: block; margin: auto; } </style> </head> <body> <!-- partial:index.partial.html --> <!-- SVG Spritesheet --> <div style="height: 0; width: 0; position: absolute; visibility: hidden;"> <svg xmlns="http://www.w3.org/2000/svg"> <symbol id="icon-play" viewBox="0 0 24 24"> <path d="M8 5v14l11-7z"/> </symbol> <symbol id="icon-pause" viewBox="0 0 24 24"> <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/> </symbol> <symbol id="icon-close" viewBox="0 0 24 24"> <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"/> </symbol> <symbol id="icon-settings" viewBox="0 0 24 24"> <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"/> </symbol> <symbol id="icon-shutter-fast" viewBox="0 0 24 24"> <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/> </symbol> <symbol id="icon-shutter-slow" viewBox="0 0 24 24"> <path d="M1 5h2v14H1zm4 0h2v14H5zm17 0H10c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1zM11 17l2.5-3.15L15.29 16l2.5-3.22L21 17H11z"/> </symbol> </svg> </div> <!-- App --> <div class="container"> <div id="loading-init">Loading...</div> <div id="stage-container" class="remove"> <div id="canvas-container"> <canvas id="trails-canvas"></canvas> <canvas id="main-canvas"></canvas> </div> <div id="controls"> <div id="pause-btn" class="btn"> <svg fill="white" width="24" height="24"><use href="#icon-pause"></use></svg> </div> <div id="shutter-btn" class="btn"> <svg fill="white" width="24" height="24"><use href="#icon-shutter-slow"></use></svg> </div> <div id="settings-btn" class="btn"> <svg fill="white" width="24" height="24"><use href="#icon-settings"></use></svg> </div> </div> <div id="menu" class="hide"> <div id="close-menu-btn" class="btn btn--bright"> <svg fill="white" width="24" height="24"><use href="#icon-close"></use></svg> </div> <div id="menu__header">Settings</div> <form> <div class="form-option form-option--select"> <label>Shell Type</label> <select id="shell-type"></select> </div> <div class="form-option form-option--select"> <label>Shell Size</label> <select id="shell-size"></select> </div> <div class="form-option form-option--checkbox"> <label id="auto-launch-label"><input id="auto-launch" type="checkbox" /><span>Auto Fire</span></label> </div> <div class="form-option form-option--checkbox"> <label id="finale-mode-label"><input id="finale-mode" type="checkbox" /><span>Finale Mode</span></label> </div> <div class="form-option form-option--checkbox"> <label id="hide-controls-label"><input id="hide-controls" type="checkbox" /><span>Hide Controls</span></label> </div> </form> </div> </div> </div> <!-- partial --> <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/fscreen%401.0.1.js'></script> <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Stage%400.1.4.js'></script> <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/MyMath.js'></script> <script> 'use strict'; console.clear(); const IS_MOBILE = window.innerWidth <= 640; const IS_DESKTOP = window.innerWidth > 800; const IS_HEADER = IS_DESKTOP && window.innerHeight < 300; // 8K - can restrict this if needed const MAX_WIDTH = 7680; const MAX_HEIGHT = 4320; const GRAVITY = 0.9; // Acceleration in px/s let simSpeed = 1; const COLOR = { Red: '#ff0043', Green: '#14fc56', Blue: '#1e7fff', Purple: '#e60aff', Gold: '#ffae00', White: '#ffffff' }; // Special invisible color (not rendered, and therefore not in COLOR map) const INVISIBLE = '_INVISIBLE_'; // Interactive state management const store = { _listeners: new Set(), _dispatch() { this._listeners.forEach(listener => listener(this.state)) }, state: { paused: false, longExposure: false, menuOpen: false, config: { shell: 'Random', size: IS_DESKTOP && !IS_HEADER ? '3' : '1', autoLaunch: true, finale: false, hideControls: IS_HEADER } }, setState(nextState) { this.state = Object.assign({}, this.state, nextState); this._dispatch(); this.persist(); }, subscribe(listener) { this._listeners.add(listener); return () => this._listeners.remove(listener); }, // Load / persist select state to localStorage load() { if (localStorage.getItem('schemaVersion') === '1') { this.state.config.size = JSON.parse(localStorage.getItem('configSize')); this.state.config.hideControls = JSON.parse(localStorage.getItem('hideControls')); } }, persist() { localStorage.setItem('schemaVersion', '1'); localStorage.setItem('configSize', JSON.stringify(this.state.config.size)); localStorage.setItem('hideControls', JSON.stringify(this.state.config.hideControls)); } }; if (!IS_HEADER) { store.load(); } // Actions // --------- function togglePause(toggle) { if (typeof toggle === 'boolean') { store.setState({ paused: toggle }); } else { store.setState({ paused: !store.state.paused }); } } function toggleLongExposure(toggle) { if (typeof toggle === 'boolean') { store.setState({ longExposure: toggle }); } else { store.setState({ longExposure: !store.state.longExposure }); } } function toggleMenu(toggle) { if (typeof toggle === 'boolean') { store.setState({ menuOpen: toggle }); } else { store.setState({ menuOpen: !store.state.menuOpen }); } } function updateConfig(nextConfig) { nextConfig = nextConfig || getConfigFromDOM(); store.setState({ config: Object.assign({}, store.state.config, nextConfig) }); } // Selectors // ----------- const canInteract = () => !store.state.paused && !store.state.menuOpen; const shellNameSelector = () => store.state.config.shell; // Converts shell size to number. const shellSizeSelector = () => +store.state.config.size; const finaleSelector = () => store.state.config.finale; // Render app UI / keep in sync with state const appNodes = { stageContainer: '#stage-container', canvasContainer: '#canvas-container', controls: '#controls', menu: '#menu', pauseBtn: '#pause-btn', pauseBtnSVG: '#pause-btn use', shutterBtn: '#shutter-btn', shutterBtnSVG: '#shutter-btn use', shellType: '#shell-type', shellSize: '#shell-size', autoLaunch: '#auto-launch', autoLaunchLabel: '#auto-launch-label', finaleMode: '#finale-mode', finaleModeLabel: '#finale-mode-label', hideControls: '#hide-controls', hideControlsLabel: '#hide-controls-label' }; // Convert appNodes selectors to dom nodes Object.keys(appNodes).forEach(key => { appNodes[key] = document.querySelector(appNodes[key]); }); // Remove loading state document.getElementById('loading-init').remove(); appNodes.stageContainer.classList.remove('remove'); // First render is called in init() function renderApp(state) { appNodes.pauseBtnSVG.setAttribute('href', `#icon-${state.paused ? 'play' : 'pause'}`); appNodes.shutterBtnSVG.setAttribute('href', `#icon-shutter-${state.longExposure ? 'fast' : 'slow'}`); 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.finaleModeLabel.style.opacity = state.config.autoLaunch ? 1 : 0.32; 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.hideControls.checked = state.config.hideControls; } store.subscribe(renderApp); function getConfigFromDOM() { return { shell: appNodes.shellType.value, size: appNodes.shellSize.value, autoLaunch: appNodes.autoLaunch.checked, finale: appNodes.finaleMode.checked, hideControls: appNodes.hideControls.checked }; }; const updateConfigNoEvent = () => updateConfig(); appNodes.shellType.addEventListener('input', updateConfigNoEvent); appNodes.shellSize.addEventListener('input', updateConfigNoEvent); appNodes.autoLaunchLabel.addEventListener('click', () => setTimeout(updateConfig, 0)); appNodes.finaleModeLabel.addEventListener('click', () => setTimeout(updateConfig, 0)); appNodes.hideControlsLabel.addEventListener('click', () => setTimeout(updateConfig, 0)); // Constant derivations const COLOR_NAMES = Object.keys(COLOR); const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]); // Invisible stars need an indentifier, even through they won't be rendered - physics still apply. const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE]; // Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects). const COLOR_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. function randomColorSimple() { return COLOR_CODES[Math.random() * COLOR_CODES.length | 0]; } // Get a random color, with some customization options available. let lastColor; function randomColor(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 randomly if (limitWhite && color === COLOR.White && Math.random() < 0.6) { color = randomColorSimple(); } if (notSame) { while (color === lastColor) { color = randomColorSimple(); } } else if (notColor) { while (color === notColor) { color = randomColorSimple(); } } lastColor = color; return color; } function whiteOrGold() { return Math.random() < 0.5 ? COLOR.Gold : COLOR.White; } const PI_2 = Math.PI * 2; const PI_HALF = Math.PI * 0.5; const trailsStage = new Stage('trails-canvas'); const mainStage = new Stage('main-canvas'); const stages = [ trailsStage, mainStage ]; // Fill trails canvas with black to start. trailsStage.ctx.fillStyle = '#000'; trailsStage.ctx.fillRect(0, 0, trailsStage.width, trailsStage.height); // Fullscreen helpers, using Fscreen for prefixes function requestFullscreen() { if (fullscreenEnabled() && !isFullscreen()) { fscreen.requestFullscreen(document.documentElement); } } function fullscreenEnabled() { return fscreen.fullscreenEnabled; } function isFullscreen() { return !!fscreen.fullscreenElement; } // Shell helpers function makePistilColor(shellColor) { return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({ notColor: shellColor }) : whiteOrGold(); } // Unique shell types //生成菊花状的烟花效果 const crysanthemumShell = (size=1) => { const glitter = Math.random() < 0.25;//是否产生闪光效果 const singleColor = Math.random() < 0.68;//是否使用单一颜色 //一个颜色数组,包含1到2个颜色值。如果singleColor为真,则该数组仅包含一个颜色;否则该数组将包含两个不同的颜色。颜色值通过调用randomColor函数随机生成 const color = singleColor ? randomColor({ limitWhite: true }) : [randomColor(), randomColor({ notSame: true })]; const pistil = singleColor && Math.random() < 0.42;//是否绘制花蕊 const pistilColor = makePistilColor(color);//绘制花蕊,花蕊的颜色 const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42;//是否绘制流星效果 return { size: 300 + size * 100,//烟花的大小 starLife: 900 + size * 200,//星星效果的寿命 starDensity: glitter ? 1.1 : 1.5,//星星效果的密度 color, glitter: glitter ? 'light' : '',//闪光效果的类型 glitterColor: whiteOrGold(),//绘制闪光效果 pistil, pistilColor, streamers }; }; //生成棕榈树状的烟花效果 const palmShell = (size=1) => ({ size: 250 + size * 75, starDensity: 0.6, starLife: 1800 + size * 200, glitter: 'heavy' }); //用于生成环状的烟花效果 const ringShell = (size=1) => { const color = randomColor(); const pistil = Math.random() < 0.75; return { ring: true, color, size: 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 }; }; //生成十字状的烟花效果 const crossetteShell = (size=1) => { const color = randomColor({ limitWhite: true }); return { size: 300 + size * 100, starLife: 900 + size * 200, starLifeVariation: 0.22, color, crossette: true, pistil: Math.random() < 0.5, pistilColor: makePistilColor(color) }; }; //生成花朵状的烟花效果 const floralShell = (size=1) => ({ size: 300 + size * 120, starDensity: 0.38, starLife: 500 + size * 50, starLifeVariation: 0.5, color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(), randomColor({ notSame: true })]), floral: true }); //生成落叶状的烟花效果 const fallingLeavesShell = (size=1) => ({ color: INVISIBLE, size: 300 + size * 120, starDensity: 0.38, starLife: 500 + size * 50, starLifeVariation: 0.5, glitter: 'medium', glitterColor: COLOR.Gold, fallingLeaves: true }); //生成柳树状烟花效果 const willowShell = (size=1) => ({ size: 300 + size * 100, starDensity: 0.7, starLife: 3000 + size * 300, glitter: 'willow', glitterColor: COLOR.Gold, color: INVISIBLE }); //生成爆裂声烟花(crackleShell)效果 const crackleShell = (size=1) => { // favor gold const color = Math.random() < 0.75 ? COLOR.Gold : randomColor(); return { size: 380 + size * 75, starDensity: 1, starLife: 600 + size * 100, starLifeVariation: 0.32, glitter: 'light', glitterColor: COLOR.Gold, color, crackle: true, pistil: Math.random() < 0.65, pistilColor: makePistilColor(color) }; }; //马尾状烟花效果 const horsetailShell = (size=1) => { const color = randomColor(); return { horsetail: true, color, size: 250 + size * 38, starDensity: 0.85 + size * 0.1, starLife: 2500 + size * 300, glitter: 'medium', glitterColor: Math.random() < 0.5 ? whiteOrGold() : color }; }; function randomShellName() { return Math.random() < 0.6 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0 ]; } function randomShell(size) { return shellTypes[randomShellName()](size); } function shellFromConfig(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']; function randomFastShell() { 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, 'Horse Tail': horsetailShell, 'Palm': palmShell, 'Ring': ringShell, 'Willow': willowShell }; const shellNames = Object.keys(shellTypes); function fitShellPositionInBoundsH(position) { const edge = 0.18; return (1 - edge*2) * position + edge; } function fitShellPositionInBoundsV(position) { return position * 0.75; } function getRandomShellPositionH() { return fitShellPositionInBoundsH(Math.random()); } function getRandomShellPositionV() { return fitShellPositionInBoundsV(Math.random()); } function getRandomShellSize() { 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.config function launchShellFromConfig(event) { const shell = new Shell(shellFromConfig(shellSizeSelector())); const w = mainStage.width; const h = mainStage.height; shell.launch( event ? event.x / w : getRandomShellPositionH(), event ? 1 - event.y / h : getRandomShellPositionV() ); } // Sequences // ----------- function seqRandomShell() { const size = getRandomShellSize(); const shell = new Shell(shellFromConfig(size.size)); shell.launch(size.x, size.height); let extraDelay = shell.starLife; if (shell.fallingLeaves) { extraDelay = 4000; } return 900 + Math.random() * 600 + extraDelay; } function seqTwoRandom() { const size1 = getRandomShellSize(); const size2 = getRandomShellSize(); const shell1 = new Shell(shellFromConfig(size1.size)); const shell2 = new Shell(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); shell2.launch(0.7 + rightOffset, size2.height); let extraDelay = Math.max(shell1.starLife, shell2.starLife); if (shell1.fallingLeaves || shell2.fallingLeaves) { extraDelay = 4000; } return 900 + Math.random() * 600 + extraDelay; } function seqTriple() { const shellType = randomFastShell(); const baseSize = shellSizeSelector(); const smallSize = Math.max(0, baseSize - 1.25); const offset = Math.random() * 0.08 - 0.04; const shell1 = new Shell(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 = new Shell(shellType(smallSize)); shell2.launch(0.2 + offset, 0.1); }, leftDelay); setTimeout(() => { const offset = Math.random() * 0.08 - 0.04; const shell3 = new Shell(shellType(smallSize)); shell3.launch(0.8 + offset, 0.1); }, rightDelay); return 4000; } function seqSmallBarrage() { seqSmallBarrage.lastCalled = Date.now(); const barrageCount = IS_DESKTOP ? 11 : 5; const shellSize = Math.max(0, shellSizeSelector() - 2); const useCrysanthemum = Math.random() < 0.7; // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights function launchShell(x) { const isRandom = shellNameSelector() === 'Random'; let shellType = isRandom ? (useCrysanthemum ? crysanthemumShell : randomFastShell()) : shellTypes[shellNameSelector()]; const shell = new Shell(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) count += 1; } else { const offset = (count + 1) / barrageCount / 2; setTimeout(() => { launchShell(0.5 + offset); launchShell(0.5 - offset); }, delay); count += 2; } delay += 200; } return 3400 + barrageCount * 120; } seqSmallBarrage.cooldown = 15000; seqSmallBarrage.lastCalled = Date.now(); const sequences = [ seqRandomShell, seqTwoRandom, seqTriple, seqSmallBarrage ]; let isFirstSeq = true; const finaleCount = 32; let currentFinaleCount = 0; function startSequence() { if (isFirstSeq) { isFirstSeq = false; const shell = new Shell(crysanthemumShell(shellSizeSelector())); shell.launch(0.5, 0.5); return 2400; } if (finaleSelector()) { seqRandomShell(); if (currentFinaleCount < finaleCount) { currentFinaleCount++; return 170; } else { currentFinaleCount = 0; return 6000; } } const rand = Math.random(); if (rand < 0.2 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) { return seqSmallBarrage(); } if (rand < 0.6) { return seqRandomShell(); } else if (rand < 0.8) { return seqTwoRandom(); } else if (rand < 1) { return seqTriple(); } } let activePointerCount = 0; let isUpdatingSpeed = false; function handlePointerStart(event) { activePointerCount++; const btnSize = 44; 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) { toggleLongExposure(); return; } if (event.x > mainStage.width - btnSize) { toggleMenu(); return; } } if (!canInteract()) return; if (updateSpeedFromEvent(event)) { isUpdatingSpeed = true; } else if (event.onCanvas) { launchShellFromConfig(event); } } function handlePointerEnd(event) { activePointerCount--; isUpdatingSpeed = false; } function handlePointerMove(event) { if (!canInteract()) return; if (isUpdatingSpeed) { updateSpeedFromEvent(event); } } function handleKeydown(event) { // P if (event.keyCode === 80) { togglePause(); } // O else if (event.keyCode === 79) { toggleMenu(); } // Esc else if (event.keyCode === 27) { toggleMenu(false); } } mainStage.addEventListener('pointerstart', handlePointerStart); mainStage.addEventListener('pointerend', handlePointerEnd); mainStage.addEventListener('pointermove', handlePointerMove); window.addEventListener('keydown', handleKeydown); // Try to go fullscreen upon a touch window.addEventListener('touchend', (event) => !IS_DESKTOP && requestFullscreen()); function handleResize() { const w = window.innerWidth; const h = window.innerHeight; // Try to adopt screen size, heeding maximum sizes specified const containerW = Math.min(w, MAX_WIDTH); // On small screens, use full device height const 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)); } // Compute initial dimensions handleResize(); window.addEventListener('resize', handleResize); // Dynamic globals let speedBarOpacity = 0; let autoLaunchTime = 0; function updateSpeedFromEvent(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 update speedBarOpacity = 1; // If we updated the speed, return true return true; } // Return false if the speed wasn't updated return false; } // Extracted function to keep `update()` optimized function updateGlobals(timeStep, lag) { // Always try to fade out speed bar if (!isUpdatingSpeed) { speedBarOpacity -= lag / 30; // half a second if (speedBarOpacity < 0) { speedBarOpacity = 0; } } // auto launch shells if (store.state.config.autoLaunch) { autoLaunchTime -= timeStep; if (autoLaunchTime <= 0) { autoLaunchTime = startSequence(); } } } function update(frameTime, lag) { if (!canInteract()) return; const { width, height } = mainStage; 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 => { // Stars Star.active[color].forEach((star, i, stars) => { star.life -= timeStep; if (star.life <= 0) { stars.splice(i, 1); Star.returnInstance(star); } else { 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; Spark.add( star.x, star.y, star.sparkColor, Math.random() * PI_2, Math.random() * star.sparkSpeed, star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation * star.sparkLife ); } } } }); // Sparks Spark.active[color].forEach((spark, i, sparks) => { 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); } function render(speed) { const { dpr, width, height } = mainStage; const trailsCtx = trailsStage.ctx; const mainCtx = mainStage.ctx; colorSky(speed); trailsCtx.scale(dpr, dpr); mainCtx.scale(dpr, dpr); trailsCtx.globalCompositeOperation = 'source-over'; trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.longExposure ? 0.0025 : 0.1 * speed})`; trailsCtx.fillRect(0, 0, width, height); // Remaining drawing on trails canvas will use 'lighten' blend mode trailsCtx.globalCompositeOperation = 'lighten'; mainCtx.clearRect(0, 0, width, height); // Draw queued burst flashes 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.05, 'white'); burstGradient.addColorStop(0.25, 'rgba(255, 160, 20, 0.2)'); burstGradient.addColorStop(1, 'rgba(255, 160, 20, 0)'); trailsCtx.fillStyle = burstGradient; trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2); BurstFlash.returnInstance(bf); } // Draw stars trailsCtx.lineWidth = Star.drawWidth; trailsCtx.lineCap = '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 => { 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 sparks trailsCtx.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 visible if (speedBarOpacity) { const speedBarHeight = 6; mainCtx.globalAlpha = speedBarOpacity; mainCtx.fillStyle = COLOR.Blue; mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight); mainCtx.globalAlpha = 1; } trailsCtx.resetTransform(); mainCtx.resetTransform(); } // 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 particles const currentSkyColor = { r: 0, g: 0, b: 0 }; const targetSkyColor = { r: 0, g: 0, b: 0 }; function colorSky(speed) { // The maximum r, g, or b value that will be used (255 would represent no maximum) const maxSkySaturation = 30; // How many stars are required in total to reach maximum sky brightness const maxStarCount = 500; let totalStarCount = 0; // Initialize sky as black targetSkyColor.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. function createParticleArc(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 assignment for (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); } } } // 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. function crossetteEffect(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 shell function floralEffect(star) { const startAngle = Math.random() * PI_HALF; createParticleArc(startAngle, PI_2, 24, 1, (angle) => { Star.add( star.x, star.y, star.color, angle, // apply near cubic falloff to speed (places more particles towards outside) Math.pow(Math.random(), 0.45) * 2.4, 1000 + Math.random() * 300, star.speedX, star.speedY ); }); // Queue burst flash render BurstFlash.add(star.x, star.y, 24); } // Floral burst with willow stars function fallingLeavesEffect(star) { const startAngle = Math.random() * PI_HALF; createParticleArc(startAngle, PI_2, 12, 1, (angle) => { const newStar = Star.add( star.x, star.y, INVISIBLE, angle, // apply near cubic falloff to speed (places more particles towards outside) Math.pow(Math.random(), 0.45) * 2.4, 2400 + Math.random() * 600, star.speedX, star.speedY ); newStar.sparkColor = COLOR.Gold; newStar.sparkFreq = 72; newStar.sparkSpeed = 0.28; newStar.sparkLife = 750; newStar.sparkLifeVariation = 3.2; }); // Queue burst flash render BurstFlash.add(star.x, star.y, 24); } // Crackle pops into a small cloud of golden sparks. function crackleEffect(star) { createParticleArc(0, PI_2, 10, 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: * * size: 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: */ class Shell { 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.size / 50 * density; this.starCount = scaledSize * scaledSize; } } launch(position, launchHeight) { const { width, height } = mainStage; // 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 height const minHeightPercent = 0.45; // Minimum burst height in px const 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, typeof this.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 testing launchVelocity * (this.horsetail ? 100 : 400) ); // making comet "heavy" limits air drag comet.heavy = true; // comet spark trail comet.spinRadius = 0.78; comet.sparkFreq = 16; if (this.glitter === 'willow' || this.fallingLeaves) { comet.sparkFreq = 10; comet.sparkSpeed = 0.5; comet.sparkLife = 500; comet.sparkLifeVariation = 3; } if (this.color === INVISIBLE) { comet.sparkColor = COLOR.Gold; } comet.onDeath = comet => this.burst(comet.x, comet.y); // comet.onDeath = () => this.burst(launchX, burstY); } 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.size / 96; let color, onDeath, sparkFreq, sparkSpeed, sparkLife; let sparkLifeVariation = 0.25; if (this.crossette) onDeath = crossetteEffect; if (this.floral) onDeath = floralEffect; if (this.crackle) onDeath = crackleEffect; if (this.fallingLeaves) onDeath = fallingLeavesEffect; if (this.glitter === 'light') { sparkFreq = 200; sparkSpeed = 0.25; sparkLife = 600; } else if (this.glitter === 'medium') { sparkFreq = 100; sparkSpeed = 0.36; sparkLife = 1400; } else if (this.glitter === 'heavy') { sparkFreq = 42; sparkSpeed = 0.62; sparkLife = 2800; } else if (this.glitter === 'streamer') { sparkFreq = 20; sparkSpeed = 0.75; sparkLife = 800; } else if (this.glitter === 'willow') { sparkFreq = 72; sparkSpeed = 0.28; sparkLife = 1000; sparkLifeVariation = 3.4; } const starFactory = angle => { const star = Star.add( x, y, color || randomColor(), angle, // apply near cubic falloff to speed (places more particles towards outside) Math.pow(Math.random(), 0.45) * speed, // add minor variation to star life this.starLife + Math.random() * this.starLife * this.starLifeVariation, this.horsetail && this.comet && this.comet.speedX, this.horsetail && this.comet && this.comet.speedY ); 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 (typeof this.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 randomly if (this.ring) { const ringStartAngle = Math.random() * Math.PI; const ringSquash = Math.pow(Math.random(), 0.45) * 0.992 + 0.008; createParticleArc(0, PI_2, this.starCount, 0, angle => { // Create a ring, squashed horizontally const initSpeedX = Math.sin(angle) * speed * ringSquash; const initSpeedY = Math.cos(angle) * speed; // Rotate ring const 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 life this.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 burst else { createParticleArc(0, PI_2, this.starCount, 1, starFactory); } } else if (Array.isArray(this.color)) { let start, start2, arc; if (Math.random() < 0.5) { start = Math.random() * Math.PI; start2 = start + Math.PI; arc = Math.PI; } else { start = 0; start2 = 0; arc = PI_2; } color = this.color[0]; createParticleArc(start, arc, this.starCount/2, 1, starFactory); color = this.color[1]; createParticleArc(start2, arc, this.starCount/2, 1, starFactory) } if (this.pistil) { const innerShell = new Shell({ size: this.size * 0.5, starLife: this.starLife * 0.7, starLifeVariation: this.starLifeVariation, starDensity: 1.65, color: this.pistilColor, glitter: 'light', glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White }); innerShell.burst(x, y); } if (this.streamers) { const innerShell = new Shell({ size: this.size, starLife: this.starLife * 0.8, starLifeVariation: this.starLifeVariation, starCount: Math.max(6, this.size / 45) | 0, color: COLOR.White, glitter: 'streamer' }); innerShell.burst(x, y); } // Queue burst flash render BurstFlash.add(x, y, this.size / 8); } } 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. function createParticleCollection() { const collection = {}; COLOR_CODES_W_INVIS.forEach(color => { collection[color] = []; }); return collection; } const Star = { // Visual properties drawWidth: 3, airDrag: 0.98, airDragHeavy: 0.992, // Star particles will be keyed by color active: createParticleCollection(), _pool: [], _new() { return {}; }, add(x, y, color, angle, speed, life, speedOffX, speedOffY) { const instance = this._pool.pop() || this._new(); 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.spinAngle = Math.random() * PI_2; instance.spinSpeed = 0.8; instance.spinRadius = 0; instance.sparkFreq = 0; // ms between spark emissions instance.sparkSpeed = 1; instance.sparkTimer = 0; instance.sparkColor = color; instance.sparkLife = 750; instance.sparkLifeVariation = 0.25; 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 up instance.onDeath = null; // Add back to the pool. this._pool.push(instance); } }; const Spark = { // Visual properties drawWidth: 0.75, airDrag: 0.9, // Star particles will be keyed by color active: 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); } }; function init() { // Populate dropdowns // shell type let options = ''; shellNames.forEach(opt => options += options += '<option value="' + opt + '">' + opt + '</option>'); appNodes.shellType.innerHTML = options; // shell size options = ''; ['3"', '5"', '6"', '8"', '12"'].forEach((opt, i) => options += '<option value="' + opt + '">' + opt + '</option>'); appNodes.shellSize.innerHTML = options; renderApp(store.state); } </script> </body> </html>