mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
887 lines
31 KiB
JavaScript
887 lines
31 KiB
JavaScript
function addSeasonalOverlay() {
|
||
const existing = document.querySelector('.snowverlay');
|
||
if (existing) {
|
||
return;
|
||
}
|
||
|
||
const SHAPE_GLYPHS = {
|
||
bat: '🦇',
|
||
snowflake: '❄',
|
||
bell: '🔔',
|
||
heart: '❤'
|
||
};
|
||
|
||
/** Extensible mode definitions — add entries + handlers to support new styles. */
|
||
const ANIMATION_MODES = {
|
||
fall: {
|
||
id: 'fall',
|
||
canvas: { heightPx: 160, fullViewport: false },
|
||
poolSize: 15,
|
||
fadeStartYRatio: 1,
|
||
speed: { min: 0.2, max: 0.7 }
|
||
},
|
||
fly: {
|
||
id: 'fly',
|
||
canvas: { heightPx: 75, fullViewport: false },
|
||
poolSize: 12,
|
||
flockSize: { min: 4, max: 6 },
|
||
groupDelayMs: { min: 1000, max: 6000 },
|
||
onScreenSec: { min: 2, max: 3 },
|
||
topY: { min: 8, max: 68 },
|
||
spawnX: -50
|
||
},
|
||
corners: {
|
||
id: 'corners',
|
||
canvas: { heightPx: null, fullViewport: false, cornerCanvases: true },
|
||
// Larger pool so micro-packs can stream while earlier bats are still flying
|
||
poolSize: 48,
|
||
// Narrow focused packs (not one wide swarm)
|
||
packSize: { min: 5, max: 8 },
|
||
// Continuous stream duration at one corner, then pause before next corner
|
||
streamDurationMs: { min: 2000, max: 3000 },
|
||
streamGapMs: { min: 160, max: 300 },
|
||
groupDelayMs: { min: 1000, max: 6000 },
|
||
onScreenSec: { min: 1.5, max: 2.5 },
|
||
// Chord through the corner quadrant (~sqrt(2) * quadrant)
|
||
pathLengthRatio: { min: 0.18, max: 0.28 },
|
||
// Corner canvas / chord band as fraction of min(vw, vh)
|
||
quadrantFrac: 0.14
|
||
}
|
||
};
|
||
|
||
const DEFAULT_MODE_ID = 'fall';
|
||
const styleKey = String(document.body.dataset.seasonalAnimationStyle || DEFAULT_MODE_ID).toLowerCase();
|
||
const modeDef = ANIMATION_MODES[styleKey] || ANIMATION_MODES[DEFAULT_MODE_ID];
|
||
|
||
const shapeKey = document.body.dataset.seasonalShape || 'snowflake';
|
||
const glyph = SHAPE_GLYPHS[shapeKey] || SHAPE_GLYPHS.snowflake;
|
||
|
||
const PARTICLE_SIZE = { min: 10, max: 22 };
|
||
const TARGET_FPS = 30;
|
||
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
||
const BASE_FRAME_MS = 1000 / 60;
|
||
const MAX_DT_MS = 50;
|
||
const RESIZE_DEBOUNCE_MS = 150;
|
||
const SPRITE_BASE_SIZE = 32;
|
||
const MAX_DPR = 2;
|
||
const FRAMES_PER_SEC = 1000 / BASE_FRAME_MS;
|
||
|
||
const rand = (min, max) => min + Math.random() * (max - min);
|
||
const randInt = (min, max) => min + Math.floor(Math.random() * (max - min + 1));
|
||
|
||
const useCornerCanvases = modeDef.canvas.cornerCanvases === true;
|
||
const CORNER_IDS = ['tl', 'tr', 'bl', 'br'];
|
||
|
||
const canvasHeightCss = () => {
|
||
if (modeDef.canvas.fullViewport) {
|
||
return Math.max(1, window.innerHeight || 1);
|
||
}
|
||
return modeDef.canvas.heightPx || 160;
|
||
};
|
||
|
||
const cornerSizeCss = () => {
|
||
const vw = Math.max(1, window.innerWidth || 1);
|
||
const vh = Math.max(1, window.innerHeight || 1);
|
||
// Keep corner canvases large enough for a readable swarm (~14% min side, min 96px)
|
||
return Math.max(96, Math.floor(Math.min(vw, vh) * (modeDef.quadrantFrac || 0.14)));
|
||
};
|
||
|
||
const style = document.createElement('style');
|
||
if (useCornerCanvases) {
|
||
style.textContent = `
|
||
body {
|
||
height: 100svh;
|
||
margin: 0;
|
||
}
|
||
.snowverlay.snowverlay-corner {
|
||
position: fixed;
|
||
width: var(--snowverlay-corner-size, 14vmin);
|
||
height: var(--snowverlay-corner-size, 14vmin);
|
||
pointer-events: none;
|
||
z-index: 1000;
|
||
}
|
||
.snowverlay-corner-tl { top: 0; left: 0; }
|
||
.snowverlay-corner-tr { top: 0; right: 0; }
|
||
.snowverlay-corner-bl { bottom: 0; left: 0; }
|
||
.snowverlay-corner-br { bottom: 0; right: 0; }
|
||
`;
|
||
} else {
|
||
style.textContent = `
|
||
body {
|
||
height: 100svh;
|
||
margin: 0;
|
||
}
|
||
.snowverlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: ${modeDef.canvas.heightPx || 160}px;
|
||
pointer-events: none;
|
||
z-index: 1000;
|
||
}
|
||
`;
|
||
}
|
||
document.head.appendChild(style);
|
||
|
||
/** @type {HTMLCanvasElement|null} */
|
||
let canvas = null;
|
||
/** @type {CanvasRenderingContext2D|null} */
|
||
let ctx = null;
|
||
/** @type {Record<string, { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D }>} */
|
||
const cornerLayers = {};
|
||
|
||
if (useCornerCanvases) {
|
||
CORNER_IDS.forEach((id) => {
|
||
const el = document.createElement('canvas');
|
||
el.className = `snowverlay snowverlay-corner snowverlay-corner-${id}`;
|
||
document.body.appendChild(el);
|
||
cornerLayers[id] = {
|
||
canvas: el,
|
||
ctx: el.getContext('2d', { alpha: true })
|
||
};
|
||
});
|
||
} else {
|
||
canvas = document.createElement('canvas');
|
||
canvas.className = 'snowverlay';
|
||
document.body.appendChild(canvas);
|
||
ctx = canvas.getContext('2d', { alpha: true });
|
||
}
|
||
|
||
let cssWidth = window.innerWidth || 1;
|
||
let cssHeight = useCornerCanvases
|
||
? Math.max(1, window.innerHeight || 1)
|
||
: canvasHeightCss();
|
||
let cornerSize = useCornerCanvases ? cornerSizeCss() : 0;
|
||
|
||
const buildGlyphSprite = () => {
|
||
const sprite = document.createElement('canvas');
|
||
sprite.width = SPRITE_BASE_SIZE;
|
||
sprite.height = SPRITE_BASE_SIZE;
|
||
const sCtx = sprite.getContext('2d');
|
||
sCtx.clearRect(0, 0, SPRITE_BASE_SIZE, SPRITE_BASE_SIZE);
|
||
sCtx.font = `${SPRITE_BASE_SIZE * 0.85}px sans-serif`;
|
||
sCtx.textAlign = 'center';
|
||
sCtx.textBaseline = 'middle';
|
||
sCtx.fillText(glyph, SPRITE_BASE_SIZE / 2, SPRITE_BASE_SIZE / 2);
|
||
return sprite;
|
||
};
|
||
const glyphSprite = buildGlyphSprite();
|
||
|
||
const resizeCanvas = () => {
|
||
cssWidth = Math.max(1, window.innerWidth || 1);
|
||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||
if (useCornerCanvases) {
|
||
cssHeight = Math.max(1, window.innerHeight || 1);
|
||
cornerSize = cornerSizeCss();
|
||
document.documentElement.style.setProperty('--snowverlay-corner-size', `${cornerSize}px`);
|
||
CORNER_IDS.forEach((id) => {
|
||
const layer = cornerLayers[id];
|
||
if (!layer) return;
|
||
layer.canvas.width = Math.round(cornerSize * dpr);
|
||
layer.canvas.height = Math.round(cornerSize * dpr);
|
||
layer.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
});
|
||
return;
|
||
}
|
||
cssHeight = canvasHeightCss();
|
||
canvas.width = Math.round(cssWidth * dpr);
|
||
canvas.height = Math.round(cssHeight * dpr);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
};
|
||
|
||
let resizeTimer = null;
|
||
const onResize = () => {
|
||
if (resizeTimer != null) clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => {
|
||
resizeTimer = null;
|
||
resizeCanvas();
|
||
if (reducedMotionMq.matches && shouldPaintStatic()) {
|
||
renderStatic();
|
||
}
|
||
}, RESIZE_DEBOUNCE_MS);
|
||
};
|
||
|
||
window.addEventListener('resize', onResize);
|
||
resizeCanvas();
|
||
|
||
const burstState = {
|
||
nextLaunchAt: 0,
|
||
lastCorner: null,
|
||
// Active corner stream (corners mode)
|
||
streamUntil: 0,
|
||
nextStreamPackAt: 0,
|
||
streamCorner: null,
|
||
streamFromVertical: true,
|
||
streamLane: 0.5,
|
||
streamSpeed: 0,
|
||
activeCornerCanvas: null
|
||
};
|
||
|
||
function getActiveCornerCtx() {
|
||
const id = burstState.activeCornerCanvas || burstState.streamCorner;
|
||
return id ? cornerLayers[id]?.ctx || null : null;
|
||
}
|
||
|
||
function clearCornerCanvas(id) {
|
||
const layer = cornerLayers[id];
|
||
if (!layer) return;
|
||
layer.ctx.clearRect(0, 0, cornerSize, cornerSize);
|
||
}
|
||
|
||
function clearAllCornerCanvases() {
|
||
CORNER_IDS.forEach(clearCornerCanvas);
|
||
}
|
||
|
||
function clearActiveSurface() {
|
||
if (useCornerCanvases) {
|
||
const id = burstState.activeCornerCanvas || burstState.streamCorner;
|
||
if (id) clearCornerCanvas(id);
|
||
return;
|
||
}
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
}
|
||
|
||
/** Map viewport particle coords into the active corner canvas local space. */
|
||
function toCornerLocal(x, y, cornerId) {
|
||
const size = cornerSize;
|
||
if (cornerId === 'tr') return { x: x - (cssWidth - size), y };
|
||
if (cornerId === 'bl') return { x, y: y - (cssHeight - size) };
|
||
if (cornerId === 'br') return { x: x - (cssWidth - size), y: y - (cssHeight - size) };
|
||
return { x, y };
|
||
}
|
||
|
||
function makeBaseParticle() {
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
vx: 0,
|
||
vy: 0,
|
||
size: rand(PARTICLE_SIZE.min, PARTICLE_SIZE.max),
|
||
opacity: null,
|
||
speed: 0,
|
||
drift: 0,
|
||
rotation: 0,
|
||
rotationSpeed: 0,
|
||
active: false,
|
||
wanderPhase: Math.random() * Math.PI * 2
|
||
};
|
||
}
|
||
|
||
function speedForDistance(distPx, secMin, secMax) {
|
||
const sec = rand(secMin, secMax);
|
||
return distPx / (sec * FRAMES_PER_SEC);
|
||
}
|
||
|
||
function applyFacing(particle, vx, vy) {
|
||
particle.rotation = (Math.atan2(vy, vx) * 180) / Math.PI + 90;
|
||
}
|
||
|
||
function applyGentleWander(particle, frameScale) {
|
||
particle.wanderPhase += 0.035 * frameScale;
|
||
const wanderVy = Math.sin(particle.wanderPhase) * 0.4;
|
||
const wanderVx = Math.cos(particle.wanderPhase * 0.7) * 0.08;
|
||
return {
|
||
vx: particle.vx + wanderVx,
|
||
vy: particle.vy + wanderVy
|
||
};
|
||
}
|
||
|
||
const fallHandlers = {
|
||
createParticle(isAnimated, index) {
|
||
const fadeY = cssHeight;
|
||
return {
|
||
...makeBaseParticle(),
|
||
active: true,
|
||
x: Math.random() * cssWidth,
|
||
y: isAnimated
|
||
? -20 - (index * cssHeight) / modeDef.poolSize
|
||
: Math.random() * fadeY,
|
||
speed: rand(modeDef.speed.min, modeDef.speed.max),
|
||
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
|
||
drift: Math.random() * 0.4 - 0.2,
|
||
rotation: Math.random() * 360,
|
||
rotationSpeed: Math.random() * 2 - 1
|
||
};
|
||
},
|
||
opacity(particle) {
|
||
if (particle.opacity != null) return particle.opacity;
|
||
return Math.max(0, 1 - particle.y / cssHeight);
|
||
},
|
||
update(particle, frameScale) {
|
||
particle.y += particle.speed * frameScale;
|
||
particle.x += particle.drift * frameScale;
|
||
particle.rotation += particle.rotationSpeed * frameScale;
|
||
const offBottom = particle.y > cssHeight + 20;
|
||
const offSides = particle.x < -50 || particle.x > cssWidth + 50;
|
||
if (offBottom || offSides) {
|
||
particle.y = -10 - Math.random() * 30;
|
||
particle.x = Math.random() * cssWidth;
|
||
particle.speed = rand(modeDef.speed.min, modeDef.speed.max);
|
||
particle.drift = Math.random() * 0.4 - 0.2;
|
||
particle.rotation = Math.random() * 360;
|
||
}
|
||
},
|
||
tickFrame() {},
|
||
paintStatic() {
|
||
for (let i = 0; i < modeDef.poolSize; i++) {
|
||
drawParticle(fallHandlers.createParticle(false, i));
|
||
}
|
||
}
|
||
};
|
||
|
||
function parkFlyParticle(p) {
|
||
p.active = false;
|
||
p.x = -999;
|
||
p.y = -999;
|
||
}
|
||
|
||
function initFlyMember(p, memberIndex, baseVx, baseY) {
|
||
const spreadX = memberIndex * rand(14, 24);
|
||
p.x = modeDef.spawnX - spreadX - Math.random() * 20;
|
||
p.y = baseY + (Math.random() * 2 - 1) * 6;
|
||
p.vx = baseVx * (0.94 + Math.random() * 0.12);
|
||
p.vy = rand(-0.08, 0.08);
|
||
p.wanderPhase = Math.random() * Math.PI * 2;
|
||
p.active = true;
|
||
applyFacing(p, p.vx, p.vy);
|
||
}
|
||
|
||
function launchFlyGroup(particles) {
|
||
const flockSize = randInt(modeDef.flockSize.min, modeDef.flockSize.max);
|
||
const baseVx = speedForDistance(
|
||
cssWidth + 80,
|
||
modeDef.onScreenSec.min,
|
||
modeDef.onScreenSec.max
|
||
);
|
||
const baseY = rand(modeDef.topY.min, modeDef.topY.max);
|
||
let placed = 0;
|
||
for (let i = 0; i < particles.length && placed < flockSize; i++) {
|
||
if (!particles[i].active) {
|
||
initFlyMember(particles[i], placed, baseVx, baseY);
|
||
placed += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
function flyGroupActive(particles) {
|
||
return particles.some((p) => p.active);
|
||
}
|
||
|
||
const flyHandlers = {
|
||
createParticle() {
|
||
const p = makeBaseParticle();
|
||
parkFlyParticle(p);
|
||
return p;
|
||
},
|
||
opacity(particle) {
|
||
if (!particle.active) return 0;
|
||
if (particle.opacity != null) return particle.opacity;
|
||
const fadeStartX = cssWidth * 0.88;
|
||
if (particle.x <= fadeStartX) return 1;
|
||
const fadeRange = Math.max(1, cssWidth + 50 - fadeStartX);
|
||
return Math.max(0, 1 - (particle.x - fadeStartX) / fadeRange);
|
||
},
|
||
update(particle, frameScale) {
|
||
if (!particle.active) return;
|
||
const { vx, vy } = applyGentleWander(particle, frameScale);
|
||
particle.x += vx * frameScale;
|
||
particle.y += vy * frameScale;
|
||
particle.y = Math.max(modeDef.topY.min, Math.min(modeDef.topY.max, particle.y));
|
||
applyFacing(particle, vx, vy);
|
||
const offRight = particle.x > cssWidth + 60;
|
||
const faded = flyHandlers.opacity(particle) <= 0.02;
|
||
if (offRight || faded) {
|
||
parkFlyParticle(particle);
|
||
}
|
||
},
|
||
tickFrame(particles) {
|
||
const now = Date.now();
|
||
if (flyGroupActive(particles)) return;
|
||
if (burstState.nextLaunchAt === 0) {
|
||
burstState.nextLaunchAt = now + randInt(
|
||
modeDef.groupDelayMs.min,
|
||
modeDef.groupDelayMs.max
|
||
);
|
||
return;
|
||
}
|
||
if (now >= burstState.nextLaunchAt) {
|
||
launchFlyGroup(particles);
|
||
burstState.nextLaunchAt = 0;
|
||
}
|
||
},
|
||
paintStatic() {
|
||
for (let i = 0; i < modeDef.poolSize; i++) {
|
||
const p = flyHandlers.createParticle();
|
||
p.active = true;
|
||
p.x = Math.random() * cssWidth;
|
||
p.y = rand(modeDef.topY.min, modeDef.topY.max);
|
||
applyFacing(p, 1, 0);
|
||
drawParticle(p);
|
||
}
|
||
},
|
||
onStart(particles) {
|
||
launchFlyGroup(particles);
|
||
burstState.nextLaunchAt = 0;
|
||
}
|
||
};
|
||
|
||
const CORNER_PICK_IDS = CORNER_IDS;
|
||
|
||
function pickNextCorner() {
|
||
const choices = CORNER_PICK_IDS.filter((c) => c !== burstState.lastCorner);
|
||
const corner = choices[Math.floor(Math.random() * choices.length)] || 'tl';
|
||
burstState.lastCorner = corner;
|
||
const fromVertical = Math.random() < 0.5;
|
||
return { corner, fromVertical };
|
||
}
|
||
|
||
/**
|
||
* True 45° diagonal across the corner canvas.
|
||
* fromVertical: enter the vertical screen edge; else enter the horizontal edge.
|
||
* |vx| === |vy| so the path is always 45°.
|
||
*
|
||
* Spawn is offset along the travel direction by `margin` so that after the
|
||
* off-screen approach, particles ENTER the viewport/canvas at `lane` —
|
||
* without this, a 45° approach from margin px off-screen only clips the tip.
|
||
*/
|
||
function cornerBurstVector(corner, fromVertical, speed, laneT = 0.5) {
|
||
// Component along each axis for 45° (speed is full diagonal magnitude)
|
||
const c = speed / Math.SQRT2;
|
||
// Lane within the corner canvas (match drawn canvas size when available)
|
||
const band = useCornerCanvases
|
||
? cornerSize
|
||
: Math.min(cssWidth, cssHeight) * (modeDef.quadrantFrac || 0.14);
|
||
// Keep chords inside the canvas with room for particle size / wander
|
||
const lane = band * (0.25 + Math.max(0, Math.min(1, laneT)) * 0.55);
|
||
const margin = 24;
|
||
|
||
if (corner === 'tl') {
|
||
return fromVertical
|
||
? {
|
||
// left → top (up-right 45°): enter left edge at y=lane
|
||
spawn: () => ({ x: -margin - Math.random() * 12, y: lane + margin }),
|
||
vx: c,
|
||
vy: -c
|
||
}
|
||
: {
|
||
// top → left (down-left 45°): enter top edge at x=lane
|
||
spawn: () => ({ x: lane + margin, y: -margin - Math.random() * 12 }),
|
||
vx: -c,
|
||
vy: c
|
||
};
|
||
}
|
||
if (corner === 'tr') {
|
||
return fromVertical
|
||
? {
|
||
// right → top (up-left 45°): enter right edge at y=lane
|
||
spawn: () => ({ x: cssWidth + margin + Math.random() * 12, y: lane + margin }),
|
||
vx: -c,
|
||
vy: -c
|
||
}
|
||
: {
|
||
// top → right (down-right 45°): enter top edge at x=cssWidth-lane
|
||
spawn: () => ({ x: cssWidth - lane - margin, y: -margin - Math.random() * 12 }),
|
||
vx: c,
|
||
vy: c
|
||
};
|
||
}
|
||
if (corner === 'bl') {
|
||
return fromVertical
|
||
? {
|
||
// left → bottom (down-right 45°): enter left edge at y=cssHeight-lane
|
||
spawn: () => ({ x: -margin - Math.random() * 12, y: cssHeight - lane - margin }),
|
||
vx: c,
|
||
vy: c
|
||
}
|
||
: {
|
||
// bottom → left (up-left 45°): enter bottom edge at x=lane
|
||
spawn: () => ({ x: lane + margin, y: cssHeight + margin + Math.random() * 12 }),
|
||
vx: -c,
|
||
vy: -c
|
||
};
|
||
}
|
||
// br
|
||
return fromVertical
|
||
? {
|
||
// right → bottom (down-left 45°): enter right edge at y=cssHeight-lane
|
||
spawn: () => ({ x: cssWidth + margin + Math.random() * 12, y: cssHeight - lane - margin }),
|
||
vx: -c,
|
||
vy: c
|
||
}
|
||
: {
|
||
// bottom → right (up-right 45°): enter bottom edge at x=cssWidth-lane
|
||
spawn: () => ({ x: cssWidth - lane - margin, y: cssHeight + margin + Math.random() * 12 }),
|
||
vx: c,
|
||
vy: -c
|
||
};
|
||
}
|
||
|
||
function isOffScreen(p) {
|
||
if (useCornerCanvases) {
|
||
// Deactivate once past the active corner canvas (+ margin for sprite)
|
||
const id = burstState.activeCornerCanvas || burstState.streamCorner;
|
||
if (!id) return true;
|
||
const local = toCornerLocal(p.x, p.y, id);
|
||
const m = 48;
|
||
return local.x < -m || local.y < -m || local.x > cornerSize + m || local.y > cornerSize + m;
|
||
}
|
||
const m = 80;
|
||
return p.x < -m || p.x > cssWidth + m || p.y < -m || p.y > cssHeight + m;
|
||
}
|
||
|
||
function cornersGroupActive(particles) {
|
||
return particles.some((p) => p.active);
|
||
}
|
||
|
||
/** Launch one narrow pack on the current stream lane. */
|
||
function launchCornersMicroPack(particles) {
|
||
const { streamCorner, streamFromVertical, streamLane, streamSpeed } = burstState;
|
||
if (!streamCorner || !streamSpeed) return;
|
||
|
||
const spec = cornerBurstVector(streamCorner, streamFromVertical, streamSpeed, streamLane);
|
||
const packSize = randInt(modeDef.packSize.min, modeDef.packSize.max);
|
||
let placed = 0;
|
||
for (let i = 0; i < particles.length && placed < packSize; i++) {
|
||
const p = particles[i];
|
||
if (p.active) continue;
|
||
const pos = spec.spawn();
|
||
// Narrow corridor: short stagger along travel, ~±4px lateral
|
||
const along = placed * rand(6, 12);
|
||
const lateral = (Math.random() * 2 - 1) * 4;
|
||
if (streamFromVertical) {
|
||
p.x = pos.x - Math.sign(spec.vx || 1) * along;
|
||
p.y = pos.y + lateral;
|
||
} else {
|
||
p.x = pos.x + lateral;
|
||
p.y = pos.y - Math.sign(spec.vy || 1) * along;
|
||
}
|
||
const scale = 0.97 + Math.random() * 0.06;
|
||
p.vx = spec.vx * scale;
|
||
p.vy = spec.vy * scale;
|
||
p.wanderPhase = Math.random() * Math.PI * 2;
|
||
p.active = true;
|
||
applyFacing(p, p.vx, p.vy);
|
||
placed += 1;
|
||
}
|
||
}
|
||
|
||
/** Begin a 2–3s stream of narrow packs at a new corner. */
|
||
function beginCornersStream(particles) {
|
||
const { corner, fromVertical } = pickNextCorner();
|
||
// Travel distance for speed: roughly the corner diagonal
|
||
const pathBase = useCornerCanvases
|
||
? cornerSize * Math.SQRT2
|
||
: Math.min(cssWidth, cssHeight) * (modeDef.quadrantFrac || 0.14) * Math.SQRT2;
|
||
const pathLen = pathBase * rand(0.85, 1.25);
|
||
const speed = speedForDistance(
|
||
pathLen,
|
||
modeDef.onScreenSec.min,
|
||
modeDef.onScreenSec.max
|
||
);
|
||
const now = Date.now();
|
||
if (burstState.activeCornerCanvas && burstState.activeCornerCanvas !== corner) {
|
||
clearCornerCanvas(burstState.activeCornerCanvas);
|
||
}
|
||
burstState.streamCorner = corner;
|
||
burstState.activeCornerCanvas = corner;
|
||
burstState.streamFromVertical = fromVertical;
|
||
burstState.streamLane = Math.random();
|
||
burstState.streamSpeed = speed;
|
||
burstState.streamUntil = now + randInt(
|
||
modeDef.streamDurationMs.min,
|
||
modeDef.streamDurationMs.max
|
||
);
|
||
burstState.nextLaunchAt = 0;
|
||
launchCornersMicroPack(particles);
|
||
burstState.nextStreamPackAt = now + randInt(
|
||
modeDef.streamGapMs.min,
|
||
modeDef.streamGapMs.max
|
||
);
|
||
}
|
||
|
||
const cornersHandlers = {
|
||
createParticle() {
|
||
const p = makeBaseParticle();
|
||
p.active = false;
|
||
p.x = -999;
|
||
p.y = -999;
|
||
return p;
|
||
},
|
||
opacity(particle) {
|
||
if (!particle.active) return 0;
|
||
if (particle.opacity != null) return particle.opacity;
|
||
return 1;
|
||
},
|
||
update(particle, frameScale) {
|
||
if (!particle.active) return;
|
||
particle.wanderPhase += 0.03 * frameScale;
|
||
const perp = Math.sin(particle.wanderPhase) * 0.1;
|
||
const vx = particle.vx + (-Math.sign(particle.vy) || -1) * perp * 0.45;
|
||
const vy = particle.vy + (Math.sign(particle.vx) || 1) * perp * 0.45;
|
||
particle.x += vx * frameScale;
|
||
particle.y += vy * frameScale;
|
||
applyFacing(particle, vx, vy);
|
||
if (isOffScreen(particle)) {
|
||
particle.active = false;
|
||
particle.x = -999;
|
||
particle.y = -999;
|
||
}
|
||
},
|
||
tickFrame(particles) {
|
||
const now = Date.now();
|
||
|
||
// Active stream: keep launching narrow packs for 2–3s
|
||
if (burstState.streamUntil > 0) {
|
||
if (now < burstState.streamUntil) {
|
||
if (now >= burstState.nextStreamPackAt) {
|
||
launchCornersMicroPack(particles);
|
||
burstState.nextStreamPackAt = now + randInt(
|
||
modeDef.streamGapMs.min,
|
||
modeDef.streamGapMs.max
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
// Stream window ended — keep drawing on activeCornerCanvas until bats exit
|
||
burstState.streamUntil = 0;
|
||
burstState.nextStreamPackAt = 0;
|
||
burstState.streamCorner = null;
|
||
burstState.streamSpeed = 0;
|
||
burstState.nextLaunchAt = 0;
|
||
}
|
||
|
||
if (cornersGroupActive(particles)) return;
|
||
|
||
if (burstState.activeCornerCanvas) {
|
||
clearCornerCanvas(burstState.activeCornerCanvas);
|
||
burstState.activeCornerCanvas = null;
|
||
}
|
||
|
||
if (burstState.nextLaunchAt === 0) {
|
||
burstState.nextLaunchAt = now + randInt(
|
||
modeDef.groupDelayMs.min,
|
||
modeDef.groupDelayMs.max
|
||
);
|
||
return;
|
||
}
|
||
if (now >= burstState.nextLaunchAt) {
|
||
beginCornersStream(particles);
|
||
}
|
||
},
|
||
paintStatic() {
|
||
const { corner, fromVertical } = pickNextCorner();
|
||
const lane = Math.random();
|
||
const spec = cornerBurstVector(corner, fromVertical, 8, lane);
|
||
burstState.activeCornerCanvas = corner;
|
||
clearAllCornerCanvases();
|
||
const count = randInt(8, 14);
|
||
for (let i = 0; i < count; i++) {
|
||
const p = cornersHandlers.createParticle();
|
||
const pos = spec.spawn();
|
||
p.active = true;
|
||
p.x = pos.x + (Math.random() * 2 - 1) * 4;
|
||
p.y = pos.y + (Math.random() * 2 - 1) * 4;
|
||
applyFacing(p, spec.vx, spec.vy);
|
||
drawParticle(p);
|
||
}
|
||
},
|
||
onStart(particles) {
|
||
beginCornersStream(particles);
|
||
}
|
||
};
|
||
|
||
const MODE_HANDLERS = {
|
||
fall: fallHandlers,
|
||
fly: flyHandlers,
|
||
corners: cornersHandlers
|
||
};
|
||
|
||
const handlers = MODE_HANDLERS[modeDef.id] || fallHandlers;
|
||
|
||
const particles = Array.from({ length: modeDef.poolSize }, (_, i) => {
|
||
if (modeDef.id === 'fall') {
|
||
return handlers.createParticle(true, i);
|
||
}
|
||
return handlers.createParticle();
|
||
});
|
||
|
||
function particleOpacity(particle) {
|
||
return handlers.opacity(particle);
|
||
}
|
||
|
||
function drawParticle(particle) {
|
||
const opacity = particleOpacity(particle);
|
||
if (opacity <= 0.01) return;
|
||
if (modeDef.id !== 'fall' && !particle.active) return;
|
||
|
||
let drawCtx = ctx;
|
||
let dx = particle.x;
|
||
let dy = particle.y;
|
||
if (useCornerCanvases) {
|
||
const cornerId = burstState.activeCornerCanvas;
|
||
drawCtx = getActiveCornerCtx();
|
||
if (!drawCtx || !cornerId) return;
|
||
const local = toCornerLocal(particle.x, particle.y, cornerId);
|
||
dx = local.x;
|
||
dy = local.y;
|
||
// Skip if outside this corner canvas (with small margin for sprite)
|
||
if (dx < -40 || dy < -40 || dx > cornerSize + 40 || dy > cornerSize + 40) return;
|
||
}
|
||
if (!drawCtx) return;
|
||
|
||
const size = particle.size;
|
||
drawCtx.save();
|
||
drawCtx.translate(dx, dy);
|
||
drawCtx.rotate((particle.rotation * Math.PI) / 180);
|
||
drawCtx.globalAlpha = opacity;
|
||
drawCtx.drawImage(glyphSprite, -size / 2, -size / 2, size, size);
|
||
drawCtx.restore();
|
||
}
|
||
|
||
let animationFrame = null;
|
||
let lastTime = 0;
|
||
let animating = false;
|
||
let startedBurst = false;
|
||
|
||
const stopAnimation = () => {
|
||
if (animationFrame != null) {
|
||
cancelAnimationFrame(animationFrame);
|
||
animationFrame = null;
|
||
}
|
||
animating = false;
|
||
startedBurst = false;
|
||
};
|
||
|
||
const animate = (currentTime) => {
|
||
animationFrame = requestAnimationFrame(animate);
|
||
|
||
if (!lastTime) {
|
||
lastTime = currentTime;
|
||
return;
|
||
}
|
||
|
||
const rawDelta = currentTime - lastTime;
|
||
if (rawDelta < FRAME_INTERVAL) return;
|
||
|
||
lastTime = currentTime - (rawDelta % FRAME_INTERVAL);
|
||
const dt = Math.min(rawDelta, MAX_DT_MS);
|
||
const frameScale = dt / BASE_FRAME_MS;
|
||
|
||
if (!startedBurst && handlers.onStart) {
|
||
handlers.onStart(particles);
|
||
startedBurst = true;
|
||
}
|
||
|
||
if (handlers.tickFrame) {
|
||
handlers.tickFrame(particles);
|
||
}
|
||
|
||
clearActiveSurface();
|
||
for (let i = 0; i < particles.length; i++) {
|
||
const particle = particles[i];
|
||
handlers.update(particle, frameScale);
|
||
drawParticle(particle);
|
||
}
|
||
};
|
||
|
||
const renderStatic = () => {
|
||
if (useCornerCanvases) {
|
||
clearAllCornerCanvases();
|
||
} else {
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
}
|
||
handlers.paintStatic();
|
||
};
|
||
|
||
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
const mobileMq = window.matchMedia('(max-width: 899px)');
|
||
|
||
const isHomePageActive = () =>
|
||
document.documentElement.getAttribute('data-active-page') === 'home';
|
||
|
||
const shouldRunAnimation = () =>
|
||
!document.hidden
|
||
&& isHomePageActive()
|
||
&& !mobileMq.matches
|
||
&& !reducedMotionMq.matches;
|
||
|
||
const shouldPaintStatic = () =>
|
||
!document.hidden
|
||
&& isHomePageActive()
|
||
&& !mobileMq.matches
|
||
&& reducedMotionMq.matches;
|
||
|
||
const startAnimation = () => {
|
||
if (animating) return;
|
||
animating = true;
|
||
lastTime = 0;
|
||
startedBurst = false;
|
||
burstState.nextLaunchAt = 0;
|
||
burstState.lastCorner = null;
|
||
burstState.streamUntil = 0;
|
||
burstState.nextStreamPackAt = 0;
|
||
burstState.streamCorner = null;
|
||
burstState.streamSpeed = 0;
|
||
burstState.activeCornerCanvas = null;
|
||
if (modeDef.id === 'fly' || modeDef.id === 'corners') {
|
||
particles.forEach((p) => {
|
||
p.active = false;
|
||
p.x = -999;
|
||
p.y = -999;
|
||
});
|
||
}
|
||
if (useCornerCanvases) clearAllCornerCanvases();
|
||
animationFrame = requestAnimationFrame(animate);
|
||
};
|
||
|
||
const syncAnimationRunning = () => {
|
||
if (shouldRunAnimation()) {
|
||
if (!animating) startAnimation();
|
||
return;
|
||
}
|
||
|
||
stopAnimation();
|
||
if (shouldPaintStatic()) {
|
||
renderStatic();
|
||
} else if (useCornerCanvases) {
|
||
clearAllCornerCanvases();
|
||
} else {
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
}
|
||
};
|
||
|
||
const onVisibilityChange = () => syncAnimationRunning();
|
||
const onReducedMotionChange = () => syncAnimationRunning();
|
||
const onMobileChange = () => syncAnimationRunning();
|
||
|
||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||
reducedMotionMq.addEventListener('change', onReducedMotionChange);
|
||
mobileMq.addEventListener('change', onMobileChange);
|
||
|
||
let pageAttrObserver = null;
|
||
if (typeof MutationObserver !== 'undefined') {
|
||
pageAttrObserver = new MutationObserver(() => syncAnimationRunning());
|
||
pageAttrObserver.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['data-active-page']
|
||
});
|
||
}
|
||
|
||
syncAnimationRunning();
|
||
|
||
const cleanup = () => {
|
||
stopAnimation();
|
||
if (resizeTimer != null) clearTimeout(resizeTimer);
|
||
window.removeEventListener('resize', onResize);
|
||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||
reducedMotionMq.removeEventListener('change', onReducedMotionChange);
|
||
mobileMq.removeEventListener('change', onMobileChange);
|
||
if (pageAttrObserver) pageAttrObserver.disconnect();
|
||
};
|
||
|
||
window.addEventListener('beforeunload', cleanup);
|
||
}
|
||
|
||
addSeasonalOverlay();
|