402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
/**
|
|
* SnowballBackground
|
|
* Círculos huecos flotantes que se fusionan en el mouse aumentando su radio
|
|
* y se desintegran expulsando todas las partículas.
|
|
*
|
|
* Uso:
|
|
* 1) Automático: agregá la clase .snowball-bg a cualquier contenedor
|
|
* 2) Manual: new SnowballBackground({ containerId: 'mi-id' })
|
|
*
|
|
* Tema: lee variables CSS del contenedor o :root y reacciona a cambios de tema.
|
|
*/
|
|
class SnowballBackground {
|
|
constructor(options = {}) {
|
|
// --- Resolver contenedor (acepta elemento, ID o selector) ---
|
|
if (options.container instanceof HTMLElement) {
|
|
this.container = options.container;
|
|
} else if (options.containerId) {
|
|
this.container = document.getElementById(options.containerId);
|
|
} else if (options.containerSelector) {
|
|
this.container = document.querySelector(options.containerSelector);
|
|
}
|
|
|
|
if (!this.container) {
|
|
console.warn('SnowballBackground: no se encontró el contenedor.', options);
|
|
return;
|
|
}
|
|
|
|
// Evitar doble inicialización en el mismo contenedor
|
|
if (this.container._snowballInstance) {
|
|
return;
|
|
}
|
|
this.container._snowballInstance = this;
|
|
|
|
// --- Configuración (defaults, se sobreescriben con CSS vars y options) ---
|
|
this.config = {
|
|
particleCount: 70,
|
|
maxCapacity: 20,
|
|
cooldownSeconds: 2.5,
|
|
color: '#38bdf8',
|
|
lineWidth: 1.5,
|
|
minRadius: 5,
|
|
maxRadius: 10
|
|
};
|
|
|
|
// Leer variables CSS antes de aplicar options (options tienen prioridad)
|
|
this.syncThemeColors();
|
|
|
|
// Aplicar overrides manuales
|
|
if (options.particleCount) this.config.particleCount = options.particleCount;
|
|
if (options.maxCapacity) this.config.maxCapacity = options.maxCapacity;
|
|
if (options.cooldownSeconds) this.config.cooldownSeconds = options.cooldownSeconds;
|
|
if (options.color) this.config.color = options.color;
|
|
if (options.lineWidth) this.config.lineWidth = options.lineWidth;
|
|
if (options.minRadius) this.config.minRadius = options.minRadius;
|
|
if (options.maxRadius) this.config.maxRadius = options.maxRadius;
|
|
|
|
// --- Estados internos ---
|
|
this.particles = [];
|
|
this.absorbedParticles = [];
|
|
this.disintegrationShards = [];
|
|
this.mouse = { x: -1000, y: -1000, active: false };
|
|
this.isCoolingDown = false;
|
|
this.cooldownEndTime = 0;
|
|
this.currentBallRadius = 0;
|
|
|
|
// --- Init ---
|
|
this.initCanvas();
|
|
this.initParticles();
|
|
this.bindEvents();
|
|
this.observeThemeChanges();
|
|
this.loop();
|
|
}
|
|
|
|
/* =============================================================
|
|
* LECTURA DE VARIABLES CSS
|
|
* =========================================================== */
|
|
readCSSVar(name) {
|
|
// Primero busca en el contenedor, luego en :root
|
|
let value = getComputedStyle(this.container).getPropertyValue(name).trim();
|
|
if (!value) {
|
|
value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
return value || null;
|
|
}
|
|
|
|
syncThemeColors() {
|
|
const map = {
|
|
'--snowball-color': { key: 'color', parse: String },
|
|
'--snowball-particle-count': { key: 'particleCount', parse: parseInt },
|
|
'--snowball-max-capacity': { key: 'maxCapacity', parse: parseInt },
|
|
'--snowball-cooldown': { key: 'cooldownSeconds',parse: parseFloat },
|
|
'--snowball-line-width': { key: 'lineWidth', parse: parseFloat },
|
|
'--snowball-min-radius': { key: 'minRadius', parse: parseFloat },
|
|
'--snowball-max-radius': { key: 'maxRadius', parse: parseFloat }
|
|
};
|
|
|
|
for (const [cssVar, { key, parse }] of Object.entries(map)) {
|
|
const raw = this.readCSSVar(cssVar);
|
|
if (raw !== null) {
|
|
const parsed = parse(raw, 10);
|
|
// Solo aplicar si el parseo dio un valor válido
|
|
if (typeof parsed === 'string' ? parsed : !isNaN(parsed)) {
|
|
this.config[key] = parsed;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* =============================================================
|
|
* OBSERVADOR DE CAMBIOS DE TEMA
|
|
* =========================================================== */
|
|
observeThemeChanges() {
|
|
this._themeObserver = new MutationObserver(() => {
|
|
this.syncThemeColors();
|
|
});
|
|
|
|
// Observar <html> (cambios de data-theme, class, style)
|
|
this._themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme', 'class', 'style']
|
|
});
|
|
|
|
// Observar <body> también (algunos frameworks ponen el tema ahí)
|
|
this._themeObserver.observe(document.body, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme', 'class', 'style']
|
|
});
|
|
}
|
|
|
|
/* =============================================================
|
|
* CANVAS
|
|
* =========================================================== */
|
|
initCanvas() {
|
|
this.canvas = document.createElement('canvas');
|
|
this.canvas.className = 'snowball-canvas';
|
|
this.canvas.style.cssText =
|
|
'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
// El contenedor debe ser posicionable
|
|
const pos = getComputedStyle(this.container).position;
|
|
if (pos === 'static') {
|
|
this.container.style.position = 'relative';
|
|
}
|
|
|
|
this.container.appendChild(this.canvas);
|
|
this.resize();
|
|
}
|
|
|
|
resize() {
|
|
const rect = this.container.getBoundingClientRect();
|
|
this.width = rect.width;
|
|
this.height = rect.height;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
this.canvas.width = this.width * dpr;
|
|
this.canvas.height = this.height * dpr;
|
|
this.ctx.scale(dpr, dpr);
|
|
}
|
|
|
|
/* =============================================================
|
|
* PARTÍCULAS
|
|
* =========================================================== */
|
|
initParticles() {
|
|
this.particles = [];
|
|
for (let i = 0; i < this.config.particleCount; i++) {
|
|
const radius = this.randomRange(this.config.minRadius, this.config.maxRadius);
|
|
this.particles.push({
|
|
x: this.randomRange(radius, Math.max(radius + 1, this.width - radius)),
|
|
y: this.randomRange(radius, Math.max(radius + 1, this.height - radius)),
|
|
vx: (Math.random() - 0.5) * 1.6,
|
|
vy: (Math.random() - 0.5) * 1.6,
|
|
radius,
|
|
state: 'FREE'
|
|
});
|
|
}
|
|
}
|
|
|
|
bindEvents() {
|
|
this._onResize = () => this.resize();
|
|
window.addEventListener('resize', this._onResize);
|
|
|
|
this._onMouseMove = (e) => {
|
|
const rect = this.container.getBoundingClientRect();
|
|
this.mouse.x = e.clientX - rect.left;
|
|
this.mouse.y = e.clientY - rect.top;
|
|
this.mouse.active = true;
|
|
};
|
|
this.container.addEventListener('mousemove', this._onMouseMove);
|
|
|
|
this._onMouseLeave = () => { this.mouse.active = false; };
|
|
this.container.addEventListener('mouseleave', this._onMouseLeave);
|
|
}
|
|
|
|
randomRange(min, max) {
|
|
return Math.random() * (max - min) + min;
|
|
}
|
|
|
|
/* =============================================================
|
|
* CONVERSIÓN DE COLOR UNIVERSAL
|
|
* =========================================================== */
|
|
toRgba(color, alpha = 1) {
|
|
if (!color) return `rgba(0,0,0,${alpha})`;
|
|
const c = color.trim();
|
|
|
|
// rgba(...) o rgb(...)
|
|
if (c.startsWith('rgb')) {
|
|
const parts = c.match(/[\d.]+/g);
|
|
if (!parts || parts.length < 3) return `rgba(0,0,0,${alpha})`;
|
|
const [r, g, b] = parts;
|
|
const origA = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
|
|
return `rgba(${r},${g},${b},${(origA * alpha).toFixed(3)})`;
|
|
}
|
|
|
|
// #hex (3, 4, 6 u 8 dígitos)
|
|
if (c.startsWith('#')) {
|
|
let hex = c.slice(1);
|
|
if (hex.length === 3 || hex.length === 4) {
|
|
hex = hex.split('').map(ch => ch + ch).join('');
|
|
}
|
|
const n = parseInt(hex.slice(0, 6), 16);
|
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
|
let hexA = 1;
|
|
if (hex.length === 8) hexA = parseInt(hex.slice(6, 8), 16) / 255;
|
|
return `rgba(${r},${g},${b},${(hexA * alpha).toFixed(3)})`;
|
|
}
|
|
|
|
// Fallback: dejar que el navegador resuelva (hsl, nombres, etc.)
|
|
try {
|
|
const tmp = document.createElement('canvas').getContext('2d');
|
|
tmp.fillStyle = c;
|
|
const resolved = tmp.fillStyle;
|
|
if (resolved !== c) return this.toRgba(resolved, alpha);
|
|
} catch (_) {}
|
|
|
|
return `rgba(0,0,0,${alpha})`;
|
|
}
|
|
|
|
/* =============================================================
|
|
* EXPLOSIÓN
|
|
* =========================================================== */
|
|
explode() {
|
|
const ex = this.mouse.x, ey = this.mouse.y;
|
|
const br = this.currentBallRadius;
|
|
const count = this.absorbedParticles.length;
|
|
|
|
// Fragmentos de desintegración
|
|
const shardCount = 8;
|
|
const arcStep = (Math.PI * 2) / shardCount;
|
|
for (let i = 0; i < shardCount; i++) {
|
|
const angle = i * arcStep;
|
|
this.disintegrationShards.push({
|
|
x: ex, y: ey, radius: br,
|
|
startAngle: angle + 0.1,
|
|
endAngle: angle + arcStep - 0.1,
|
|
vx: Math.cos(angle + arcStep / 2) * 3,
|
|
vy: Math.sin(angle + arcStep / 2) * 3,
|
|
alpha: 1
|
|
});
|
|
}
|
|
|
|
// Dispersar partículas
|
|
this.absorbedParticles.forEach((p, i) => {
|
|
const angle = (Math.PI * 2 / count) * i + (Math.random() - 0.5) * 0.3;
|
|
const force = this.randomRange(10, 18);
|
|
p.x = ex + Math.cos(angle) * (br * 0.8);
|
|
p.y = ey + Math.sin(angle) * (br * 0.8);
|
|
p.vx = Math.cos(angle) * force;
|
|
p.vy = Math.sin(angle) * force;
|
|
p.state = 'EXPLODED';
|
|
});
|
|
|
|
this.absorbedParticles = [];
|
|
this.currentBallRadius = 0;
|
|
this.isCoolingDown = true;
|
|
this.cooldownEndTime = performance.now() + this.config.cooldownSeconds * 1000;
|
|
}
|
|
|
|
/* =============================================================
|
|
* UPDATE
|
|
* =========================================================== */
|
|
update() {
|
|
const now = performance.now();
|
|
if (this.isCoolingDown && now >= this.cooldownEndTime) {
|
|
this.isCoolingDown = false;
|
|
}
|
|
|
|
let targetRadius = 0;
|
|
if (this.absorbedParticles.length > 0) {
|
|
targetRadius = 14 + (this.absorbedParticles.length / this.config.maxCapacity) * 46;
|
|
}
|
|
this.currentBallRadius += (targetRadius - this.currentBallRadius) * 0.15;
|
|
|
|
const hitRadius = this.absorbedParticles.length === 0 ? 14 : this.currentBallRadius;
|
|
|
|
this.particles.forEach((p) => {
|
|
if (p.state === 'FREE') {
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
if (p.x - p.radius <= 0) { p.x = p.radius; p.vx *= -1; }
|
|
if (p.x + p.radius >= this.width) { p.x = this.width - p.radius; p.vx *= -1; }
|
|
if (p.y - p.radius <= 0) { p.y = p.radius; p.vy *= -1; }
|
|
if (p.y + p.radius >= this.height) { p.y = this.height - p.radius; p.vy *= -1; }
|
|
|
|
if (this.mouse.active && !this.isCoolingDown) {
|
|
const dx = p.x - this.mouse.x, dy = p.y - this.mouse.y;
|
|
if (Math.sqrt(dx * dx + dy * dy) < hitRadius + p.radius) {
|
|
p.state = 'ABSORBED';
|
|
this.absorbedParticles.push(p);
|
|
if (this.absorbedParticles.length >= this.config.maxCapacity) {
|
|
this.explode();
|
|
}
|
|
}
|
|
}
|
|
} else if (p.state === 'EXPLODED') {
|
|
p.x += p.vx; p.y += p.vy;
|
|
p.vx *= 0.96; p.vy *= 0.96;
|
|
if (p.x - p.radius <= 0) { p.x = p.radius; p.vx *= -1; }
|
|
if (p.x + p.radius >= this.width) { p.x = this.width - p.radius; p.vx *= -1; }
|
|
if (p.y - p.radius <= 0) { p.y = p.radius; p.vy *= -1; }
|
|
if (p.y + p.radius >= this.height) { p.y = this.height - p.radius; p.vy *= -1; }
|
|
if (Math.sqrt(p.vx * p.vx + p.vy * p.vy) < 1.6) p.state = 'FREE';
|
|
}
|
|
});
|
|
|
|
for (let i = this.disintegrationShards.length - 1; i >= 0; i--) {
|
|
const s = this.disintegrationShards[i];
|
|
s.x += s.vx; s.y += s.vy;
|
|
s.radius += 0.8;
|
|
s.alpha -= 0.05;
|
|
if (s.alpha <= 0) this.disintegrationShards.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/* =============================================================
|
|
* DRAW (todos los colores pasan por toRgba)
|
|
* =========================================================== */
|
|
draw() {
|
|
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
const color = this.config.color;
|
|
const lw = this.config.lineWidth;
|
|
|
|
// 1. Partículas libres / explotadas
|
|
this.ctx.lineWidth = lw;
|
|
this.ctx.strokeStyle = this.toRgba(color, 1);
|
|
this.particles.forEach((p) => {
|
|
if (p.state !== 'ABSORBED') {
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
|
this.ctx.stroke();
|
|
}
|
|
});
|
|
|
|
// 2. Fragmentos de desintegración
|
|
this.disintegrationShards.forEach((s) => {
|
|
this.ctx.save();
|
|
this.ctx.lineWidth = lw + 0.5;
|
|
this.ctx.strokeStyle = this.toRgba(color, s.alpha);
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(s.x, s.y, s.radius, s.startAngle, s.endAngle);
|
|
this.ctx.stroke();
|
|
this.ctx.restore();
|
|
});
|
|
|
|
// 3. Bola en el mouse
|
|
if (this.mouse.active && !this.isCoolingDown && this.currentBallRadius > 2) {
|
|
this.ctx.save();
|
|
this.ctx.lineWidth = lw + 0.5;
|
|
this.ctx.strokeStyle = this.toRgba(color, 1);
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(this.mouse.x, this.mouse.y, this.currentBallRadius, 0, Math.PI * 2);
|
|
this.ctx.stroke();
|
|
this.ctx.restore();
|
|
}
|
|
}
|
|
|
|
/* =============================================================
|
|
* LOOP
|
|
* =========================================================== */
|
|
loop() {
|
|
this.update();
|
|
this.draw();
|
|
requestAnimationFrame(() => this.loop());
|
|
}
|
|
}
|
|
|
|
/* ==============================================================
|
|
* AUTO-INICIALIZACIÓN
|
|
* Busca todos los elementos con clase .snowball-bg
|
|
* ============================================================== */
|
|
function initSnowballBackgrounds() {
|
|
document.querySelectorAll('.snowball-bg').forEach((el) => {
|
|
if (!el._snowballInstance) {
|
|
new SnowballBackground({ container: el });
|
|
}
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initSnowballBackgrounds);
|
|
} else {
|
|
initSnowballBackgrounds();
|
|
} |