Download dssl-vscode/lib/engine/aura.js from EHS-Studio/dssl: direct link, hf CLI and curl.
- Browser
- Download file 69.3 kB
-
https://huggingface.co/EHS-Studio/dssl/resolve/main/dssl-vscode/lib/engine/aura.js
- Command line
-
hf download hf://EHS-Studio/dssl/dssl-vscode/lib/engine/aura.js
-
curl -L -o aura.js https://huggingface.co/EHS-Studio/dssl/resolve/main/dssl-vscode/lib/engine/aura.js
69.3 kB
| /* ========================================================================= | |
| * DSSL: aura.js | |
| * | |
| * .orb { aura-glow: color.accent 28 pulse 1600 } | |
| * .orb { aura-particle: #7cf -> transparent count 40 dir up from bottom } | |
| * .orb { aura: glow #ff7a00 30 flicker 0.3, particles #fc4 -> #f30 count 60 shape flame glowing } | |
| * | |
| * An aura is light and particles around an element. It has NO presets: fire, | |
| * an energy aura, snow and sparks are all just parameters, and you name your | |
| * own with a `behavior` block. What you get is the set of knobs. | |
| * | |
| * Two kinds of layer: | |
| * glow a soft light around the element (color, radius, pulse, flicker) | |
| * particles things that leave the element (count, size, life, speed, dir ...) | |
| * | |
| * Properties (all of them end up in the same two slots, so they stack with the cascade): | |
| * aura: glow and/or particles, comma separated. Replaces both slots. | |
| * aura-glow: the glow slot | |
| * aura-particle(s): the particles slot | |
| * glow-particle(s): the particles slot, every particle glowing | |
| * aura-glow-particle(s): both slots at once; settings are shared | |
| * | |
| * This file has two halves. The top is PURE (parseAura, no DOM) and runs in the | |
| * compiler. The bottom is the canvas RUNTIME (AuraManager) that the engine drives. | |
| * ========================================================================= */ | |
| import { parseColor, toRgba, withAlpha } from './color.js'; | |
| import { parseTexture, makeMask, splitWords, rng } from './texture.js'; | |
| /* ------------------------------------------------------------------ * | |
| * vocabulary | |
| * ------------------------------------------------------------------ */ | |
| export const FROM_WORDS = ['edge', 'outline', 'bottom', 'top', 'left', 'right', 'center', 'corners', 'thumb']; | |
| export const SHAPES = ['circle', 'spark', 'square', 'ring', 'star', 'flame', 'smoke', 'line', | |
| 'heart', 'diamond', 'triangle', 'plus', 'drop', 'bubble', 'snowflake', 'confetti', 'leaf']; // 0.16.0: the last nine | |
| /* ---- shapes an aura can follow (0.16.0) ---- */ | |
| const TWO_PI = Math.PI * 2; | |
| const ngon = (n, rot = -Math.PI / 2) => Array.from({ length: n }, (_, i) => { const a = rot + (i / n) * TWO_PI; return [0.5 + 0.5 * Math.cos(a), 0.5 + 0.5 * Math.sin(a)]; }); | |
| const starOf = (n, inner) => Array.from({ length: n * 2 }, (_, i) => { const a = -Math.PI / 2 + (i / (n * 2)) * TWO_PI, r = i % 2 ? inner : 1; return [0.5 + 0.5 * r * Math.cos(a), 0.5 + 0.5 * r * Math.sin(a)]; }); | |
| const heartOf = () => { | |
| const pts = []; | |
| for (let i = 0; i < 48; i++) { const t = (i / 48) * TWO_PI; pts.push([16 * Math.pow(Math.sin(t), 3), -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t))]); } | |
| const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]); | |
| const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys); | |
| return pts.map(([x, y]) => [(x - x0) / (x1 - x0), (y - y0) / (y1 - y0)]); | |
| }; | |
| const dropOf = () => { | |
| const pts = [[0.5, 0]]; | |
| for (let deg = -32.6; deg <= 212.6; deg += 8) { const a = (deg * Math.PI) / 180; pts.push([0.5 + 0.35 * Math.cos(a), 0.65 + 0.35 * Math.sin(a)]); } | |
| return pts; | |
| }; | |
| /** the polygons, as fractions of the element's box (0..1, y down) */ | |
| const UNIT_SHAPES = { | |
| diamond: [[0.5, 0], [1, 0.5], [0.5, 1], [0, 0.5]], | |
| triangle: [[0.5, 0], [1, 1], [0, 1]], | |
| hexagon: ngon(6, 0), | |
| octagon: ngon(8, Math.PI / 8), | |
| pentagon: ngon(5), | |
| star: starOf(5, 0.4), | |
| star4: starOf(4, 0.36), | |
| star6: starOf(6, 0.5), | |
| cross: [[0.33, 0], [0.67, 0], [0.67, 0.33], [1, 0.33], [1, 0.67], [0.67, 0.67], [0.67, 1], [0.33, 1], [0.33, 0.67], [0, 0.67], [0, 0.33], [0.33, 0.33]], | |
| heart: heartOf(), | |
| drop: dropOf(), | |
| bolt: [[0.58, 0], [0.18, 0.56], [0.46, 0.56], [0.34, 1], [0.82, 0.38], [0.54, 0.38], [0.72, 0]], | |
| }; | |
| /** the names `around` takes (polygon(...) is the other form) */ | |
| export const AROUND_NAMES = ['follow', 'box', 'round', 'pill', 'alpha', ...Object.keys(UNIT_SHAPES)]; | |
| /** `around` in its parsed form to a polygon, as [x, y] fractions of the box, or null for a shape that is not a polygon (box, round, pill, follow, alpha) */ | |
| export function aroundPoints(spec) { | |
| if (!spec) return null; | |
| if (spec.name === 'polygon') return spec.pts; | |
| return UNIT_SHAPES[spec.name] || null; | |
| } | |
| /** `around` words to {name} or {name:'polygon', pts}; null with a message when it is neither */ | |
| export function parseAround(word, hooks) { | |
| const w = String(word || '').trim(); | |
| if (AROUND_NAMES.includes(w)) return { name: w }; | |
| const m = /^polygon\((.*)\)$/s.exec(w); | |
| if (!m) { hooks.error(`unknown around "${w}"`, hooks.suggest(w, AROUND_NAMES) || `use one of: ${AROUND_NAMES.join(', ')}, or polygon(50% 0, 100% 50%, 50% 100%, 0 50%)`); return null; } | |
| const pts = []; | |
| for (const part of m[1].split(',')) { | |
| const t = part.trim().split(/\s+/); | |
| const v = t.map((x) => (/^-?[\d.]+%$/.test(x) ? parseFloat(x) / 100 : /^-?[\d.]+$/.test(x) ? parseFloat(x) / 100 : NaN)); | |
| if (t.length !== 2 || v.some((n) => !Number.isFinite(n))) { hooks.error(`polygon needs "x y" pairs in percent, got "${part.trim()}"`, 'around polygon(50% 0, 100% 50%, 50% 100%, 0 50%)'); return null; } | |
| pts.push(v); | |
| } | |
| if (pts.length < 3) { hooks.error('a polygon needs at least 3 points', 'around polygon(50% 0, 100% 100%, 0 100%)'); return null; } | |
| return { name: 'polygon', pts }; | |
| } | |
| export const DIR_WORDS = { up: -90, down: 90, left: 180, right: 0, out: 'out', in: 'in' }; | |
| export const AURA_BLENDS = ['add', 'normal']; | |
| const num = (min, max, extra = {}) => ({ kind: 'number', words: 1, min, max, ...extra }); | |
| export const GLOW_KEYS = { | |
| color: { kind: 'color', words: 1, doc: 'the light colour (default: the element\'s text colour)' }, | |
| radius: num(1, 400, { doc: 'how far the glow reaches, in px' }), | |
| intensity: num(0, 4, { doc: 'brightness, 1 is normal' }), | |
| pulse: num(0, 60000, { doc: 'milliseconds for one breath in and out; 0 is steady' }), | |
| depth: num(0, 1, { doc: 'how deep the pulse goes, 0 to 1' }), | |
| flicker: num(0, 1, { doc: 'random flicker, like a flame: 0 is steady, 1 is wild' }), | |
| seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }), | |
| texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' }, | |
| essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' }, | |
| unclipped: { kind: 'flag', words: 0, doc: 'let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself' }, | |
| around: { kind: 'around', words: 1, doc: 'the shape the light follows: follow (the default: the element\'s clip-path or rounded corners), box, round, pill, diamond, triangle, hexagon, octagon, pentagon, star, star4, star6, cross, heart, drop, bolt, alpha (an <img> or <canvas>\'s visible pixels), or polygon(50% 0, 100% 50%, 50% 100%, 0 50%)' }, | |
| inner: { kind: 'flag', words: 0, doc: 'light inside the edge (a rim) instead of outside it' }, | |
| rays: num(1, 64, { int: true, doc: 'that many beams of light from the middle, slowly turning (see spin)' }), | |
| spin: num(-3600, 3600, { doc: 'rays turn this many degrees a second (default 10); on particles, how fast each one tumbles' }), | |
| ripple: num(200, 60000, { doc: 'rings that spread out from the shape and fade, one every this many milliseconds' }), | |
| nohalo: { kind: 'flag', words: 0, doc: 'only the rays and ripples, without the soft halo' }, | |
| always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' }, | |
| z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer this light is drawn on: `z 5` puts it under a hill with z-index 10. It meets elements in the page\'s root stacking context' }), | |
| }; | |
| export const PARTICLE_KEYS = { | |
| color: { kind: 'colors', words: 1, doc: 'colours over a particle\'s life: color #ff0 -> #f60 -> transparent' }, | |
| count: num(1, 600, { int: true, doc: 'how many particles are alive at once' }), | |
| size: { kind: 'range', words: 2, min: 0.5, max: 300, doc: 'diameter in px: size 4, or size 10 4 (start and end of a random range)' }, | |
| life: { kind: 'range', words: 2, min: 50, max: 60000, doc: 'how long one lives, in ms: life 900, or life 600 1200' }, | |
| speed: { kind: 'range', words: 2, min: 0, max: 3000, doc: 'px per second: speed 60, or speed 30 90' }, | |
| dir: { kind: 'dir', words: 1, doc: 'where they head: up, down, left, right, out, in, or degrees (-90 is up)' }, | |
| spread: num(0, 360, { doc: 'how wide the fan of directions is, in degrees' }), | |
| gravity: num(-2000, 2000, { doc: 'px/s squared; negative pulls up (flames), positive down (snow)' }), | |
| drag: num(0, 10, { doc: 'air resistance: how fast particles slow down' }), | |
| turbulence: num(0, 1000, { doc: 'random sideways wobble, px/s' }), | |
| from: { kind: 'word', words: 1, values: FROM_WORDS, doc: 'where they are born: edge or outline (along the element\'s real outline, see around), bottom, top, left, right, center, corners, thumb (range sliders)' }, | |
| shape: { kind: 'word', words: 1, values: SHAPES, doc: 'circle, spark, square, ring, star, flame, smoke, line, heart, diamond, triangle, plus, drop, bubble, snowflake, confetti or leaf' }, | |
| grow: num(0, 10, { doc: 'size at the end of life as a multiple of the start: 0.2 shrinks, 3 swells' }), | |
| blend: { kind: 'word', words: 1, values: AURA_BLENDS, doc: 'add makes overlapping particles brighter, normal just stacks them' }, | |
| glowing: { kind: 'flag', words: 0, doc: 'soft glowing particles that add light' }, | |
| behind: { kind: 'flag', words: 0, doc: 'draw only outside the element\'s box, as if the element hid them' }, | |
| seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }), | |
| texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' }, | |
| essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' }, | |
| unclipped: { kind: 'flag', words: 0, doc: 'let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge' }, | |
| around: { kind: 'around', words: 1, doc: 'the shape `from edge` and `from outline` follow: follow (the default), box, round, pill, diamond, triangle, hexagon, octagon, pentagon, star, star4, star6, cross, heart, drop, bolt, or polygon(...)' }, | |
| palette: { kind: 'flag', words: 0, doc: 'each particle takes ONE of the colours (confetti) instead of fading through them' }, | |
| spin: num(-3600, 3600, { doc: 'how fast each particle tumbles, in degrees a second (random direction)' }), | |
| wind: num(-3000, 3000, { doc: 'a sideways push, px/s squared; negative pushes left' }), | |
| orbit: num(-3600, 3600, { doc: 'the particles circle the middle of the element, this many degrees a second' }), | |
| always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' }, | |
| z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer these particles are drawn on: `z 5` puts them under a hill with z-index 10. It meets elements in the page\'s root stacking context' }), | |
| }; | |
| /* ------------------------------------------------------------------ * | |
| * parsing (compile time, no DOM) | |
| * ------------------------------------------------------------------ */ | |
| function splitCommas(s) { | |
| const out = []; let depth = 0, cur = ''; | |
| for (const ch of s) { | |
| if (ch === '(') depth++; | |
| if (ch === ')') depth--; | |
| if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; } | |
| cur += ch; | |
| } | |
| if (cur.trim()) out.push(cur.trim()); | |
| return out; | |
| } | |
| const isNumeric = (w) => w !== '' && !Number.isNaN(Number(w)); | |
| /** could this word be a colour? A bare unknown word (speedd) must NOT be swallowed as one, or the typo | |
| * never gets its "did you mean" */ | |
| const looksLikeColor = (w) => /^#[0-9a-f]{3,8}(\/[\d.]+%?)?$/i.test(w) || /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|var)\(/i.test(w) | |
| || /^[a-z][\w-]*(\.[\w-]+)+(\/[\d.]+%?)?$/i.test(w) || /^(transparent|currentcolor)$/i.test(w) || parseColor(w) !== null; | |
| /** | |
| * Parse one layer's words. | |
| * kind: 'glow' | 'particles' | 'both' which keys are legal | |
| * forceGlowing: glow-particle | |
| * Returns { glow?, particles? } layers, or null (an error was reported). | |
| */ | |
| function parseLayerWords(words, kind, hooks, opts = {}) { | |
| const wantGlow = kind === 'glow' || kind === 'both'; | |
| const wantParticles = kind === 'particles' || kind === 'both'; | |
| const keys = kind === 'glow' ? GLOW_KEYS : kind === 'particles' ? PARTICLE_KEYS : { ...GLOW_KEYS, ...PARTICLE_KEYS }; | |
| const g = {}, p = {}; | |
| const setBoth = (k, v) => { if (k in GLOW_KEYS && wantGlow) g[k] = v; if (k in PARTICLE_KEYS && wantParticles) p[k] = v; }; | |
| const textureOf = (inner) => parseTexture(inner, hooks); | |
| let i = 0; | |
| const err = (m, h) => { hooks.error(m, h); return null; }; | |
| // positional shorthand: aura-glow: <color> <radius> aura-particle: <colors> <count> | |
| const isKey = (w) => Object.prototype.hasOwnProperty.call(keys, w) || w in DIR_WORDS; | |
| if (words.length && !isKey(words[0]) && words[0] !== '->') { | |
| const stops = []; | |
| while (i < words.length && !isKey(words[i]) && !isNumeric(words[i]) && (words[i] === '->' || looksLikeColor(words[i]))) { | |
| if (words[i] === '->') { i++; continue; } | |
| const css = hooks.color(words[i]); | |
| if (css === null) return null; | |
| stops.push(css); | |
| i++; | |
| } | |
| if (stops.length) { | |
| if (wantGlow) g.color = stops[0]; | |
| if (wantParticles) p.colors = stops; | |
| } | |
| if (i < words.length && isNumeric(words[i])) { | |
| const n = Number(words[i]); | |
| if (wantGlow && !wantParticles) g.radius = n; | |
| else if (wantParticles && !wantGlow) p.count = n; | |
| else { g.radius = n; } | |
| i++; | |
| } | |
| } | |
| while (i < words.length) { | |
| const w = words[i]; | |
| if (w in DIR_WORDS && !(w in keys)) { if (wantParticles) p.dir = DIR_WORDS[w]; i++; continue; } | |
| // texture(grain scale 2) is one function-style word that carries its own settings | |
| const fnTex = /^texture\((.*)\)$/s.exec(w); | |
| if (fnTex && keys.texture) { | |
| i++; | |
| const t = textureOf(fnTex[1].trim()); | |
| if (t === null) return null; | |
| setBoth('texture', t); | |
| continue; | |
| } | |
| const def = keys[w]; | |
| if (!def) { | |
| const all = [...Object.keys(keys), ...Object.keys(DIR_WORDS)]; | |
| return err(`unknown aura setting "${w}"`, hooks.suggest(w, all) || `settings: ${Object.keys(keys).join(', ')}`); | |
| } | |
| i++; | |
| if (def.kind === 'flag') { | |
| if (w === 'glowing') { if (wantParticles) p.glowing = true; } | |
| else setBoth(w, true); | |
| continue; | |
| } | |
| if (def.kind === 'colors') { | |
| const stops = []; | |
| while (i < words.length && (words[i] === '->' || (!isKey(words[i]) && !isNumeric(words[i]) && looksLikeColor(words[i])))) { | |
| if (words[i] === '->') { i++; continue; } | |
| const css = hooks.color(words[i]); | |
| if (css === null) return null; | |
| stops.push(css); | |
| i++; | |
| } | |
| if (!stops.length) return err('color needs at least one colour', def.doc); | |
| if (wantGlow && 'color' in GLOW_KEYS) g.color = stops[0]; | |
| if (wantParticles) p.colors = stops; | |
| continue; | |
| } | |
| if (def.kind === 'color') { | |
| const css = hooks.color(words[i]); | |
| if (css === null) return null; | |
| i++; | |
| setBoth(w, css); | |
| if (w === 'color' && wantParticles) p.colors = [css]; | |
| continue; | |
| } | |
| if (def.kind === 'texture') { | |
| const word = words[i]; | |
| i++; | |
| const m = /^texture\((.*)\)$/s.exec(word || ''); | |
| const inner = m ? m[1] : word; | |
| if (!inner) return err('texture needs a name', def.doc); | |
| const t = textureOf(inner); | |
| if (t === null) return null; | |
| setBoth('texture', t); | |
| continue; | |
| } | |
| if (def.kind === 'around') { | |
| const v = words[i]; i++; | |
| const sp = parseAround(v, hooks); | |
| if (sp === null) return null; | |
| setBoth('around', sp); | |
| continue; | |
| } | |
| if (def.kind === 'dir') { | |
| const v = words[i]; i++; | |
| if (v in DIR_WORDS) p.dir = DIR_WORDS[v]; | |
| else if (isNumeric(v)) p.dir = Number(v); | |
| else return err(`unknown direction "${v}"`, hooks.suggest(v, Object.keys(DIR_WORDS)) || 'up, down, left, right, out, in, or degrees'); | |
| continue; | |
| } | |
| if (def.kind === 'word') { | |
| const v = words[i]; i++; | |
| if (!def.values.includes(v)) return err(`unknown ${w} "${v}"`, hooks.suggest(v, def.values) || `use one of: ${def.values.join(', ')}`); | |
| if (wantParticles) p[w] = v; | |
| continue; | |
| } | |
| if (def.kind === 'range') { | |
| const vals = []; | |
| while (i < words.length && vals.length < 2 && isNumeric(words[i])) { vals.push(Number(words[i])); i++; } | |
| if (!vals.length) return err(`${w} needs a number`, def.doc); | |
| if (vals.some((n) => n < def.min || n > def.max)) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc); | |
| if (wantParticles) p[w] = vals.length === 1 ? [vals[0], vals[0]] : [Math.min(...vals), Math.max(...vals)]; | |
| continue; | |
| } | |
| // plain number | |
| const raw = words[i]; i++; | |
| if (!isNumeric(raw)) return err(`${w} needs a number, got "${raw ?? ''}"`, def.doc); | |
| const n = Number(raw); | |
| if (n < def.min || n > def.max) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc); | |
| setBoth(w === 'turbulence' ? 'turbulence' : w, def.int ? Math.round(n) : n); | |
| } | |
| if (opts.forceGlowing && wantParticles) p.glowing = true; | |
| const out = {}; | |
| if (wantGlow && (kind === 'glow' || Object.keys(g).length || opts.requireGlow)) out.glow = { kind: 'glow', ...g }; | |
| if (wantParticles && (kind === 'particles' || Object.keys(p).length || opts.requireParticles)) out.particles = { kind: 'particles', ...p }; | |
| return out; | |
| } | |
| /** | |
| * Parse the value of one of the aura properties. `prop` decides the shape: | |
| * 'aura' layers need a leading glow / particles word | |
| * 'aura-glow' one glow layer | |
| * 'aura-particle' one particles layer | |
| * 'glow-particle' one particles layer, glowing | |
| * 'aura-glow-particle' a glow layer AND a glowing particles layer, settings shared | |
| * Returns { glow: [layers]|null, particles: [layers]|null } where null means "clear this slot", | |
| * or null when invalid. | |
| */ | |
| export function parseAura(prop, value, hooks) { | |
| const text = String(value || '').trim(); | |
| if (!text) { hooks.error(`${prop} needs a value`, 'e.g. aura-glow: color.accent 24'); return null; } | |
| const base = prop.replace(/s$/, ''); // aura-particles -> aura-particle | |
| if (text === 'none') { | |
| // `none` clears exactly the slots this property owns, so aura-glow: none leaves particles alone | |
| const clears = { 'aura': { glow: null, particles: null }, 'aura-glow': { glow: null }, 'aura-particle': { particles: null }, 'glow-particle': { particles: null }, 'aura-glow-particle': { glow: null, particles: null } }; | |
| return clears[base] || { glow: null, particles: null }; | |
| } | |
| if (prop === 'aura') { | |
| const glow = [], particles = []; | |
| for (const part of splitCommas(text)) { | |
| const words = splitWords(part); | |
| const head = words[0]; | |
| let kind; | |
| if (head === 'glow') kind = 'glow'; | |
| else if (head === 'particles' || head === 'particle') kind = 'particles'; | |
| else { | |
| hooks.error(`an aura layer starts with glow or particles, not "${head}"`, hooks.suggest(head, ['glow', 'particles']) || 'e.g. aura: glow color.accent 24, particles #fc4 count 40'); | |
| return null; | |
| } | |
| const r = parseLayerWords(words.slice(1), kind, hooks); | |
| if (!r) return null; | |
| if (r.glow) glow.push(r.glow); | |
| if (r.particles) particles.push(r.particles); | |
| } | |
| return { glow, particles }; | |
| } | |
| const out = { glow: undefined, particles: undefined }; | |
| const layersOf = (kind, opts) => { | |
| const arr = []; | |
| for (const part of splitCommas(text)) { | |
| const r = parseLayerWords(splitWords(part), kind, hooks, opts); | |
| if (!r) return null; | |
| if (r.glow) arr.push(r.glow); | |
| if (r.particles) arr.push(r.particles); | |
| } | |
| return arr; | |
| }; | |
| if (base === 'aura-glow') { const a = layersOf('glow'); if (!a) return null; out.glow = a; } | |
| else if (base === 'aura-particle') { const a = layersOf('particles'); if (!a) return null; out.particles = a; } | |
| else if (base === 'glow-particle') { const a = layersOf('particles', { forceGlowing: true }); if (!a) return null; out.particles = a; } | |
| else if (base === 'aura-glow-particle') { | |
| const glow = [], particles = []; | |
| for (const part of splitCommas(text)) { | |
| const r = parseLayerWords(splitWords(part), 'both', hooks, { forceGlowing: true, requireGlow: true, requireParticles: true }); | |
| if (!r) return null; | |
| if (r.glow) glow.push(r.glow); | |
| if (r.particles) particles.push(r.particles); | |
| } | |
| out.glow = glow; out.particles = particles; | |
| } else { hooks.error(`unknown aura property "${prop}"`, null); return null; } | |
| return out; | |
| } | |
| /* ------------------------------------------------------------------ * | |
| * turning a parsed layer into numbers the runtime can use | |
| * ------------------------------------------------------------------ */ | |
| export const GLOW_DEFAULTS = { radius: 24, intensity: 1, pulse: 0, depth: 0.35, flicker: 0, seed: 1 }; | |
| export const PARTICLE_DEFAULTS = { | |
| count: 40, size: [4, 4], life: [900, 900], speed: [40, 40], dir: -90, spread: 30, | |
| gravity: 0, drag: 0, turbulence: 0, from: 'edge', shape: 'circle', grow: 0.3, blend: null, glowing: false, seed: 1, | |
| }; | |
| /** | |
| * Resolve a parsed layer for drawing. `rc(cssColor)` returns {r,g,b,a} or null, | |
| * `fallback` is the colour used when the layer names none (the element's text colour). | |
| */ | |
| export function resolveLayer(layer, rc, fallback) { | |
| const pick = (css) => (css === undefined || /^currentcolor$/i.test(css) ? fallback : (rc(css) || fallback)); | |
| if (layer.kind === 'glow') { | |
| return { ...GLOW_DEFAULTS, ...layer, rgba: pick(layer.color), tex: layer.texture && layer.texture.length ? layer.texture : null }; | |
| } | |
| const stops = (layer.colors && layer.colors.length ? layer.colors : [undefined]).map(pick); | |
| if (stops.length === 1) stops.push(withAlpha(stops[0], 0)); // a single colour fades out | |
| const glowing = !!layer.glowing; | |
| return { | |
| ...PARTICLE_DEFAULTS, ...layer, stops, glowing, behind: !!layer.behind, | |
| blend: layer.blend || (glowing ? 'add' : 'normal'), | |
| tex: layer.texture && layer.texture.length ? layer.texture : null, | |
| }; | |
| } | |
| /* ====================================================================== * | |
| * RUNTIME. Everything below needs a browser. | |
| * ====================================================================== */ | |
| const TAU = Math.PI * 2; | |
| const lerp = (a, b, t) => a + (b - a) * t; | |
| const clamp01 = (x) => Math.max(0, Math.min(1, x)); | |
| /* ---- geometry of an element's real shape (0.16.0) ---- */ | |
| const ANGLE_UNIT = { deg: Math.PI / 180, rad: 1, turn: TAU, grad: Math.PI / 200 }; | |
| /** the 2x2 part of everything the element does to itself: rotate, scale and transform (its translation is already in its rect) */ | |
| function linearOf(cs) { | |
| let a = 1, b = 0, c = 0, d = 1, any = false; | |
| const mul = (ma, mb, mc, md) => { // cur = cur * m (m is applied to a point first) | |
| const na = a * ma + c * mb, nb = b * ma + d * mb, nc = a * mc + c * md, nd = b * mc + d * md; | |
| a = na; b = nb; c = nc; d = nd; any = true; | |
| }; | |
| const rot = cs.rotate; | |
| if (rot && rot !== 'none') { | |
| const m = /^(?:z\s+)?(-?[\d.]+)(deg|rad|turn|grad)$/.exec(rot); | |
| if (m) { const t = parseFloat(m[1]) * ANGLE_UNIT[m[2]]; mul(Math.cos(t), Math.sin(t), -Math.sin(t), Math.cos(t)); } | |
| } | |
| const sc = cs.scale; | |
| if (sc && sc !== 'none') { | |
| const m = /^(-?[\d.]+)(?:\s+(-?[\d.]+))?/.exec(sc); | |
| if (m) { const sx = parseFloat(m[1]), sy = m[2] !== undefined ? parseFloat(m[2]) : sx; mul(sx, 0, 0, sy); } | |
| } | |
| const tr = cs.transform; | |
| if (tr && tr !== 'none') { | |
| let m = /^matrix\(([^)]*)\)$/.exec(tr); | |
| if (m) { const v = m[1].split(',').map(parseFloat); if (v.length === 6 && v.every(Number.isFinite)) mul(v[0], v[1], v[2], v[3]); } | |
| else if ((m = /^matrix3d\(([^)]*)\)$/.exec(tr))) { const v = m[1].split(',').map(parseFloat); if (v.length === 16 && v.every(Number.isFinite)) mul(v[0], v[1], v[4], v[5]); } | |
| } | |
| return any ? { a, b, c, d } : null; | |
| } | |
| /** clip-path: polygon / circle / ellipse / inset to a shape in fractions of the box; null for anything else (path(), url(), calc) */ | |
| const clipCache = new Map(); | |
| function parseClip(str) { | |
| if (!str || str === 'none') return null; | |
| if (clipCache.has(str)) return clipCache.get(str); | |
| let out = null; | |
| const val = (t, size) => (/%$/.test(t) ? parseFloat(t) / 100 : /px$/.test(t) ? parseFloat(t) / size : /^-?[\d.]+$/.test(t) ? (parseFloat(t) === 0 ? 0 : NaN) : NaN); | |
| try { | |
| let m; | |
| if ((m = /^polygon\((?:(?:nonzero|evenodd)\s*,\s*)?(.*)\)$/s.exec(str))) { | |
| const pts = m[1].split(',').map((p) => p.trim().split(/\s+/)); | |
| if (pts.length >= 3 && pts.every((p) => p.length === 2)) out = { kind: 'poly', pts: pts.map((p) => ({ x: p[0], y: p[1] })) }; | |
| } else if ((m = /^(circle|ellipse)\((.*)\)$/s.exec(str))) { | |
| const at = m[2].split(/\s+at\s+/); | |
| const radii = at[0].trim().split(/\s+/).filter(Boolean); | |
| const pos = (at[1] || '50% 50%').trim().split(/\s+/); | |
| out = { kind: 'ellipse', circle: m[1] === 'circle', radii, cx: pos[0], cy: pos[1] === undefined ? '50%' : pos[1] }; | |
| } else if ((m = /^inset\(([^)]*?)(?:\s+round\s+.*)?\)$/s.exec(str))) { | |
| out = { kind: 'inset', v: m[1].trim().split(/\s+/) }; | |
| } | |
| } catch { out = null; } | |
| if (clipCache.size > 200) clipCache.clear(); | |
| clipCache.set(str, out); | |
| return out; | |
| } | |
| /** a shape in the element's own box, centred on (0, 0), sized w by h, in CSS px */ | |
| function resolveGeom(spec, clipStr, w, h, radius, srcEl) { | |
| const name = spec ? spec.name : 'follow'; | |
| const pct = (t, size) => { const v = /%$/.test(t) ? parseFloat(t) / 100 * size : /px$/.test(t) ? parseFloat(t) : /^-?[\d.]+$/.test(t) ? parseFloat(t) : NaN; return v; }; | |
| const polyFrom = (pts) => ({ kind: 'poly', pts: pts.map(([fx, fy]) => [(fx - 0.5) * w, (fy - 0.5) * h]) }); | |
| if (name === 'follow') { | |
| const cl = parseClip(clipStr); | |
| if (cl) { | |
| if (cl.kind === 'poly') { | |
| const pts = cl.pts.map((p) => [pct(p.x, w) - w / 2, pct(p.y, h) - h / 2]); | |
| if (pts.every((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]))) return { kind: 'poly', pts, sig: 'clip:' + clipStr }; | |
| } else if (cl.kind === 'ellipse') { | |
| const cx = pct(cl.cx, w) - w / 2, cy = pct(cl.cy, h) - h / 2; | |
| let rx, ry; | |
| if (cl.circle) { const ref = Math.sqrt((w * w + h * h) / 2); const r = cl.radii[0] ? (/%$/.test(cl.radii[0]) ? parseFloat(cl.radii[0]) / 100 * ref : pct(cl.radii[0], ref)) : ref / 2; rx = ry = r; } | |
| else { rx = cl.radii[0] ? pct(cl.radii[0], w) : w / 2; ry = cl.radii[1] ? pct(cl.radii[1], h) : h / 2; } | |
| if ([cx, cy, rx, ry].every(Number.isFinite)) return { kind: 'ellipse', cx, cy, rx, ry, sig: 'clip:' + clipStr }; | |
| } else if (cl.kind === 'inset') { | |
| const v = cl.v.map((t, i) => pct(t, i % 2 ? w : h)); | |
| const [t, r, b = t, l = r] = v.length === 1 ? [v[0], v[0], v[0], v[0]] : v.length === 2 ? [v[0], v[1], v[0], v[1]] : v; | |
| if ([t, r, b, l].every(Number.isFinite)) return { kind: 'poly', pts: [[-w / 2 + l, -h / 2 + t], [w / 2 - r, -h / 2 + t], [w / 2 - r, h / 2 - b], [-w / 2 + l, h / 2 - b]], sig: 'clip:' + clipStr }; | |
| } | |
| } | |
| return { kind: 'rrect', r: radius, sig: 'rr' + radius }; | |
| } | |
| if (name === 'box') return { kind: 'rrect', r: radius, sig: 'rr' + radius }; | |
| if (name === 'pill') return { kind: 'rrect', r: Math.min(w, h) / 2, sig: 'pill' }; | |
| if (name === 'round') return { kind: 'ellipse', cx: 0, cy: 0, rx: w / 2, ry: h / 2, sig: 'round' }; | |
| if (name === 'alpha') { | |
| const ok = srcEl && ((srcEl.tagName === 'IMG' && srcEl.complete && srcEl.naturalWidth > 0) || srcEl.tagName === 'CANVAS' || (srcEl.tagName === 'VIDEO' && srcEl.readyState >= 2)); | |
| if (ok) return { kind: 'alpha', src: srcEl, sig: 'alpha' + (srcEl.currentSrc || srcEl.src || '') }; | |
| return { kind: 'rrect', r: radius, sig: 'rr' + radius }; | |
| } | |
| const pts = aroundPoints(spec); | |
| if (pts) return { ...polyFrom(pts), sig: 'poly' + name + w + 'x' + h }; | |
| return { kind: 'rrect', r: radius, sig: 'rr' + radius }; | |
| } | |
| /** add a shape (centred, w by h) to the current path */ | |
| function tracePath(ctx, g, w, h) { | |
| if (g.kind === 'ellipse') { ctx.moveTo(g.cx + g.rx, g.cy); ctx.ellipse(g.cx, g.cy, Math.max(0.01, g.rx), Math.max(0.01, g.ry), 0, 0, TAU); ctx.closePath(); } | |
| else if (g.kind === 'poly') { g.pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.closePath(); } | |
| else roundRectPath(ctx, -w / 2, -h / 2, w, h, g.kind === 'rrect' ? g.r : 0); | |
| } | |
| function roundRectPath(ctx, x, y, w, h, r) { | |
| r = Math.max(0, Math.min(r, w / 2, h / 2)); | |
| ctx.moveTo(x + r, y); | |
| ctx.arcTo(x + w, y, x + w, y + h, r); | |
| ctx.arcTo(x + w, y + h, x, y + h, r); | |
| ctx.arcTo(x, y + h, x, y, r); | |
| ctx.arcTo(x, y, x + w, y, r); | |
| ctx.closePath(); | |
| } | |
| function colorAt(stops, t) { | |
| const n = stops.length - 1; | |
| if (n <= 0) return stops[0]; | |
| const f = clamp01(t) * n; | |
| const i = Math.min(n - 1, Math.floor(f)); | |
| const k = f - i; | |
| const a = stops[i], b = stops[i + 1]; | |
| return { r: lerp(a.r, b.r, k), g: lerp(a.g, b.g, k), b: lerp(a.b, b.b, k), a: lerp(a.a, b.a, k) }; | |
| } | |
| /** A tiny signature, used to tell "same settings" from "changed" without deep compares */ | |
| const sigOf = (x) => JSON.stringify(x); | |
| export class AuraManager { | |
| /** | |
| * opts.reducedMotion: a MediaQueryList (or anything with .matches) | |
| * opts.budget: the most particles alive across the whole page | |
| */ | |
| constructor(opts = {}) { | |
| this.reducedMotion = opts.reducedMotion || null; | |
| this.budget = opts.budget || 1800; | |
| this.emitters = new Map(); // element -> emitter | |
| this.canvas = null; | |
| this.ctx = null; | |
| this.dpr = 1; | |
| this.vw = 0; this.vh = 0; | |
| this.running = false; | |
| this.paused = false; | |
| this.last = 0; | |
| this.sprites = new Map(); | |
| this.masks = new Map(); | |
| this.io = null; | |
| this.stats = { particles: 0, emitters: 0, frameMs: 0, fps: 0, frames: 0 }; | |
| this._fpsT = 0; this._fpsN = 0; | |
| this._onResize = () => this.resize(); | |
| // reduced motion draws a still picture, so it only needs a new frame when something could have moved | |
| this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); }; | |
| this._poll = null; | |
| this.extra = new Map(); // z-index -> { canvas, ctx }: layers asked for with `z N` | |
| this._frame = (t) => this.frame(t); | |
| } | |
| get reduced() { return !!(this.reducedMotion && this.reducedMotion.matches); } | |
| /* ---- setup ---- */ | |
| ensureCanvas() { | |
| if (this.canvas) return; | |
| const c = document.createElement('canvas'); | |
| c.setAttribute('aria-hidden', 'true'); | |
| c.setAttribute('data-dssl-aura', ''); | |
| // 0.14.0: the layer sits above the page by default. <html data-dssl-aura-z="20"> (or DSSL.auraZ(20)) lowers it, so a menu or a | |
| // glass overlay with a higher z-index covers the light the way it covers everything else. | |
| const z = parseInt(document.documentElement.getAttribute('data-dssl-aura-z'), 10); | |
| c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${Number.isFinite(z) ? z : 2147483000};`; | |
| (document.body || document.documentElement).appendChild(c); | |
| this.canvas = c; | |
| this.ctx = c.getContext('2d'); | |
| this.resize(); | |
| window.addEventListener('resize', this._onResize, { passive: true }); | |
| window.addEventListener('scroll', this._onScroll, { passive: true, capture: true }); | |
| if (typeof IntersectionObserver !== 'undefined') { | |
| this.io = new IntersectionObserver((entries) => { | |
| for (const e of entries) { | |
| const em = this.emitters.get(e.target); | |
| if (em) { em.visible = e.isIntersecting; if (e.isIntersecting) this.start(); } | |
| } | |
| }, { rootMargin: '200px' }); | |
| } | |
| } | |
| resize() { | |
| if (!this.canvas) return; | |
| this.dpr = Math.min(2, window.devicePixelRatio || 1); | |
| this.vw = window.innerWidth; this.vh = window.innerHeight; | |
| for (const c of this.allCanvases()) { | |
| c.width = Math.max(1, Math.round(this.vw * this.dpr)); | |
| c.height = Math.max(1, Math.round(this.vh * this.dpr)); | |
| } | |
| this.dirty = true; | |
| } | |
| allCanvases() { return [this.canvas, ...[...this.extra.values()].map((e) => e.canvas)]; } | |
| /** the drawing context for a layer: the default canvas, or the one at the z-index the layer asked for (`z 5`) */ | |
| ctxFor(z) { | |
| if (z === undefined || z === null) return this.ctx; | |
| let e = this.extra.get(z); | |
| if (!e) { | |
| const c = document.createElement('canvas'); | |
| c.setAttribute('aria-hidden', 'true'); | |
| c.setAttribute('data-dssl-aura', String(z)); | |
| c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${z};`; | |
| c.width = this.canvas.width; c.height = this.canvas.height; | |
| (document.body || document.documentElement).appendChild(c); | |
| e = { canvas: c, ctx: c.getContext('2d') }; | |
| this.extra.set(z, e); | |
| } | |
| return e.ctx; | |
| } | |
| /** how visible an element really is: its opacity times every ancestor's; 0 for display: none or visibility: hidden */ | |
| effectiveOpacity(el) { | |
| try { | |
| let o = 1; | |
| if (getComputedStyle(el).visibility !== 'visible') return 0; | |
| for (let a = el; a && a.nodeType === 1; a = a.parentElement) { | |
| const cs = getComputedStyle(a); | |
| if (cs.display === 'none') return 0; | |
| o *= parseFloat(cs.opacity); | |
| if (!(o > 0.004)) return 0; | |
| } | |
| return o; | |
| } catch { return 1; } | |
| } | |
| /** what a frame would draw for this emitter, as a string: the same string twice means nothing needs redrawing */ | |
| stateKey(el, eo) { | |
| const r = el.getBoundingClientRect(); | |
| let extra = ''; | |
| try { const cs = getComputedStyle(el); extra = `${cs.transform}|${cs.rotate}|${cs.scale}|${cs.clipPath}`; } catch { /* detached */ } | |
| return `${r.left.toFixed(1)},${r.top.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)},${eo.toFixed(2)},${extra}`; | |
| } | |
| /** reduced motion: has anything moved, resized or faded since the last frame? (cheap: no drawing) */ | |
| peek() { | |
| for (const [el, em] of this.emitters) { | |
| if (!el.isConnected) return true; | |
| if (this.stateKey(el, this.effectiveOpacity(el)) !== em.key) return true; | |
| } | |
| return false; | |
| } | |
| /* ---- the engine's interface ---- */ | |
| /** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */ | |
| set(el, glow, particles) { | |
| const hasGlow = glow && glow.length, hasParts = particles && particles.length; | |
| if (!hasGlow && !hasParts) { this.remove(el); return; } | |
| this.ensureCanvas(); | |
| let em = this.emitters.get(el); | |
| if (!em) { | |
| em = { el, visible: true, rect: null, radius: 0, glow: [], layers: [], dying: false, fade: 1, sig: '', age: 0, radiusAt: -1 }; | |
| this.emitters.set(el, em); | |
| if (this.io) this.io.observe(el); | |
| } | |
| em.dying = false; | |
| const sig = sigOf([glow, particles]); | |
| if (sig === em.sig) { this.start(); return; } | |
| em.sig = sig; | |
| em.glow = (glow || []).map((g, i) => ({ cfg: g, rnd: rng((g.seed || 1) * 7919 + i), flick: 0, flickTarget: 0, t0: performance.now() })); | |
| // keep running particles when the layer list keeps its shape; otherwise start fresh | |
| const old = em.layers; | |
| em.layers = (particles || []).map((cfg, i) => { | |
| const prev = old[i]; | |
| const keep = prev && prev.parts; | |
| return { cfg, rnd: keep ? prev.rnd : rng((cfg.seed || 1) * 104729 + i), parts: keep ? prev.parts : [], acc: keep ? prev.acc : 0 }; | |
| }); | |
| this.start(); | |
| } | |
| remove(el) { | |
| const em = this.emitters.get(el); | |
| if (!em) return; | |
| em.dying = true; // stop emitting, let what is alive finish, fade the glow | |
| this.start(); | |
| } | |
| pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); } | |
| resume() { this.paused = false; this.start(); } | |
| destroy() { | |
| this.running = false; | |
| window.removeEventListener('resize', this._onResize); | |
| window.removeEventListener('scroll', this._onScroll, { capture: true }); | |
| if (this._poll) { clearTimeout(this._poll); this._poll = null; } | |
| if (this.io) this.io.disconnect(); | |
| for (const e of this.extra.values()) e.canvas.remove(); | |
| this.extra.clear(); | |
| if (this.canvas) this.canvas.remove(); | |
| this.canvas = null; this.ctx = null; this.emitters.clear(); | |
| } | |
| /* ---- loop ---- */ | |
| start() { | |
| if (this.running || this.paused || !this.canvas) return; | |
| this.running = true; | |
| this.last = 0; | |
| requestAnimationFrame(this._frame); | |
| } | |
| frame(now) { | |
| if (!this.running) return; | |
| const t0 = performance.now(); | |
| const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016; | |
| this.last = now; | |
| const reduced = this.reduced; | |
| const dpr = this.dpr; | |
| for (const c of this.allCanvases()) { | |
| const x = c.getContext('2d'); | |
| x.setTransform(1, 0, 0, 1, 0, 0); | |
| x.clearRect(0, 0, c.width, c.height); | |
| x.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| } | |
| let changed = false; | |
| // global budget: if everyone asks for more than the page can afford, everyone gets a share | |
| let want = 0; | |
| for (const em of this.emitters.values()) if (em.visible && !em.dying) for (const l of em.layers) want += l.cfg.count; | |
| const share = want > this.budget ? this.budget / want : 1; | |
| let alive = 0, anyActive = false; | |
| this._n = (this._n || 0) + 1; | |
| for (const [el, em] of this.emitters) { | |
| if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; } | |
| // read geometry once per frame for visible emitters | |
| let rect = null; | |
| if (em.visible || em.layers.some((l) => l.parts.length)) { | |
| rect = el.getBoundingClientRect(); | |
| if (rect.width === 0 && rect.height === 0) rect = null; | |
| } | |
| em.rect = rect; | |
| // 0.15.0: an emitter that cannot be seen (opacity 0 on it or any ancestor, display: none, visibility: hidden) does not emit, and | |
| // what it already let go fades with it. Looked up every third frame, or every frame in reduced motion where frames are rare. | |
| if (em.eo === undefined || reduced || this._n % 3 === 0) em.eo = this.effectiveOpacity(el); | |
| if (em.layers.some((l) => l.cfg.always) || em.glow.some((g) => g.cfg.always)) em.eo = 1; // an invisible anchor on purpose | |
| const key = this.stateKey(el, em.eo); | |
| if (key !== em.key) { em.key = key; changed = true; } | |
| // 0.14.0: a stage scaled with transform: scale() has a rect the size you SEE, so the light follows it, and so must its size | |
| // and speed. The scale is how much bigger (or smaller) the box is than the box CSS says it is. | |
| em.scale = 1; | |
| if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; em.clips = this.clipAncestors(el, em); } | |
| // 0.16.0: where the element REALLY is: its rect is the box around the turned/scaled shape, so work out the shape's own size, the way it | |
| // is turned (rotate, scale, transform) and how much a parent scales it. Glow, edge particles and the hidden-behind clip all use this. | |
| em.base = rect ? this.baseOf(el, rect) : null; | |
| if (em.base) em.scale = em.base.s; | |
| if (em.dying) em.fade = Math.max(0, em.fade - dt * 3); | |
| else em.fade = Math.min(1, em.fade + dt * 4); | |
| // 0.14.0: cut the light at the edge of any parent that clips, so a stage with overflow: hidden keeps its fire inside | |
| const clipped = rect && em.clips && em.clips.length ? this.clipRect(em.clips) : null; | |
| const open = (ctx) => { if (clipped) { ctx.save(); ctx.beginPath(); ctx.rect(clipped.x, clipped.y, clipped.w, clipped.h); ctx.clip(); } }; | |
| const close = (ctx) => { if (clipped) ctx.restore(); }; | |
| const seen = em.eo > 0.004; | |
| // glow | |
| if (rect && em.fade > 0 && (em.visible || em.dying) && seen) { | |
| for (const g of em.glow) { const ctx = this.ctxFor(g.cfg.z); open(ctx); this.drawGlow(ctx, rect, em, g, now, reduced); close(ctx); } | |
| if (em.glow.length) anyActive = true; | |
| } | |
| // particles | |
| for (const L of em.layers) { | |
| const cfg = L.cfg; | |
| const moving = !reduced || cfg.essential; | |
| L.scale = em.scale; | |
| L.eo = em.eo; | |
| if (em.base) { L.cx = em.base.cx; L.cy = em.base.cy; } | |
| if (rect && em.visible && !em.dying && moving && seen) this.emit(L, rect, em, dt, share, el); | |
| if (L.parts.length) { const ctx = this.ctxFor(cfg.z); open(ctx); this.stepAndDraw(ctx, L, dt, moving, cfg.behind && em.base ? { base: em.base, geom: this.geomFor(em, cfg) } : null); close(ctx); } | |
| alive += L.parts.length; | |
| if (L.parts.length || (rect && em.visible && !em.dying && moving && seen)) anyActive = true; | |
| } | |
| if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) { | |
| this.emitters.delete(el); | |
| if (this.io) this.io.unobserve(el); | |
| } | |
| } | |
| this.stats.particles = alive; | |
| this.stats.emitters = this.emitters.size; | |
| this.stats.frameMs = performance.now() - t0; | |
| this.stats.frames++; | |
| this._fpsN++; | |
| if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; } | |
| if (this.emitters.size === 0) { this.running = false; for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); return; } | |
| // Normally the loop runs every frame (the glow follows the element, particles move). With reduced | |
| // motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a | |
| // style change, and a quick look (peek) for anything that moved. A frame that saw something move, resize or fade asks for the next one, | |
| // so a glow on a thing that moves under reduced motion follows it every frame instead of lagging. Essential layers and fading-out emitters still loop. | |
| let needLoop = !reduced; | |
| if (reduced) { | |
| if (changed) needLoop = true; | |
| for (const em of this.emitters.values()) { | |
| if (em.dying) needLoop = true; | |
| if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true; | |
| } | |
| } | |
| if (needLoop) { requestAnimationFrame(this._frame); return; } | |
| this.running = false; | |
| this.watchStill(); | |
| } | |
| /** reduced motion, nothing moving: look every 100ms (a few rect reads, no drawing) and draw again the moment something changed */ | |
| watchStill() { | |
| if (this._poll || this.paused) return; | |
| this._poll = setTimeout(() => { | |
| this._poll = null; | |
| if (this.running || !this.canvas) return; | |
| if (this.peek()) this.start(); else this.watchStill(); | |
| }, 100); | |
| } | |
| /** the ancestors that clip their children (overflow other than visible), unless this emitter said `unclipped` */ | |
| clipAncestors(el, em) { | |
| if (em.layers.some((l) => l.cfg.unclipped) || em.glow.some((g) => g.cfg.unclipped)) return []; | |
| const out = []; | |
| try { | |
| for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) { | |
| const cs = getComputedStyle(a); | |
| if (/(hidden|clip|auto|scroll)/.test(cs.overflowX + ' ' + cs.overflowY)) out.push(a); | |
| } | |
| } catch { /* detached */ } | |
| return out; | |
| } | |
| /** the visible box: the padding boxes of every clipping ancestor, intersected. Null when nothing clips. */ | |
| clipRect(list) { | |
| let x0 = -1e9, y0 = -1e9, x1 = 1e9, y1 = 1e9; | |
| for (const a of list) { | |
| const r = a.getBoundingClientRect(); | |
| const sx = a.offsetWidth ? r.width / a.offsetWidth : 1, sy = a.offsetHeight ? r.height / a.offsetHeight : 1; | |
| x0 = Math.max(x0, r.left + a.clientLeft * sx); y0 = Math.max(y0, r.top + a.clientTop * sy); | |
| x1 = Math.min(x1, r.left + (a.clientLeft + a.clientWidth) * sx); y1 = Math.min(y1, r.top + (a.clientTop + a.clientHeight) * sy); | |
| } | |
| return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) }; | |
| } | |
| cornerRadius(el, rect) { | |
| try { | |
| const raw = getComputedStyle(el).borderTopLeftRadius; | |
| const w = el.offsetWidth || (rect ? rect.width : 0), h = el.offsetHeight || (rect ? rect.height : 0); | |
| // a percentage (border-radius: 50%) is a share of the box, not that many px | |
| const v = /%$/.test(raw) ? (parseFloat(raw) / 100) * Math.min(w, h) : parseFloat(raw) || 0; | |
| return Math.min(v, Math.min(w, h) / 2); | |
| } catch { return 0; } | |
| } | |
| /** the element's own box, centre and the way it is turned, scaled and clipped, read from the page */ | |
| baseOf(el, rect) { | |
| let L = null, clip = 'none'; | |
| try { const cs = getComputedStyle(el); L = linearOf(cs); clip = cs.clipPath; } catch { /* detached */ } | |
| let w = el.offsetWidth, h = el.offsetHeight; | |
| let a = 1, b = 0, c = 0, d = 1; | |
| if (!w || !h) { w = rect.width; h = rect.height; L = null; } | |
| if (L) ({ a, b, c, d } = L); | |
| const ew = Math.abs(a) * w + Math.abs(c) * h; | |
| const s = ew > 0 ? Math.max(0.05, Math.min(20, rect.width / ew)) : 1; | |
| return { cx: rect.left + rect.width / 2, cy: rect.top + rect.height / 2, w, h, a, b, c, d, s, clip, turned: !!L }; | |
| } | |
| /** the shape a layer follows (its `around`, else the element's clip-path or rounded corners), cached while nothing about it changes */ | |
| geomFor(em, cfg) { | |
| const base = em.base; | |
| const key = `${cfg.around ? cfg.around.name + (cfg.around.pts ? cfg.around.pts.length : '') : 'follow'}|${base.w}x${base.h}|${base.clip}|${em.radius}`; | |
| const asks = cfg.around && cfg.around.name === 'alpha'; // an image can finish loading at any moment, so look again every frame | |
| if (!asks && cfg._gc && cfg._gc.key === key) return cfg._gc.geom; | |
| const geom = resolveGeom(cfg.around, base.clip, base.w, base.h, em.radius, em.el); | |
| cfg._gc = { key, geom }; | |
| return geom; | |
| } | |
| /** put the shape into the current path of ctx, in page coordinates, whatever transform ctx has now */ | |
| addShape(ctx, base, geom) { | |
| const m = ctx.getTransform(); | |
| ctx.transform(base.a * base.s, base.b * base.s, base.c * base.s, base.d * base.s, base.cx, base.cy); | |
| tracePath(ctx, geom, base.w, base.h); | |
| ctx.setTransform(m); | |
| } | |
| /** the outline of the shape as a closed polyline in page coordinates, with the length of each side, for particles born "from outline" */ | |
| outlineOf(em, cfg) { | |
| const base = em.base; | |
| if (!base) return null; | |
| const geom = this.geomFor(em, cfg); | |
| if (geom._olN === this._n && geom._olBase === base) return geom._ol; | |
| let local; | |
| if (geom.kind === 'poly') local = geom.pts; | |
| else if (geom.kind === 'ellipse') { local = []; for (let i = 0; i < 48; i++) { const t = (i / 48) * TAU; local.push([geom.cx + geom.rx * Math.cos(t), geom.cy + geom.ry * Math.sin(t)]); } } | |
| else { const hw = base.w / 2, hh = base.h / 2; local = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; } | |
| const pts = local.map(([x, y]) => ({ x: base.cx + base.s * (base.a * x + base.c * y), y: base.cy + base.s * (base.b * x + base.d * y) })); | |
| const lens = []; let total = 0; | |
| for (let i = 0; i < pts.length; i++) { const q = pts[(i + 1) % pts.length]; const l = Math.hypot(q.x - pts[i].x, q.y - pts[i].y); lens.push(l); total += l; } | |
| geom._ol = { pts, lens, total }; geom._olN = this._n; geom._olBase = base; | |
| return geom._ol; | |
| } | |
| /* ---- glow ---- */ | |
| drawGlow(ctx, rect, em, g, now, reduced) { | |
| const cfg = g.cfg; | |
| const base = em.base; | |
| if (!base) return; | |
| const moving = !reduced || cfg.essential; | |
| let a = cfg.intensity * em.fade * (em.eo === undefined ? 1 : em.eo); | |
| if (moving && cfg.pulse > 0) { | |
| const ph = ((now - g.t0) / cfg.pulse) * TAU; | |
| a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ; | |
| } | |
| if (moving && cfg.flicker > 0) { | |
| // smooth random flicker: ease toward a new random target a few times a second | |
| if (Math.random() < 0.18) g.flickTarget = g.rnd(); | |
| g.flick += (g.flickTarget - g.flick) * 0.35; | |
| a *= 1 - cfg.flicker * g.flick * 0.9; | |
| } | |
| if (a <= 0.004) return; | |
| const c = cfg.rgba; | |
| const s = base.s; | |
| const geom = this.geomFor(em, cfg); | |
| // 0.16.0: beams and ripples, drawn straight onto the canvas (they move, so there is nothing to cache) | |
| if (cfg.rays) this.drawRays(ctx, em, geom, cfg, a, now, moving); | |
| if (cfg.ripple) this.drawRipple(ctx, em, geom, cfg, a, now, moving); | |
| if (cfg.nohalo && (cfg.rays || cfg.ripple)) return; | |
| const R = cfg.radius * s; | |
| const dpr = this.dpr; | |
| const pad = Math.ceil(R * 1.6); | |
| const lw = Math.ceil(rect.width + pad * 2), lh = Math.ceil(rect.height + pad * 2); | |
| if (lw * lh * dpr * dpr > 6e6) return; // a glow bitmap bigger than this would cost more than it is worth | |
| // The blur is the expensive part, and pulsing and flickering only change how BRIGHT the shape is, not | |
| // what it looks like. So the shape is painted once into this glow's own bitmap, and each frame just | |
| // draws that bitmap at the current alpha. It repaints only when the shape really changes. | |
| const m = [base.a, base.b, base.c, base.d].map((n) => n.toFixed(3)).join(','); | |
| const live = geom.kind === 'alpha' && geom.src && geom.src.tagName !== 'IMG' ? Math.floor(now / 120) : ''; // a canvas or video changes under us | |
| const key = `${lw}x${lh}|${dpr}|${c.r | 0},${c.g | 0},${c.b | 0},${c.a.toFixed(2)}|${R.toFixed(1)}|${cfg.inner ? 1 : 0}|${geom.sig}|${m}|${base.w}x${base.h}|${s.toFixed(3)}|${cfg.tex ? sigOf(cfg.tex[0]) : ''}|${live}`; | |
| if (!g.bitmap || g.bitmapKey !== key) { | |
| if (!g.bitmap) g.bitmap = document.createElement('canvas'); | |
| const off = g.bitmap; | |
| off.width = Math.ceil(lw * dpr); off.height = Math.ceil(lh * dpr); | |
| const o = off.getContext('2d'); | |
| const SH = 10000; // draw the shape far off-canvas and let only its blurred shadow land here | |
| // the shape in bitmap pixels: centred, turned and scaled like the element; shiftDev slides it sideways in device pixels | |
| const put = (shiftDev) => { | |
| o.setTransform(dpr * base.a * s, dpr * base.b * s, dpr * base.c * s, dpr * base.d * s, dpr * (lw / 2) + shiftDev, dpr * (lh / 2)); | |
| if (geom.kind === 'alpha') { try { o.drawImage(geom.src, -base.w / 2, -base.h / 2, base.w, base.h); } catch { /* an image that cannot be drawn */ } } | |
| else tracePath(o, geom, base.w, base.h); | |
| o.setTransform(1, 0, 0, 1, 0, 0); | |
| }; | |
| const addPath = () => { o.setTransform(dpr * base.a * s, dpr * base.b * s, dpr * base.c * s, dpr * base.d * s, dpr * (lw / 2), dpr * (lh / 2)); tracePath(o, geom.kind === 'alpha' ? { kind: 'rrect', r: 0 } : geom, base.w, base.h); o.setTransform(1, 0, 0, 1, 0, 0); }; | |
| const shadowOf = (shiftDev, blur, color) => { | |
| o.shadowOffsetX = SH * dpr; o.shadowBlur = blur * dpr; o.shadowColor = color; | |
| if (geom.kind === 'alpha') put(-SH * dpr); | |
| else { o.beginPath(); put(-SH * dpr); o.fillStyle = '#000'; o.fill(); } | |
| }; | |
| o.save(); | |
| if (cfg.inner) { | |
| // light just inside the edge: fill the shape with the colour, then wipe out its blurred core | |
| if (geom.kind === 'alpha') { o.fillStyle = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a)})`; o.fillRect(0, 0, off.width, off.height); o.globalCompositeOperation = 'destination-in'; o.shadowColor = 'transparent'; put(0); } | |
| else { | |
| o.beginPath(); addPath(); o.clip(); | |
| o.fillStyle = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a)})`; | |
| o.fillRect(0, 0, off.width, off.height); | |
| } | |
| o.globalCompositeOperation = 'destination-out'; | |
| shadowOf(0, R * 0.9, 'rgba(0,0,0,1)'); | |
| } else { | |
| // outside: the shape itself is cut out of the clip, so the light never paints over the element | |
| if (geom.kind !== 'alpha') { o.beginPath(); o.rect(0, 0, off.width, off.height); addPath(); o.clip('evenodd'); } | |
| for (const [k, mul] of [[1, 0.8], [0.55, 0.45], [0.25, 0.4]]) { | |
| shadowOf(0, R * k, `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a * mul)})`); | |
| } | |
| } | |
| o.restore(); | |
| if (!cfg.inner && geom.kind === 'alpha') { | |
| o.save(); o.globalCompositeOperation = 'destination-out'; o.shadowColor = 'transparent'; o.shadowBlur = 0; o.shadowOffsetX = 0; put(0); o.restore(); | |
| } | |
| if (cfg.tex) { | |
| const mask = this.maskCanvas(cfg.tex[0], 128); | |
| o.save(); | |
| o.globalCompositeOperation = 'destination-in'; | |
| const pat = o.createPattern(mask, 'repeat'); | |
| if (pat) { o.fillStyle = pat; o.fillRect(0, 0, off.width, off.height); } | |
| o.restore(); | |
| } | |
| g.bitmapKey = key; | |
| } | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| // intensity above 1 brightens by drawing the same light more than once | |
| const passes = Math.max(1, Math.ceil(a)); | |
| ctx.globalAlpha = clamp01(a / passes); | |
| for (let i = 0; i < passes; i++) ctx.drawImage(g.bitmap, 0, 0, g.bitmap.width, g.bitmap.height, base.cx - lw / 2, base.cy - lh / 2, lw, lh); | |
| ctx.restore(); | |
| } | |
| /** `rays N`: beams of light from the middle of the shape, turning slowly, never over the element itself */ | |
| drawRays(ctx, em, geom, cfg, a, now, moving) { | |
| const base = em.base, c = cfg.rgba, n = cfg.rays; | |
| const len = (Math.hypot(base.w, base.h) / 2 + cfg.radius) * base.s; | |
| const turn = moving ? ((now / 1000) * (cfg.spin === undefined ? 10 : cfg.spin) * Math.PI) / 180 : 0; | |
| ctx.save(); | |
| if (geom.kind !== 'alpha') { ctx.beginPath(); ctx.rect(-50, -50, this.vw + 100, this.vh + 100); this.addShape(ctx, base, geom); ctx.clip('evenodd'); } | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.translate(base.cx, base.cy); | |
| const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, len); | |
| grad.addColorStop(0, `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a * a * 0.85)})`); | |
| grad.addColorStop(1, `rgba(${c.r | 0},${c.g | 0},${c.b | 0},0)`); | |
| ctx.fillStyle = grad; | |
| const half = (Math.PI / n) * 0.42; | |
| for (let i = 0; i < n; i++) { | |
| const ang = turn + (i / n) * TAU; | |
| ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, len, ang - half, ang + half); ctx.closePath(); ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| /** `ripple P`: three rings that spread out from the outline and fade; one still ring under reduced motion */ | |
| drawRipple(ctx, em, geom, cfg, a, now, moving) { | |
| const base = em.base, c = cfg.rgba, P = cfg.ripple; | |
| const reach = (cfg.radius * base.s) / Math.max(1, (Math.min(base.w, base.h) * base.s) / 2); | |
| const phase0 = moving ? now / P : 0.4; | |
| for (let k = 0; k < 3; k++) { | |
| const t = (phase0 + k / 3) % 1; | |
| const ga = clamp01(a * (1 - t) * (moving ? 1 : 0.6)); | |
| if (ga < 0.01) continue; | |
| const grow = 1 + t * reach; | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.transform(base.a * base.s * grow, base.b * base.s * grow, base.c * base.s * grow, base.d * base.s * grow, base.cx, base.cy); | |
| ctx.beginPath(); | |
| tracePath(ctx, geom.kind === 'alpha' ? { kind: 'rrect', r: 0 } : geom, base.w, base.h); | |
| ctx.lineWidth = 2 / (base.s * grow); | |
| ctx.strokeStyle = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${ga})`; | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| } | |
| /* ---- masks and sprites ---- */ | |
| maskCanvas(texLayer, size) { | |
| const key = sigOf([texLayer, size]); | |
| let c = this.masks.get(key); | |
| if (c) return c; | |
| const data = makeMask(texLayer, size); | |
| c = document.createElement('canvas'); | |
| c.width = c.height = size; | |
| const x = c.getContext('2d'); | |
| const img = x.createImageData(size, size); | |
| for (let i = 0; i < data.length; i++) { img.data[i * 4 + 3] = data[i]; img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = 255; } | |
| x.putImageData(img, 0, 0); | |
| this.masks.set(key, c); | |
| return c; | |
| } | |
| /** a soft round sprite in one colour, optionally textured; cached by quantised colour */ | |
| sprite(c, shape, tex, variant) { | |
| const q = (v) => (Math.round(v / 16) * 16) | 0; | |
| const key = `${q(c.r)},${q(c.g)},${q(c.b)}|${shape}|${tex ? sigOf(tex[0]) : ''}|${variant}`; | |
| let s = this.sprites.get(key); | |
| if (s) return s; | |
| if (this.sprites.size > 600) this.sprites.clear(); | |
| const S = 64; | |
| s = document.createElement('canvas'); | |
| s.width = s.height = S; | |
| const x = s.getContext('2d'); | |
| const rgb = `${q(c.r)},${q(c.g)},${q(c.b)}`; | |
| if (shape === 'ring') { | |
| x.strokeStyle = `rgb(${rgb})`; x.lineWidth = 5; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 6, 0, TAU); x.stroke(); | |
| } else if (shape === 'star') { | |
| x.fillStyle = `rgb(${rgb})`; x.beginPath(); | |
| for (let i = 0; i < 8; i++) { const r = i % 2 ? S * 0.1 : S * 0.48, a = (i / 8) * TAU - Math.PI / 2; x.lineTo(S / 2 + Math.cos(a) * r, S / 2 + Math.sin(a) * r); } | |
| x.closePath(); x.fill(); | |
| } else if (shape === 'square') { | |
| x.fillStyle = `rgb(${rgb})`; x.fillRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7); | |
| } else if (shape === 'heart' || shape === 'diamond' || shape === 'triangle' || shape === 'drop' || shape === 'plus') { | |
| // the same polygons an aura can follow, filled | |
| const pts = UNIT_SHAPES[shape === 'plus' ? 'cross' : shape]; | |
| x.fillStyle = `rgb(${rgb})`; x.beginPath(); | |
| pts.forEach(([px, py], i) => { const X = S * (0.08 + 0.84 * px), Y = S * (0.08 + 0.84 * py); if (i) x.lineTo(X, Y); else x.moveTo(X, Y); }); | |
| x.closePath(); x.fill(); | |
| } else if (shape === 'bubble') { | |
| const g = x.createRadialGradient(S / 2, S / 2, S * 0.2, S / 2, S / 2, S / 2 - 3); | |
| g.addColorStop(0, `rgba(${rgb},0.05)`); g.addColorStop(1, `rgba(${rgb},0.35)`); | |
| x.fillStyle = g; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 3, 0, TAU); x.fill(); | |
| x.strokeStyle = `rgba(${rgb},0.9)`; x.lineWidth = 3; x.stroke(); | |
| x.strokeStyle = 'rgba(255,255,255,0.8)'; x.lineWidth = 4; x.lineCap = 'round'; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 12, Math.PI * 1.1, Math.PI * 1.45); x.stroke(); | |
| } else if (shape === 'snowflake') { | |
| x.strokeStyle = `rgb(${rgb})`; x.lineWidth = 4; x.lineCap = 'round'; | |
| for (let i = 0; i < 6; i++) { | |
| const a = (i / 6) * TAU, cx = Math.cos(a), cy = Math.sin(a); | |
| x.beginPath(); x.moveTo(S / 2, S / 2); x.lineTo(S / 2 + cx * S * 0.44, S / 2 + cy * S * 0.44); | |
| for (const f of [0.55, 0.8]) { // two little branches on each arm | |
| const bx = S / 2 + cx * S * 0.44 * f, by = S / 2 + cy * S * 0.44 * f; | |
| for (const d of [-0.9, 0.9]) { x.moveTo(bx, by); x.lineTo(bx + Math.cos(a + d) * S * 0.12, by + Math.sin(a + d) * S * 0.12); } | |
| } | |
| x.stroke(); | |
| } | |
| } else if (shape === 'confetti') { | |
| x.fillStyle = `rgb(${rgb})`; x.fillRect(S * 0.05, S * 0.05, S * 0.9, S * 0.9); | |
| } else if (shape === 'leaf') { | |
| x.fillStyle = `rgb(${rgb})`; x.beginPath(); x.moveTo(S * 0.12, S * 0.88); x.quadraticCurveTo(S * 0.08, S * 0.12, S * 0.9, S * 0.1); x.quadraticCurveTo(S * 0.92, S * 0.9, S * 0.12, S * 0.88); x.closePath(); x.fill(); | |
| x.strokeStyle = 'rgba(0,0,0,0.25)'; x.lineWidth = 2; x.beginPath(); x.moveTo(S * 0.15, S * 0.85); x.lineTo(S * 0.75, S * 0.25); x.stroke(); | |
| } else { | |
| const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); | |
| const hard = shape === 'circle' ? 0.55 : 0.0; | |
| g.addColorStop(0, `rgba(${rgb},1)`); | |
| g.addColorStop(hard, `rgba(${rgb},${shape === 'circle' ? 0.95 : 0.7})`); | |
| g.addColorStop(1, `rgba(${rgb},0)`); | |
| x.fillStyle = g; x.fillRect(0, 0, S, S); | |
| } | |
| if (tex) { | |
| // multiply the sprite's alpha by a texture mask, a different offset per variant | |
| const m = this.maskCanvas(tex[0], 64); | |
| const id = x.getImageData(0, 0, S, S); | |
| const mx = m.getContext('2d').getImageData(0, 0, S, S).data; | |
| const off = (variant * 13) % S; | |
| for (let yy = 0; yy < S; yy++) for (let xx = 0; xx < S; xx++) { | |
| const mi = (((yy + off) % S) * S + ((xx + off * 2) % S)) * 4 + 3; | |
| const i = (yy * S + xx) * 4 + 3; | |
| id.data[i] = (id.data[i] * mx[mi]) / 255; | |
| } | |
| x.putImageData(id, 0, 0); | |
| } | |
| this.sprites.set(key, s); | |
| return s; | |
| } | |
| /* ---- particles ---- */ | |
| emit(L, rect, em, dt, share, el) { | |
| const c = L.cfg; | |
| const meanLife = ((c.life[0] + c.life[1]) / 2) / 1000; | |
| const rate = (c.count * share) / meanLife; // per second | |
| L.acc += rate * dt; | |
| const cap = Math.max(1, Math.round(c.count * share)); | |
| while (L.acc >= 1) { | |
| L.acc -= 1; | |
| if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; } | |
| L.parts.push(this.spawn(L, rect, el, em.scale || 1, em)); | |
| } | |
| } | |
| spawn(L, rect, el, scale = 1, em = null) { | |
| const c = L.cfg, r = L.rnd; | |
| const between = (a, b) => a + (b - a) * r(); | |
| let x, y; | |
| switch (c.from) { | |
| case 'bottom': x = rect.left + r() * rect.width; y = rect.bottom; break; | |
| case 'top': x = rect.left + r() * rect.width; y = rect.top; break; | |
| case 'left': x = rect.left; y = rect.top + r() * rect.height; break; | |
| case 'right': x = rect.right; y = rect.top + r() * rect.height; break; | |
| case 'center': x = rect.left + r() * rect.width; y = rect.top + r() * rect.height; break; | |
| case 'corners': { const k = (r() * 4) | 0; x = k & 1 ? rect.right : rect.left; y = k & 2 ? rect.bottom : rect.top; break; } | |
| case 'thumb': { | |
| // the thumb of a range input: where the value is | |
| let p = 0.5; | |
| try { const mn = Number(el.min || 0), mx = Number(el.max || 100); p = (Number(el.value) - mn) / (mx - mn || 1); } catch { /* not a range */ } | |
| const pad = 10; | |
| x = rect.left + pad + p * Math.max(0, rect.width - pad * 2); y = rect.top + rect.height / 2 + (r() - 0.5) * 6; break; | |
| } | |
| default: { // edge / outline: a random point on the perimeter | |
| // 0.16.0: along the element's REAL outline (turned, clipped to a diamond or a star), not the box around it | |
| const ol = em && this.outlineOf(em, c); | |
| if (ol && ol.total > 0) { | |
| let d = r() * ol.total, i = 0; | |
| while (i < ol.lens.length - 1 && d > ol.lens[i]) { d -= ol.lens[i]; i++; } | |
| const p0 = ol.pts[i], p1 = ol.pts[(i + 1) % ol.pts.length], t = ol.lens[i] > 0 ? d / ol.lens[i] : 0; | |
| x = p0.x + (p1.x - p0.x) * t; y = p0.y + (p1.y - p0.y) * t; | |
| break; | |
| } | |
| const per = 2 * (rect.width + rect.height); | |
| let d = r() * per; | |
| if (d < rect.width) { x = rect.left + d; y = rect.top; } | |
| else if ((d -= rect.width) < rect.height) { x = rect.right; y = rect.top + d; } | |
| else if ((d -= rect.height) < rect.width) { x = rect.right - d; y = rect.bottom; } | |
| else { d -= rect.width; x = rect.left; y = rect.bottom - d; } | |
| } | |
| } | |
| let ang; | |
| if (c.dir === 'out' || c.dir === 'in') { | |
| const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2; | |
| ang = Math.atan2(y - cy, x - cx); | |
| if (c.dir === 'in') ang += Math.PI; | |
| } else ang = (c.dir * Math.PI) / 180; | |
| ang += ((r() - 0.5) * c.spread * Math.PI) / 180; | |
| const sp = between(c.speed[0], c.speed[1]) * scale; | |
| return { | |
| x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, | |
| age: 0, life: between(c.life[0], c.life[1]) / 1000, | |
| size: between(c.size[0], c.size[1]) * scale, rot: r() * TAU, | |
| spin: c.spin !== undefined ? (r() < 0.5 ? -1 : 1) * ((c.spin * Math.PI) / 180) * (0.6 + 0.8 * r()) : (r() - 0.5) * 4, | |
| variant: (r() * 4) | 0, ph: r() * TAU, pc: (r() * 1000) | 0, | |
| }; | |
| } | |
| stepAndDraw(ctx, L, dt, moving, hide) { | |
| const c = L.cfg, parts = L.parts; | |
| const additive = c.blend === 'add'; | |
| ctx.save(); | |
| if (hide) { | |
| // clip the element's own shape OUT, so the layer reads as sitting behind it | |
| ctx.beginPath(); | |
| ctx.rect(-50, -50, this.vw + 100, this.vh + 100); | |
| this.addShape(ctx, hide.base, hide.geom); | |
| ctx.clip('evenodd'); | |
| } | |
| ctx.globalCompositeOperation = additive ? 'lighter' : 'source-over'; | |
| const drag = Math.exp(-c.drag * dt); | |
| for (let i = parts.length - 1; i >= 0; i--) { | |
| const p = parts[i]; | |
| if (moving) { | |
| p.age += dt; | |
| if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; } | |
| const sc = L.scale || 1; | |
| p.vy += c.gravity * sc * dt; | |
| if (c.wind) p.vx += c.wind * sc * dt; | |
| if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * sc * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * sc * 0.3 * dt; } | |
| if (c.drag) { p.vx *= drag; p.vy *= drag; } | |
| p.x += p.vx * dt; p.y += p.vy * dt; | |
| if (c.orbit && L.cx !== undefined) { // 0.16.0: circle the middle of the element | |
| const ang = (c.orbit * Math.PI / 180) * dt, co = Math.cos(ang), si = Math.sin(ang), dx = p.x - L.cx, dy = p.y - L.cy; | |
| p.x = L.cx + dx * co - dy * si; p.y = L.cy + dx * si + dy * co; | |
| } | |
| p.rot += p.spin * dt; | |
| } | |
| const t = p.age / p.life; | |
| let col, alphaIn; | |
| if (c.palette) { | |
| // confetti: ONE colour per particle, fading in and out over its life | |
| const base = c.stops[p.pc % Math.max(1, c.stops.length - (c.stops.length > 1 && c.stops[c.stops.length - 1].a === 0 ? 1 : 0))]; | |
| col = base; alphaIn = base.a * Math.min(1, t / 0.08, (1 - t) / 0.3); | |
| } else { col = colorAt(c.stops, t); alphaIn = col.a; } | |
| const alpha = clamp01(alphaIn) * (L.eo === undefined ? 1 : L.eo); | |
| if (alpha <= 0.003) continue; | |
| const size = p.size * lerp(1, c.grow, t); | |
| this.drawParticle(ctx, c, p, col, alpha, size); | |
| } | |
| ctx.restore(); | |
| } | |
| drawParticle(ctx, c, p, col, alpha, size) { | |
| const shape = c.shape; | |
| if (shape === 'spark' || shape === 'line') { | |
| const len = Math.max(size, Math.hypot(p.vx, p.vy) * 0.06); | |
| const sp = Math.hypot(p.vx, p.vy) || 1; | |
| ctx.strokeStyle = `rgba(${col.r | 0},${col.g | 0},${col.b | 0},${alpha})`; | |
| ctx.lineWidth = Math.max(1, size * 0.3); | |
| ctx.lineCap = 'round'; | |
| ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - (p.vx / sp) * len, p.y - (p.vy / sp) * len); ctx.stroke(); | |
| if (c.glowing) { | |
| const s = this.sprite(col, 'glow', c.tex, p.variant), d = size * 3; | |
| ctx.globalAlpha = alpha * 0.5; ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d); ctx.globalAlpha = 1; | |
| } | |
| return; | |
| } | |
| const kind = shape === 'circle' && c.glowing ? 'glow' : shape === 'flame' ? 'glow' : shape === 'smoke' ? 'glow' : shape; | |
| const s = this.sprite(col, kind, c.tex, p.variant); | |
| ctx.globalAlpha = alpha; | |
| if (shape === 'flame') { | |
| // a teardrop: stretched along its travel, narrower at the tip | |
| const sp = Math.hypot(p.vx, p.vy) || 1; | |
| const ang = Math.atan2(p.vy, p.vx) + Math.PI / 2; | |
| ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(ang); | |
| const w = size * 2.2, h = size * 3.4; | |
| ctx.drawImage(s, -w / 2, -h * 0.65, w, h); | |
| ctx.restore(); | |
| } else if (shape === 'confetti') { | |
| // a flat scrap tumbling: its height shrinks and grows as it turns over | |
| ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); | |
| const w = size * 1.5, h = Math.max(0.5, size * 0.8 * Math.abs(Math.cos(p.ph + p.age * 9))); | |
| ctx.drawImage(s, -w / 2, -h / 2, w, h); | |
| ctx.restore(); | |
| } else if (shape === 'ring' || shape === 'star' || shape === 'square' || shape === 'heart' || shape === 'diamond' || shape === 'triangle' || shape === 'plus' || shape === 'drop' || shape === 'bubble' || shape === 'snowflake' || shape === 'leaf') { | |
| ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(shape === 'drop' ? Math.atan2(p.vy, p.vx) + Math.PI / 2 : p.rot); | |
| const d = size * (shape === 'ring' ? 1.2 : 1.4); | |
| ctx.drawImage(s, -d / 2, -d / 2, d, d); | |
| ctx.restore(); | |
| } else { | |
| const d = size * (c.glowing || shape === 'smoke' ? 2.6 : 1.2); | |
| ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| /** for tests and tools: a readable snapshot, never needed in normal use */ | |
| debug() { | |
| const out = []; | |
| for (const [el, em] of this.emitters) out.push({ el, visible: em.visible, dying: em.dying, glow: em.glow.length, layers: em.layers.map((l) => ({ alive: l.parts.length, sample: l.parts.slice(0, 5).map((p) => ({ x: p.x, y: p.y, vx: p.vx, vy: p.vy, age: p.age })) })) }); | |
| return { stats: { ...this.stats }, running: this.running, paused: this.paused, canvas: !!this.canvas, emitters: out }; | |
| } | |
| } | |