用HTML和CSS打造跨年烟花秀视觉盛宴

news2024/11/26 8:29:38

目录

一、程序代码

二、代码原理

三、运行效果


一、程序代码

<!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>

二、代码原理

这段代码的实现原理主要是通过 HTML、CSS 和外部资源(图标、字体和样式表)来构建一个具有烟花秀效果的网页。

首先,通过<meta>标签设置文档的元数据,如字符编码、视口大小等。然后,通过<link>标签引入外部资源,包括图标、字体和样式表。

<style>标签中,定义了一系列的 CSS 样式规则。这些样式规则用于设置页面元素的位置、尺寸、颜色、字体等样式属性。例如,设置容器的高度为100%,设置背景颜色为黑色,设置文字的颜色和字体等。

在样式规则中,使用了类选择器(以.开头)和id选择器(以#开头)来选择特定的元素,并对它们应用相应的样式。例如,通过.container选择器选择了类名为.container的元素,并设置其为居中对齐。

此外,还使用了媒体查询(@media)来根据不同的屏幕宽度设置不同的样式。例如,在宽度大于800px时,显示菜单栏并设置透明度为1;在小于800px时,隐藏菜单栏。

整个页面的布局和样式通过HTML和CSS实现,通过引入外部资源来美化页面。最终效果是一个具有烟花秀效果的网页,用户可以在此网页上观看跨年烟花秀。

鉴于该代码较长,此处只对一小部分核心代码进行解释说明,具体如下所示:

  1. <!DOCTYPE html>:声明文档类型为 HTML。
  2. <html lang="en">:开始 HTML 标签,并指定页面语言为英语。
  3. <head>:头部标签,包含了关于文档的元数据。
  4. <meta charset="UTF-8">:设置字符编码为 UTF-8。
  5. <title>跨年烟花秀</title>:设置页面标题为“跨年烟花秀”。
  6. 一系列 <meta> 标签:设置视口、苹果设备支持、主题色等元信息。
  7. 一系列 <link> 标签:引入图标、字体和样式表等外部资源。
  8. <style>:内部样式表,定义页面元素的样式。
  9. CSS样式规则:包括元素的位置、尺寸、颜色、字体等样式设置。
  10. * {...}:通配符样式,设置所有元素的默认样式。
  11. .container {...}:定义类名为.container的容器样式。
  12. #loading-init {...}:定义id为loading-init的元素样式。
  13. #stage-container {...}:定义id为stage-container的元素样式。
  14. #canvas-container {...}:定义id为canvas-container的元素样式。
  15. #controls {...}:定义id为controls的元素样式。
  16. 媒体查询 @media (min-width: 800px):针对不同屏幕宽度设置不同的样式。
  17. #menu {...}:定义id为menu的元素样式。
  18. #menu__header {...}:定义id为menu__header的元素样式。
  19. #menu form {...}:定义form元素的样式。
  20. #menu .form-option {...}:定义类名为.form-option的表单选项样式。
  21. #menu .form-option label {...}:定义表单选项中的标签样式。
  22. #menu .form-option--select {...}:定义下拉选择框的样式。
  23. #menu .form-option--checkbox {...}:定义复选框的样式。
  24. 媒体查询 @media (max-width: 800px):针对小屏幕设备设置不同的样式。
  25. #close-menu-btn {...}:定义关闭菜单按钮样式。
  26. .btn {...}:定义按钮样式。
  27. .btn svg {...}:定义按钮内的 SVG 图标样式。

三、运行效果

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1451253.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

[01] Vue2学习准备

目录 vue理解创建实例插值表达式 {{}}响应式特性 vue理解 Vue.js 是一套构建用户界面的渐进式框架。 Vue 只关注视图层&#xff0c; 采用自底向上增量开发的设计。 Vue 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。 创建实例 准备容器 <div id…

初识Qt | 从安装到编写Hello World程序

文章目录 1.前端开发简单分类2.Qt的简单介绍3.Qt的安装和环境配置4.创建简单的Qt项目 1.前端开发简单分类 前端开发&#xff0c;这里是一个广义的概念&#xff0c;不单指网页开发&#xff0c;它的常见分类 网页开发&#xff1a;前端开发的主要领域&#xff0c;使用HTML、CSS …

leetcode刷题--贪心算法

七. 贪心算法 文章目录 七. 贪心算法1. 605 种花问题2. 121 买卖股票的最佳时机3. 561 数组拆分4. 455 分发饼干5. 575 分糖果6. 135 分发糖果7. 409 最长回文串8. 621 任务调度器9. 179 最大数10. 56 合并区间11. 57 插入区间13. 452 用最少数量的箭引爆气球14. 435 无重叠区间…

Deep learning学习笔记

lec 1&#xff1a;Regression 1.5 Linear neural networks for regression线性神经网络的回归 I parameterizing output layer, I handling data, I specifying loss function, I training model. 浅层网络包括线性模型&#xff0c;其中包含了许多经典的统计预测方法&…

网络安全防御保护 Day5

今天的任务如下 要求一的解决方法&#xff1a; 前面这些都是在防火墙FW1上的配置。 首先创建电信的NAT策略 这里新建转换后的地址池 移动同理&#xff0c;不过地址池不一样 要求二的解决方法&#xff1a; 切换至服务器映射选项&#xff0c;点击新建&#xff0c;配置外网通过…

JDBC 核心 API

引入 mysql-jdbc 驱动 驱动 jar 版本的选择&#xff1a;推荐使用 8.0.25&#xff0c;省略时区设置java 工程导入依赖 项目创建 lib 文件夹导入驱动依赖 jar 包jar 包右键 - 添加为库 JDBC 基本使用步骤 注册驱动获取连接创建发送 sql 语句对象发送 sql 语句&#xff0c;并获…

讲解用Python处理Excel表格

我们今天来一起探索一下用Python怎么操作Excel文件。与word文件的操作库python-docx类似&#xff0c;Python也有专门的库为Excel文件的操作提供支持&#xff0c;这些库包括xlrd、xlwt、xlutils、openpyxl、xlsxwriter几种&#xff0c;其中我最喜欢用的是openpyxl&#xff0c;这…

LEETCODE 315. 计算右侧小于当前元素的个数(归并)

class Solution { public: // 将count声明为publicvector<int> count; vector<int> indexs,tmp;public:vector<int> countSmaller(vector<int>& nums) {//归并int left0;int rightnums.size()-1;//计数// vector<int> count(nums.size()); …

题解37-42

101. 对称二叉树 - 力扣&#xff08;LeetCode&#xff09; 给你一个二叉树的根节点 root &#xff0c; 检查它是否轴对称。 示例 1&#xff1a; 输入&#xff1a;root [1,2,2,3,4,4,3] 输出&#xff1a;true示例 2&#xff1a; 输入&#xff1a;root [1,2,2,null,3,nul…

【C++】友元、内部类和匿名对象

&#x1f497;个人主页&#x1f497; ⭐个人专栏——C学习⭐ &#x1f4ab;点击关注&#x1f929;一起学习C语言&#x1f4af;&#x1f4ab; 目录 1. 友元 1.1 友元函数 1.2 友元类 2. 内部类 2.1 成员内部类 2.2 局部内部类 3. 匿名对象 3.1 基本概念 3.1 隐式转换 1…

(10)Hive的相关概念——文件格式和数据压缩

目录 一、文件格式 1.1 列式存储和行式存储 1.1.1 行存储的特点 1.1.2 列存储的特点 1.2 TextFile 1.3 SequenceFile 1.4 Parquet 1.5 ORC 二、数据压缩 2.1 数据压缩-概述 2.1.1 压缩的优点 2.1.2 压缩的缺点 2.2 Hive中压缩配置 2.2.1 开启Map输出阶段压缩&…

threejs之使用shader实现雷达扫描

varying vec2 vUv; uniform vec3 uColor; uniform float uTime;mat2 rotate2d(float _angle){return mat2(cos(_angle),-sin(_angle),sin(_angle),cos(_angle)); }void main(){vec2 newUv rotate2d(uTime*6.18)*(vUv-0.5);float angle atan(newUv.x,newUv.y);// 根据uv坐标获…

那些杠鸿蒙的现在怎么样了?

别杠&#xff0c;要杠就是你对。 一个纯血鸿蒙就已经打了那些杠精的嘴&#xff0c;以前是套壳Android&#xff0c;大家纷纷喷鸿蒙。现在鸿蒙已经全栈自研&#xff0c;并且已经展开各大企业生态合作。不管什么独立系统&#xff0c;都是一定要走一遍套壳Android的道路的&#xf…

幻兽帕鲁云服务器搭建零基础教程,新手小白一看就会

以下教程基于阿里云服务器ECS 来搭建幻兽帕鲁游戏服务器&#xff0c;通过一键部署的方式&#xff0c;最快1分钟即可完成部署。 阿里云一键部署幻兽帕鲁的活动地址&#xff1a;1分钟畅玩&#xff01;一键部署幻兽帕鲁联机服务器 首先&#xff0c;打开阿里云的这个游戏服务器活…

laravel_进程门面_简单介绍

文章目录 Facade是什么&#xff1f;Facade能干什么Facade有哪些方法&#xff1f;怎么使用Facade呢&#xff1f;详细的代码解释Symfony Process是什么&#xff1f;介绍Symfony总结 Facade是什么&#xff1f; 在 Laravel 框架中&#xff0c;Facade 是一种设计模式。 它提供了一…

Javaweb基础-会话

会话&#xff1a; 会话管理&#xff1a;Cookie和Session配合解决 cookie是在客户端保留少量数据的技术,主要通过响应头向客户端响应一些客户端要保留的信息 session是在服务端保留更多数据的技术,主要通过HttpSession对象保存一些和客户端相关的信息 cookie和session配合记录…

奇异递归模板模式应用3-克隆对象

需求&#xff1a;希望某些类提供拷贝自身对象的功能&#xff0c;实现如下 template <typename T> class A { public:T *clone() {return new T(static_cast<T &>(*this));}private:friend T;A() default; };class B : public A<B> { public:B(int valu…

基于Java (spring-boot)和微信小程序的奶茶点餐小程序

一、项目介绍 基于Java (spring-boot)和微信小程序的奶茶点餐小程序功能&#xff1a;客户端登录、个人中心、点餐、选规格、去结算、取餐、我的信息、管理员登录、管理员首页、用户管理、商品管理、商品编辑、商品种类、订单管理、订单处理、等等等。 适用人群&#xff1a;适合…

全网首发 vsol光猫v2802rh光猫配置及IPTV组播教程

写在前面&#xff0c;首先感谢恩山的前辈们&#xff01;在农村老家没有10GPON但是GPON线路可以完成最高2.5G带宽&#xff0c;因此在重庆联通的基础上&#xff0c;配合V2802RH出这个教程&#xff08;图片都是一样我直接借用网上展示一下光猫后台&#xff09;。 提前准备一个VSO…

Unity 2D Spine 外发光实现思路

Unity 2D Spine 外发光实现思路 前言 对于3D骨骼&#xff0c;要做外发光可以之间通过向法线方向延申来实现。 但是对于2D骨骼&#xff0c;各顶点的法线没有向3D骨骼那样拥有垂直于面的特性&#xff0c;那我们如何做2D骨骼的外发光效果呢&#xff1f; 理论基础 我们要知道&a…