用HTML和CSS打造跨年烟花秀视觉盛宴-一、程序代码

时间:2024-02-19 17:46:08
<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<title>跨年烟花秀</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">惊喜即将来临!</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;
			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
		});

		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 init() {
			// Populate dropdowns
			// shell type
			let options = '';
			shellNames.forEach(opt => options += `<option value="${opt}">${opt}</option>`);
			appNodes.shellType.innerHTML = options;
			// shell size
			options = '';
			['3"', '5"', '6"', '8"', '12"'].forEach((opt, i) => options += `<option value="${i}">${opt}</option>`);
			appNodes.shellSize.innerHTML = options;

			// initial render
			renderApp(store.state);
		}


		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);
			}
		};



		init();
	</script>
</body>

</html>