龙年快乐,大烟花

烟花秀:
在这里插入图片描述
源码:

<!DOCTYPE html>
<html lang="en" >
<head>
    <meta charset="UTF-8">
    <title>2024新年快乐!万事如意!</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="theme-color" content="#000000">
    <link rel="shortcut icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <link rel="icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <link rel="apple-touch-icon-precomposed" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <meta name="msapplication-TileColor" content="#000000">
    <meta name="msapplication-TileImage" content="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
    <link href="https://fonts.googleapis.com/css?family=Russo+One" rel="stylesheet"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
    <link rel="stylesheet" href="./style.css">
    <style>
        * {
            position: relative;
            box-sizing: border-box;
        }

        html,
        body {
            height: 100%;
        }

        html {
            background-color: #000;
        }

        body {
            overflow: hidden;
            color: rgba(255, 255, 255, 0.5);
            font-family: "Russo One", arial, sans-serif;
            line-height: 1.25;
            letter-spacing: 0.06em;
        }

        .hide {
            opacity: 0;
            visibility: hidden;
        }

        .remove {
            display: none;
        }

        .blur {
            filter: blur(12px);
        }

        .container {
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        #loading-init {
            width: 100%;
            align-self: center;
            text-align: center;
            font-size: 2em;
        }

        #stage-container {
            overflow: hidden;
            box-sizing: initial;
            border: 1px solid #222;
            margin: -1px;
        }

        #canvas-container {
            width: 100%;
            height: 100%;
            transition: filter 0.3s;
        }
        #canvas-container canvas {
            position: absolute;
            mix-blend-mode: lighten;
        }

        #controls {
            position: absolute;
            top: 0;
            width: 100%;
            padding-bottom: 50px;
            display: flex;
            justify-content: space-between;
            transition: opacity 0.3s, visibility 0.3s;
        }
        @media (min-width: 800px) {
            #controls {
                visibility: visible;
            }
            #controls.hide:hover {
                opacity: 1;
            }
        }

        #menu {
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            position: absolute;
            top: 0;
            bottom: 0;
            width: 100%;
            background-color: rgba(0, 0, 0, 0.42);
            transition: opacity 0.3s, visibility 0.3s;
        }
        #menu__header {
            padding: 20px 0 44px;
            font-size: 2em;
            text-transform: uppercase;
        }
        #menu form {
            width: 240px;
            padding: 0 20px;
            overflow: auto;
        }
        #menu .form-option {
            margin: 20px 0;
        }
        #menu .form-option label {
            text-transform: uppercase;
        }
        #menu .form-option--select label {
            display: block;
            margin-bottom: 6px;
        }
        #menu .form-option--select select {
            display: block;
            width: 100%;
            height: 30px;
            font-size: 1rem;
            font-family: "Russo One", arial, sans-serif;
            color: rgba(255, 255, 255, 0.5);
            letter-spacing: 0.06em;
            background-color: transparent;
            border: 1px solid rgba(255, 255, 255, 0.5);
        }
        #menu .form-option--select select option {
            background-color: black;
        }
        #menu .form-option--checkbox label {
            display: flex;
            align-items: center;
            transition: opacity 0.3s;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }
        #menu .form-option--checkbox input {
            display: block;
            width: 20px;
            height: 20px;
            margin-right: 8px;
            opacity: 0.5;
        }
        @media (max-width: 800px) {
            #menu .form-option select, #menu .form-option input {
                outline: none;
            }
        }

        #close-menu-btn {
            position: absolute;
            top: 0;
            right: 0;
        }

        .btn {
            opacity: 0.16;
            width: 44px;
            height: 44px;
            display: flex;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
            cursor: default;
            transition: opacity 0.3s;
        }
        .btn--bright {
            opacity: 0.5;
        }
        @media (min-width: 800px) {
            .btn:hover {
                opacity: 0.32;
            }
            .btn--bright:hover {
                opacity: 0.75;
            }
        }
        .btn svg {
            display: block;
            margin: auto;
        }
    </style>
