Add Corners seasonal animation style for Halloween flying bats animation

This commit is contained in:
HighImKevin committed 2026-10-02 05:53:32 -04:00
1 parent d558a0da83
commit 3276720c93
1 file changed
+683 -179
+683 -179
View File
@@ -10,32 +10,53 @@ function addSeasonalOverlay() {
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 animationStyle = document.body.dataset.seasonalAnimationStyle === 'fly' ? 'fly' : 'fall';
const isFly = animationStyle === 'fly';
// fly: bats burst from a top-center "window", arc outward; fade before strip bottom
const CANVAS_HEIGHT_PX = isFly ? 220 : 160;
const FADE_START_Y = isFly ? 170 : CANVAS_HEIGHT_PX;
const PARTICLE_AMOUNT = isFly ? 8 : 15;
const PARTICLE_SIZE = { min: 10, max: 22 };
const PARTICLE_SPEED = isFly
? { min: 0.35, max: 0.7 }
: { min: 0.2, max: 0.7 };
// Window aperture near top-center (fraction of canvas width)
const FLY_WINDOW_WIDTH_FRAC = 0.18;
const FLY_WINDOW_Y = -8;
// Outward curve: horizontal accel grows as they leave the window
const FLY_OUTWARD_ACCEL = { min: 0.018, max: 0.038 };
const FLY_OUTWARD_MAX = 3.2;
const FLY_DOWN_ACCEL = { min: 0.008, max: 0.018 };
const FLY_DOWN_MAX = 2.4;
// Organic wing-wobble
const FLY_WOBBLE_AMP = { min: 1.2, max: 2.8 };
const FLY_WOBBLE_SPEED = { min: 0.08, max: 0.16 };
const FLY_ROT_WOBBLE = { min: 8, max: 22 };
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
const BASE_FRAME_MS = 1000 / 60;
@@ -43,9 +64,49 @@ function addSeasonalOverlay() {
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');
style.textContent = `
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;
@@ -55,21 +116,43 @@ function addSeasonalOverlay() {
top: 0;
left: 0;
width: 100%;
height: ${CANVAS_HEIGHT_PX}px;
height: ${modeDef.canvas.heightPx || 160}px;
pointer-events: none;
z-index: 1000;
}
`;
}
document.head.appendChild(style);
const canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d', { alpha: true });
/** @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 });
}
/** CSS-pixel size used for physics / clearRect (not buffer pixels). */
let cssWidth = window.innerWidth || 1;
let cssHeight = CANVAS_HEIGHT_PX;
let cssHeight = useCornerCanvases
? Math.max(1, window.innerHeight || 1)
: canvasHeightCss();
let cornerSize = useCornerCanvases ? cornerSizeCss() : 0;
const buildGlyphSprite = () => {
const sprite = document.createElement('canvas');
@@ -87,8 +170,21 @@ function addSeasonalOverlay() {
const resizeCanvas = () => {
cssWidth = Math.max(1, window.innerWidth || 1);
cssHeight = CANVAS_HEIGHT_PX;
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);
@@ -109,123 +205,544 @@ function addSeasonalOverlay() {
window.addEventListener('resize', onResize);
resizeCanvas();
const particleOpacity = (particle) => {
if (particle.opacity != null) return particle.opacity;
if (!isFly) {
return Math.max(0, 1 - particle.y / cssHeight);
}
if (particle.y <= FADE_START_Y) {
return 1;
}
const fadeRange = Math.max(1, cssHeight - FADE_START_Y);
return Math.max(0, 1 - (particle.y - FADE_START_Y) / fadeRange);
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
};
const flyWindowCenterX = () => cssWidth * 0.5;
const flyWindowHalfWidth = () => Math.max(40, cssWidth * FLY_WINDOW_WIDTH_FRAC * 0.5);
function getActiveCornerCtx() {
const id = burstState.activeCornerCanvas || burstState.streamCorner;
return id ? cornerLayers[id]?.ctx || null : null;
}
/** Spawn from top-center window; side picks outward curve direction. */
const initFlyMotion = (particle, index = 0) => {
const center = flyWindowCenterX();
const half = flyWindowHalfWidth();
// Cluster in the window; slight stagger so they don't stack
particle.x = center + (Math.random() * 2 - 1) * half * 0.85;
particle.y = FLY_WINDOW_Y - (index * 14) - Math.random() * 24;
// Bias side from spawn position relative to center (with a little flip chance)
let side = particle.x >= center ? 1 : -1;
if (Math.random() < 0.18) side *= -1;
function clearCornerCanvas(id) {
const layer = cornerLayers[id];
if (!layer) return;
layer.ctx.clearRect(0, 0, cornerSize, cornerSize);
}
const outwardAccel = (Math.random() * (FLY_OUTWARD_ACCEL.max - FLY_OUTWARD_ACCEL.min) + FLY_OUTWARD_ACCEL.min) * side;
// Start mostly downward with a small outward kick, then curve harder
particle.vx = side * (0.15 + Math.random() * 0.45);
particle.vy = Math.random() * (PARTICLE_SPEED.max - PARTICLE_SPEED.min) + PARTICLE_SPEED.min;
particle.outwardAccel = outwardAccel;
particle.downAccel = Math.random() * (FLY_DOWN_ACCEL.max - FLY_DOWN_ACCEL.min) + FLY_DOWN_ACCEL.min;
particle.wobblePhase = Math.random() * Math.PI * 2;
particle.wobbleSpeed = Math.random() * (FLY_WOBBLE_SPEED.max - FLY_WOBBLE_SPEED.min) + FLY_WOBBLE_SPEED.min;
particle.wobbleAmp = Math.random() * (FLY_WOBBLE_AMP.max - FLY_WOBBLE_AMP.min) + FLY_WOBBLE_AMP.min;
particle.wobblePhase2 = Math.random() * Math.PI * 2;
particle.wobbleSpeed2 = particle.wobbleSpeed * (0.55 + Math.random() * 0.5);
particle.wobbleAmp2 = particle.wobbleAmp * (0.35 + Math.random() * 0.4);
// Face along initial flight direction (emoji bat points up at 0deg → +180 for down)
const aimDeg = (Math.atan2(particle.vy, particle.vx) * 180) / Math.PI + 90;
particle.rotation = aimDeg + (Math.random() * 20 - 10);
particle.rotationSpeed = (Math.random() * 1.2 - 0.6) * side;
particle.rotWobbleAmp = Math.random() * (FLY_ROT_WOBBLE.max - FLY_ROT_WOBBLE.min) + FLY_ROT_WOBBLE.min;
particle.side = side;
};
function clearAllCornerCanvases() {
CORNER_IDS.forEach(clearCornerCanvas);
}
const createParticle = (isAnimated = true, index = 0) => {
if (isFly) {
const particle = {
x: 0,
y: 0,
size: Math.random() * (PARTICLE_SIZE.max - PARTICLE_SIZE.min) + PARTICLE_SIZE.min,
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
speed: 0,
drift: 0,
rotation: 180,
rotationSpeed: 0
};
if (isAnimated) {
initFlyMotion(particle, index);
} else {
// Static snapshot: scatter mid-arc for reduced-motion
initFlyMotion(particle, index);
const t = 0.25 + Math.random() * 0.55;
particle.x += particle.vx * t * 40 + particle.outwardAccel * t * t * 200;
particle.y += particle.vy * t * 40;
}
return particle;
}
return {
x: Math.random() * cssWidth,
y: isAnimated
? -20 - (index * cssHeight) / PARTICLE_AMOUNT
: Math.random() * Math.min(FADE_START_Y, cssHeight),
size: Math.random() * (PARTICLE_SIZE.max - PARTICLE_SIZE.min) + PARTICLE_SIZE.min,
speed: Math.random() * (PARTICLE_SPEED.max - PARTICLE_SPEED.min) + PARTICLE_SPEED.min,
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
};
};
const resetParticle = (particle) => {
if (isFly) {
initFlyMotion(particle, Math.floor(Math.random() * PARTICLE_AMOUNT));
function clearActiveSurface() {
if (useCornerCanvases) {
const id = burstState.activeCornerCanvas || burstState.streamCorner;
if (id) clearCornerCanvas(id);
return;
}
particle.y = -10 - Math.random() * 30;
particle.x = Math.random() * cssWidth;
particle.speed = Math.random() * (PARTICLE_SPEED.max - PARTICLE_SPEED.min) + PARTICLE_SPEED.min;
particle.drift = Math.random() * 0.4 - 0.2;
particle.rotation = Math.random() * 360;
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));
}
}
};
const drawParticle = (particle) => {
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;
ctx.save();
ctx.translate(particle.x, particle.y);
ctx.rotate((particle.rotation * Math.PI) / 180);
ctx.globalAlpha = opacity;
ctx.drawImage(glyphSprite, -size / 2, -size / 2, size, size);
ctx.restore();
};
const particles = Array.from(
{ length: PARTICLE_AMOUNT },
(_, i) => createParticle(true, i)
);
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) {
@@ -233,49 +750,7 @@ function addSeasonalOverlay() {
animationFrame = null;
}
animating = false;
};
const updateParticle = (particle, frameScale) => {
if (isFly) {
particle.vx += (particle.outwardAccel || 0) * frameScale;
const maxOut = FLY_OUTWARD_MAX;
if (Math.abs(particle.vx) > maxOut) {
particle.vx = Math.sign(particle.vx) * maxOut;
}
particle.vy = Math.min(
FLY_DOWN_MAX,
particle.vy + (particle.downAccel || 0) * frameScale
);
particle.wobblePhase += particle.wobbleSpeed * frameScale;
particle.wobblePhase2 += particle.wobbleSpeed2 * frameScale;
const wobbleX =
Math.sin(particle.wobblePhase) * particle.wobbleAmp +
Math.sin(particle.wobblePhase2) * particle.wobbleAmp2;
const wobbleY =
Math.cos(particle.wobblePhase * 0.85) * (particle.wobbleAmp * 0.35) +
Math.sin(particle.wobblePhase2 * 1.3) * (particle.wobbleAmp2 * 0.5);
particle.x += (particle.vx + wobbleX * 0.15) * frameScale;
particle.y += (particle.vy + wobbleY * 0.12) * frameScale;
const aimDeg = (Math.atan2(particle.vy, particle.vx) * 180) / Math.PI + 90;
const rotFlutter = Math.sin(particle.wobblePhase) * particle.rotWobbleAmp
+ Math.sin(particle.wobblePhase2) * (particle.rotWobbleAmp * 0.4);
particle.rotation = aimDeg + rotFlutter + particle.rotationSpeed * frameScale;
} else {
particle.y += particle.speed * frameScale;
particle.x += particle.drift * frameScale;
particle.rotation += particle.rotationSpeed * frameScale;
}
const offBottom = particle.y > cssHeight + 20;
const fadedOut = isFly && particle.y > FADE_START_Y && particleOpacity(particle) <= 0.02;
const offSides = particle.x < -50 || particle.x > cssWidth + 50;
if (offBottom || fadedOut || offSides) {
resetParticle(particle);
}
startedBurst = false;
};
const animate = (currentTime) => {
@@ -293,19 +768,30 @@ function addSeasonalOverlay() {
const dt = Math.min(rawDelta, MAX_DT_MS);
const frameScale = dt / BASE_FRAME_MS;
ctx.clearRect(0, 0, cssWidth, cssHeight);
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];
updateParticle(particle, frameScale);
handlers.update(particle, frameScale);
drawParticle(particle);
}
};
const renderStatic = () => {
ctx.clearRect(0, 0, cssWidth, cssHeight);
for (let i = 0; i < PARTICLE_AMOUNT; i++) {
drawParticle(createParticle(false));
if (useCornerCanvases) {
clearAllCornerCanvases();
} else {
ctx.clearRect(0, 0, cssWidth, cssHeight);
}
handlers.paintStatic();
};
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -330,6 +816,22 @@ function addSeasonalOverlay() {
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);
};
@@ -342,6 +844,8 @@ function addSeasonalOverlay() {
stopAnimation();
if (shouldPaintStatic()) {
renderStatic();
} else if (useCornerCanvases) {
clearAllCornerCanvases();
} else {
ctx.clearRect(0, 0, cssWidth, cssHeight);
}