</head>
<body>
<!-- partial:index.partial.html -->
<!-- SVG Spritesheet -->
<div style="height: 0; width: 0; position: absolute; visibility: hidden;">
    <svg xmlns="http://www.w3.org/2000/svg">
        <symbol id="icon-play" viewBox="0 0 24 24">
            <path d="M8 5v14l11-7z"/>
        </symbol>
        <symbol id="icon-pause" viewBox="0 0 24 24">
            <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
        </symbol>
        <symbol id="icon-close" viewBox="0 0 24 24">
            <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
        </symbol>
        <symbol id="icon-settings" viewBox="0 0 24 24">
            <path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/>
        </symbol>
        <symbol id="icon-shutter-fast" viewBox="0 0 24 24">
            <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
        </symbol>
        <symbol id="icon-shutter-slow" viewBox="0 0 24 24">
            <path d="M1 5h2v14H1zm4 0h2v14H5zm17 0H10c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1zM11 17l2.5-3.15L15.29 16l2.5-3.22L21 17H11z"/>
        </symbol>
    </svg>
</div>

<!-- App -->
<div class="container">
    <div id="loading-init">Loading...</div>
    <div id="stage-container" class="remove">
        <div id="canvas-container">
            <canvas id="trails-canvas"></canvas>
            <canvas id="main-canvas"></canvas>
        </div>
        <div id="controls">
            <div id="pause-btn" class="btn">
                <svg fill="white" width="24" height="24"><use href="#icon-pause"></use></svg>
            </div>
            <div id="shutter-btn" class="btn">
                <svg fill="white" width="24" height="24"><use href="#icon-shutter-slow"></use></svg>
            </div>
            <div id="settings-btn" class="btn">
                <svg fill="white" width="24" height="24"><use href="#icon-settings"></use></svg>
            </div>
        </div>
        <div id="menu" class="hide">
            <div id="close-menu-btn" class="btn btn--bright">
                <svg fill="white" width="24" height="24"><use href="#icon-close"></use></svg>
            </div>
            <div id="menu__header">Settings</div>
            <form>
                <div class="form-option form-option--select">
                    <label>Shell Type</label>
                    <select id="shell-type"></select>
                </div>
                <div class="form-option form-option--select">
                    <label>Shell Size</label>
                    <select id="shell-size"></select>
                </div>
                <div class="form-option form-option--checkbox">
                    <label id="auto-launch-label"><input id="auto-launch" type="checkbox" /><span>Auto Fire</span></label>
                </div>
                <div class="form-option form-option--checkbox">
                    <label id="finale-mode-label"><input id="finale-mode" type="checkbox" /><span>Finale Mode</span></label>
                </div>
                <div class="form-option form-option--checkbox">
                    <label id="hide-controls-label"><input id="hide-controls" type="checkbox" /><span>Hide Controls</span></label>
                </div>
            </form>
        </div>
    </div>
</div>
<!-- partial -->
<script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/fscreen%401.0.1.js'></script>
<script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Stage%400.1.4.js'></script>
<script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/MyMath.js'></script>
<script>
    'use strict';
    console.clear();


    const IS_MOBILE = window.innerWidth <= 640;
    const IS_DESKTOP = window.innerWidth > 800;
    const IS_HEADER = IS_DESKTOP && window.innerHeight < 300;
    // 8K - can restrict this if needed
    const MAX_WIDTH = 7680;
    const MAX_HEIGHT = 4320;
    const GRAVITY = 0.9; // Acceleration in px/s
    let simSpeed = 1;

    const COLOR = {
        Red: '#ff0043',
        Green: '#14fc56',
        Blue: '#1e7fff',
        Purple: '#e60aff',
        Gold: '#ffae00',
        White: '#ffffff'
    };

    // Special invisible color (not rendered, and therefore not in COLOR map)
    const INVISIBLE = '_INVISIBLE_';


    // Interactive state management
    const store = {
        _listeners: new Set(),
        _dispatch() {
            this._listeners.forEach(listener => listener(this.state))
        },
        state: {
            paused: false,
            longExposure: false,
            menuOpen: false,
            config: {
                shell: 'Random',
                size: IS_DESKTOP && !IS_HEADER ? '3' : '1',
                autoLaunch: true,
                finale: false,
                hideControls: IS_HEADER
            }
        },
        setState(nextState) {
            this.state = Object.assign({}, this.state, nextState);
            this._dispatch();
            this.persist();
        },
        subscribe(listener) {
            this._listeners.add(listener);
            return () => this._listeners.remove(listener);
        },
// Load / persist select state to localStorage
        load() {
            if (localStorage.getItem('schemaVersion') === '1') {
                this.state.config.size = JSON.parse(localStorage.getItem('configSize'));
                this.state.config.hideControls = JSON.parse(localStorage.getItem('hideControls'));
            }
        },
        persist() {
            localStorage.setItem('schemaVersion', '1');
            localStorage.setItem('configSize', JSON.stringify(this.state.config.size));
            localStorage.setItem('hideControls', JSON.stringify(this.state.config.hideControls));
        }
    };

    if (!IS_HEADER) {
        store.load();
    }

    // Actions
    // ---------

    function togglePause(toggle) {
        if (typeof toggle === 'boolean') {
            store.setState({ paused: toggle });
        } else {
            store.setState({ paused: !store.state.paused });
        }
    }

    function toggleLongExposure(toggle) {
        if (typeof toggle === 'boolean') {
            store.setState({ longExposure: toggle });
        } else {
            store.setState({ longExposure: !store.state.longExposure });
        }
    }

    function toggleMenu(toggle) {
        if (typeof toggle === 'boolean') {
            store.setState({ menuOpen: toggle });
        } else {
            store.setState({ menuOpen: !store.state.menuOpen });
        }
    }

    function updateConfig(nextConfig) {
        nextConfig = nextConfig || getConfigFromDOM();
        store.setState({
            config: Object.assign({}, store.state.config, nextConfig)
        });
    }

    // Selectors
    // -----------

    const canInteract = () => !store.state.paused && !store.state.menuOpen;
    const shellNameSelector = () => store.state.config.shell;
    // Converts shell size to number.
    const shellSizeSelector = () => +store.state.config.size;
    const finaleSelector = () => store.state.config.finale;


    // Render app UI / keep in sync with state
    const appNodes = {
        stageContainer: '#stage-container',
        canvasContainer: '#canvas-container',
        controls: '#controls',
        menu: '#menu',
        pauseBtn: '#pause-btn',
        pauseBtnSVG: '#pause-btn use',
        shutterBtn: '#shutter-btn',
        shutterBtnSVG: '#shutter-btn use',
        shellType: '#shell-type',
        shellSize: '#shell-size',
        autoLaunch: '#auto-launch',
        autoLaunchLabel: '#auto-launch-label',
        finaleMode: '#finale-mode',
        finaleModeLabel: '#finale-mode-label',
        hideControls: '#hide-controls',
        hideControlsLabel: '#hide-controls-label'
    };

    // Convert appNodes selectors to dom nodes
    Object.keys(appNodes).forEach(key => {
        appNodes[key] = document.querySelector(appNodes[key]);
    });

    // Remove loading state
    document.getElementById('loading-init').remove();
    appNodes.stageContainer.classList.remove('remove');

    // First render is called in init()
    function renderApp(state) {
        appNodes.pauseBtnSVG.setAttribute('href', `#icon-${state.paused ? 'play' : 'pause'}`);
        appNodes.shutterBtnSVG.setAttribute('href', `#icon-shutter-${state.longExposure ? 'fast' : 'slow'}`);
        appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);
        appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);
        appNodes.menu.classList.toggle('hide', !state.menuOpen);
        appNodes.finaleModeLabel.style.opacity = state.config.autoLaunch ? 1 : 0.32;
        appNodes.shellType.value = state.config.shell;
        appNodes.shellSize.value = state.config.size;
        appNodes.autoLaunch.checked = state.config.autoLaunch;
        appNodes.finaleMode.checked = state.config.finale;
        appNodes.hideControls.checked = state.config.hideControls;
    }

    store.subscribe(renderApp);


    function getConfigFromDOM() {
        return {
            shell: appNodes.shellType.value,
            size: appNodes.shellSize.value,
            autoLaunch: appNodes.autoLaunch.checked,
            finale: appNodes.finaleMode.checked,
            hideControls: appNodes.hideControls.checked
        };
    };

    const updateConfigNoEvent = () => updateConfig();
    appNodes.shellType.addEventListener('input', updateConfigNoEvent);
    appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
    appNodes.autoLaunchLabel.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.finaleModeLabel.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.hideControlsLabel.addEventListener('click', () => setTimeout(updateConfig, 0));


    // Constant derivations
    const COLOR_NAMES = Object.keys(COLOR);
    const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]);
    // Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
    const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
    // Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
    const COLOR_TUPLES = {};
    COLOR_CODES.forEach(hex => {
        COLOR_TUPLES[hex] = {
            r: parseInt(hex.substr(1, 2), 16),
            g: parseInt(hex.substr(3, 2), 16),
            b: parseInt(hex.substr(5, 2), 16),
        };
    });

    // Get a random color.
    function randomColorSimple() {
        return COLOR_CODES[Math.random() * COLOR_CODES.length | 0];
    }

    // Get a random color, with some customization options available.
    let lastColor;
    function randomColor(options) {
        const notSame = options && options.notSame;
        const notColor = options && options.notColor;
        const limitWhite = options && options.limitWhite;
        let color = randomColorSimple();
// limit the amount of white chosen randomly
        if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
            color = randomColorSimple();
        }
        if (notSame) {
            while (color === lastColor) {
                color = randomColorSimple();
            }
        }
        else if (notColor) {
            while (color === notColor) {
                color = randomColorSimple();
            }
        }
        lastColor = color;
        return color;
    }

    function whiteOrGold() {
        return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
    }

    const PI_2 = Math.PI * 2;
    const PI_HALF = Math.PI * 0.5;

    const trailsStage = new Stage('trails-canvas');
    const mainStage = new Stage('main-canvas');
    const stages = [
        trailsStage,
        mainStage
    ];

    // Fill trails canvas with black to start.
    trailsStage.ctx.fillStyle = '#000';
    trailsStage.ctx.fillRect(0, 0, trailsStage.width, trailsStage.height);


    // Fullscreen helpers, using Fscreen for prefixes
    function requestFullscreen() {
        if (fullscreenEnabled() && !isFullscreen()) {
            fscreen.requestFullscreen(document.documentElement);
        }
    }

    function fullscreenEnabled() {
        return fscreen.fullscreenEnabled;
    }

    function isFullscreen() {
        return !!fscreen.fullscreenElement;
    }


    // Shell helpers
    function makePistilColor(shellColor) {
        return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({ notColor: shellColor }) : whiteOrGold();
    }

    // Unique shell types
    const crysanthemumShell = (size=1) => {
        const glitter = Math.random() < 0.25;
        const singleColor = Math.random() < 0.68;
        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>
  • 7
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
以下是一个基于Python和pygame实现的龙年烟花秀的示例代码: ```python import pygame import random # 初始化pygame pygame.init() # 设置窗口大小和标题 screen_width = 800 screen_height = 600 screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("龙年烟花秀") # 加载背景图片 background_image = pygame.image.load("background.jpg") background_rect = background_image.get_rect() # 加载烟花图片 firework_image = pygame.image.load("firework.png") firework_rect = firework_image.get_rect() # 加载背景音乐 pygame.mixer.music.load("background_music.mp3") pygame.mixer.music.play(-1) # -1表示循环播放 # 设置烟花数量 num_fireworks = 10 # 烟花类 class Firework(pygame.sprite.Sprite): def __init__(self): pygame.sprite.Sprite.__init__(self) self.image = firework_image self.rect = self.image.get_rect() self.rect.x = random.randint(0, screen_width - self.rect.width) self.rect.y = screen_height def update(self): self.rect.y -= 2 # 控制烟花上升的速度 if self.rect.y < -self.rect.height: self.rect.x = random.randint(0, screen_width - self.rect.width) self.rect.y = screen_height # 创建烟花精灵组 fireworks = pygame.sprite.Group() for i in range(num_fireworks): firework = Firework() fireworks.add(firework) # 游戏主循环 running = True clock = pygame.time.Clock() while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 绘制背景 screen.blit(background_image, background_rect) # 更新和绘制烟花 fireworks.update() fireworks.draw(screen) # 刷新屏幕 pygame.display.flip() # 控制帧率 clock.tick(60) # 退出游戏 pygame.quit() ``` 请确保在运行代码之前,将背景图片、烟花图片和背景音乐文件放在与代码文件相同的目录下,并将它们的文件名分别替换为"background.jpg"、"firework.png"和"background_music.mp3"。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

timi先生

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值