← AE Lab
El botón que te atrae
Un botón de llamada a la acción con imán: se acerca al cursor estirándose como una goma, enseña el hueco de donde sale y, al soltarlo, vuelve rebotando como una gelatina. Al pulsarlo estalla en partículas que escriben «¡Hecho!» y vuelven a ser botón. En el móvil salta hacia el dedo.
// CÓMO ESTÁ HECHO
- Se mide la distancia del puntero al centro del botón; si entra en su radio, el objetivo es acercarse un 40 % hacia él, con la fuerza apagándose suave en el borde para que no dé tirones.
- Un muelle con rozamiento lleva el botón al objetivo en cada fotograma (v = (v + (objetivo − x) · 0,12) · 0,78). Al soltarlo cambia a un muelle más duro y con menos rozamiento (0,19 y 0,885): se pasa, vuelve y rebota como una gelatina.
- El detalle pro: el botón se estira en la dirección en la que se mueve con rotate(ángulo) scale(1 + e, 1 − e/2) rotate(−ángulo), y una silueta discontinua y una goma de luz marcan el sitio de reposo. Además se inclina en 3D y el texto se mueve un 45 % más (paralaje).
- Al pulsar, se dibujan a escondidas el botón y la palabra «¡Hecho!» en canvas ocultos; con getImageData se sacan sus píxeles y cada partícula viaja con un muelle de una forma a la otra y vuelve.
- Accesible y en móvil: es un <button> de verdad (Intro, Espacio y Tab, que también lo despierta), avisa por aria-live, en el móvil salta hacia el dedo y vibra al pulsar, y con prefers-reduced-motion solo cambia el texto. Un cursor fantasma lo enseña en bucle hasta que lo tocas.
// EL CÓDIGO
<div class="pagina" id="pagina">
<canvas id="campo" aria-hidden="true"></canvas>
<div class="halo" id="halo" aria-hidden="true"></div>
<canvas id="goma" aria-hidden="true"></canvas>
<header class="cabecera">
<div class="marca">
<!-- Logotipo inventado: una brújula -->
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="14" fill="none" stroke="#00FF7F" stroke-width="2"/><path d="M16 6l4 10-4 10-4-10z" fill="#00FF7F"/><circle cx="16" cy="16" r="2" fill="#05070A"/></svg>
<div>Rumbo<small>academia online</small></div>
</div>
<ul class="menu"><li>Cursos</li><li>Mentores</li><li>Precios</li><li>Opiniones</li></ul>
<span class="entrar">Entrar</span>
</header>
<main class="portada">
<p class="antetitulo"><i></i>Matrícula abierta · grupo de noviembre</p>
<h1>Aprende a programar <em>sin perder el rumbo</em>.</h1>
<p class="entradilla">12 semanas, proyectos reales y un mentor por cada ocho alumnos. Online y a tu ritmo.</p>
<div class="iman" id="iman">
<span class="ancla" id="ancla" aria-hidden="true"></span>
<button class="cta" id="cta" type="button">
<span class="cta-texto" id="ctaTexto">Reserva tu plaza
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>
</div>
<p class="nota">Quedan <b>14 plazas</b> · sin permanencia</p>
</main>
<ul class="datos">
<li><strong>12 sem.</strong><span>de curso intensivo</span></li>
<li><strong>1 : 8</strong><span>mentor por alumnos</span></li>
<li><strong>94 %</strong><span>termina el curso</span></li>
</ul>
<canvas id="particulas" aria-hidden="true"></canvas>
<div class="fantasma" id="fantasma" aria-hidden="true">
<svg viewBox="0 0 26 26"><path d="M3 2l18 9.5-7.6 1.9L9.6 21z" fill="#E8F5EE" stroke="#05070A" stroke-width="1.4" stroke-linejoin="round"/></svg>
<div class="dedo"></div>
</div>
<p class="solo-lector" id="aviso" aria-live="polite"></p>
</div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* La página entera: el dedo se puede arrastrar sin que el navegador haga scroll */
.pagina {
position: relative;
height: 100dvh;
display: flex; flex-direction: column;
overflow: hidden;
touch-action: none;
user-select: none; -webkit-user-select: none;
}
/* Capa 1: la rejilla de puntos que el imán deforma (canvas, detrás de todo) */
#campo { position: absolute; inset: 0; width: 100%; height: 100%; }
/* El halo: un círculo de luz que persigue al cursor cerca del botón */
.halo {
position: absolute; left: 0; top: 0;
width: 420px; height: 420px; margin: -210px 0 0 -210px;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(0, 255, 127, .32), rgba(0, 255, 127, .08) 55%, transparent);
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
/* Cabecera de la academia de ejemplo */
.cabecera {
position: relative;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 20px clamp(16px, 4vw, 48px);
}
.marca { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -.01em; }
.marca svg { width: 28px; height: 28px; }
.marca small { display: block; font: 500 11px/1 ui-monospace, monospace; color: var(--gris); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; }
.menu { display: flex; gap: 28px; list-style: none; color: var(--gris); font-size: 14px; }
.entrar {
font: 600 13px/1 system-ui, sans-serif; color: var(--blanco);
border: 1px solid var(--borde); background: var(--superficie);
padding: 10px 16px; border-radius: 99px;
}
/* Portada */
.portada {
position: relative; flex: 1;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center;
padding: 0 16px 8px;
}
.antetitulo {
display: inline-flex; align-items: center; gap: 8px;
font: 500 12px/1 ui-monospace, "JetBrains Mono", monospace; letter-spacing: .06em; text-transform: uppercase;
color: var(--verde);
border: 1px solid var(--borde); background: rgba(13, 20, 16, .7);
padding: 8px 12px; border-radius: 99px;
}
.antetitulo i { width: 7px; height: 7px; border-radius: 50%; background: var(--verde); box-shadow: 0 0 10px var(--verde); }
h1 {
margin-top: 22px;
font-size: clamp(38px, 6.4vw, 88px); line-height: .98; letter-spacing: -.045em; font-weight: 800;
max-width: 14ch;
}
h1 em { font-style: normal; color: var(--verde); }
.entradilla { margin-top: 18px; color: var(--gris); max-width: 46ch; line-height: 1.5; font-size: clamp(15px, 1.3vw, 18px); }
/* El imán: una caja quieta que marca la posición de reposo del botón */
.iman { position: relative; margin-top: clamp(30px, 5vh, 52px); padding: 10px; }
/* El ancla: la silueta del sitio de reposo, que se ve cuando el imán saca al botón de él */
.ancla {
position: absolute; inset: 10px;
border-radius: 99px;
border: 1.5px dashed rgba(0, 255, 127, .55);
opacity: 0; pointer-events: none;
will-change: opacity, transform;
}
/* La goma: una línea elástica entre el ancla y el botón (canvas propio, debajo del botón) */
#goma { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.cta {
appearance: none; border: 0; cursor: pointer;
display: block;
font: 700 17px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--fondo);
background: var(--verde);
height: 64px; padding: 0 34px;
border-radius: 99px;
box-shadow: 0 14px 40px rgba(0, 255, 127, .25), inset 0 -3px 0 rgba(0, 0, 0, .18);
will-change: transform, opacity;
transition: opacity .35s;
}
/* El texto va en su propia capa para moverse más que el botón (paralaje) */
.cta-texto { display: flex; align-items: center; gap: 10px; pointer-events: none; will-change: transform; }
.cta-texto svg { width: 18px; height: 18px; }
.cta:focus-visible { outline: 3px solid var(--blanco); outline-offset: 6px; }
/* Al estallar desaparece de golpe (las partículas toman su sitio); al volver, con fundido */
.cta.oculto { opacity: 0; pointer-events: none; transition: none; }
/* Mientras se forma «¡Hecho!», los textos de alrededor se apagan para dejarle sitio */
.entradilla, .nota { transition: opacity .4s; }
.estallando .entradilla, .estallando .nota { opacity: .12; }
.nota { margin-top: 18px; font: 500 12px/1.4 ui-monospace, monospace; color: var(--gris); letter-spacing: .04em; }
.nota b { color: var(--blanco); font-weight: 600; }
/* Tres datos de la academia */
.datos {
position: relative;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
width: min(760px, calc(100% - 32px)); margin: 0 auto calc(22px + env(safe-area-inset-bottom));
list-style: none;
}
.datos li {
border: 1px solid var(--borde); background: rgba(13, 20, 16, .75);
border-radius: 16px; padding: 14px 16px; text-align: left;
}
.datos strong { display: block; font: 700 clamp(20px, 2.4vw, 28px)/1 ui-monospace, monospace; letter-spacing: -.03em; }
.datos span { display: block; margin-top: 6px; font-size: 12px; color: var(--gris); line-height: 1.3; }
/* Capa 2: las partículas de la explosión (encima de todo, no recibe clics) */
#particulas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
/* El cursor fantasma del modo demostración */
.fantasma {
position: absolute; left: 0; top: 0; pointer-events: none;
opacity: 0; will-change: transform, opacity;
}
.fantasma svg { display: block; width: 26px; height: 26px; transform-origin: 3px 3px; transition: transform .15s; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, .6)); }
.fantasma.pulsa svg { transform: scale(.78); }
/* En pantallas táctiles el fantasma es una yema de dedo, no una flecha */
.fantasma .dedo { display: none; width: 44px; height: 44px; margin: -22px 0 0 -22px; border-radius: 50%; background: rgba(232, 245, 238, .22); border: 2px solid rgba(232, 245, 238, .7); transition: transform .15s; }
.tactil .fantasma svg { display: none; }
.tactil .fantasma .dedo { display: block; }
.tactil .fantasma.pulsa .dedo { transform: scale(.8); background: rgba(232, 245, 238, .4); }
.solo-lector { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* Móvil */
@media (max-width: 700px) {
.menu { display: none; }
.cabecera { padding: 16px; }
.antetitulo { font-size: 10.5px; }
h1 { margin-top: 18px; }
.entradilla { font-size: 15px; max-width: 32ch; }
.cta { height: 60px; padding: 0 28px; font-size: 16px; }
.datos { gap: 8px; }
.datos li { padding: 12px; border-radius: 14px; }
.datos span { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cta, .cta-texto { transition: opacity .3s; }
}
(() => {
/* ---------- Piezas y ajustes ---------- */
const pagina = document.getElementById('pagina');
const campo = document.getElementById('campo');
const cc = campo.getContext('2d');
const lienzo = document.getElementById('particulas');
const pc = lienzo.getContext('2d');
const halo = document.getElementById('halo');
const iman = document.getElementById('iman');
const cta = document.getElementById('cta');
const ctaTexto = document.getElementById('ctaTexto');
const fantasma = document.getElementById('fantasma');
const aviso = document.getElementById('aviso');
const ancla = document.getElementById('ancla');
const goma = document.getElementById('goma');
const gc = goma.getContext('2d');
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const tactil = matchMedia('(pointer: coarse)').matches;
if (tactil) pagina.classList.add('tactil');
let ancho = 0, alto = 0, dpr = 1;
let centro = { x: 0, y: 0 }; // centro del botón en reposo
let radio = 200; // hasta dónde llega la atracción
let puntos = []; // rejilla del fondo
/* ---------- Medidas: se recalculan al cambiar el tamaño ---------- */
function medir() {
ancho = innerWidth; alto = innerHeight;
dpr = Math.min(devicePixelRatio || 1, 2);
for (const c of [campo, lienzo, goma]) { c.width = ancho * dpr; c.height = alto * dpr; }
cc.setTransform(dpr, 0, 0, dpr, 0, 0);
pc.setTransform(dpr, 0, 0, dpr, 0, 0);
gc.setTransform(dpr, 0, 0, dpr, 0, 0);
const r = iman.getBoundingClientRect();
centro = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
radio = Math.max(150, cta.offsetWidth * 0.85);
// Rejilla de puntos del fondo
const paso = ancho < 700 ? 22 : 28;
puntos = [];
for (let y = paso / 2; y < alto; y += paso)
for (let x = paso / 2; x < ancho; x += paso) puntos.push({ x, y });
}
/* ---------- El puntero: ratón, dedo (al mantener pulsado) o fantasma ---------- */
const puntero = { x: -9999, y: -9999, activo: false };
/* ---------- El muelle del botón ---------- */
// Posición (x, y) y velocidad (vx, vy) del desplazamiento del botón
const boton = { x: 0, y: 0, vx: 0, vy: 0, cerca: 0 };
const haloPos = { x: 0, y: 0, o: 0 };
let focoTeclado = false; // con Tab el botón también «se despierta»
function moverBoton(f) {
let ox = 0, oy = 0, cerca = 0;
if (puntero.activo && !explosion) {
const dx = puntero.x - centro.x, dy = puntero.y - centro.y;
const d = Math.hypot(dx, dy);
if (d < radio) {
// Fuerza plena en el 70 % interior y se apaga suave en el borde (sin tirones)
cerca = Math.min(1, (radio - d) / (radio * 0.3));
ox = dx * 0.4 * cerca;
oy = dy * 0.4 * cerca;
}
}
if (focoTeclado && !explosion) cerca = Math.max(cerca, 0.6);
// Dos muelles: mientras el imán tira, uno firme y frenado (sigue sin temblar);
// al soltar, uno más duro y con poco rozamiento: vuelve, se pasa y rebota como gelatina
const atrae = cerca > 0.01;
const k = atrae ? 0.12 : 0.19;
const roce = atrae ? 0.78 : 0.885;
boton.vx = (boton.vx + (ox - boton.x) * k * f) * Math.pow(roce, f);
boton.vy = (boton.vy + (oy - boton.y) * k * f) * Math.pow(roce, f);
boton.x += boton.vx * f; boton.y += boton.vy * f;
boton.cerca += (cerca - boton.cerca) * 0.12 * f;
// Estiramiento: el botón se alarga en la dirección en la que se mueve (y en la que tiran de él)
// y adelgaza en la otra, como una gota de goma. Velocidad + distancia al ancla.
const dist = Math.hypot(boton.x, boton.y);
const vel = Math.hypot(boton.vx, boton.vy);
const ax = boton.vx * 1.2 + boton.x * 0.06, ay = boton.vy * 1.2 + boton.y * 0.06;
const estira = Math.min(0.3, vel * 0.02 + (dist / radio) * 0.2);
const ang = Math.hypot(ax, ay) > 0.01 ? Math.atan2(ay, ax) * 180 / Math.PI : 0;
const sx = 1 + estira, sy = 1 - estira * 0.5;
// Inclinación 3D: el botón «mira» hacia el cursor
const giroY = Math.max(-18, Math.min(18, boton.x * 0.28));
const giroX = Math.max(-18, Math.min(18, -boton.y * 0.45));
const escala = 1 + boton.cerca * 0.06;
cta.style.transform =
`translate3d(${boton.x.toFixed(2)}px, ${boton.y.toFixed(2)}px, 0) perspective(500px) rotateX(${giroX.toFixed(2)}deg) rotateY(${giroY.toFixed(2)}deg) ` +
`rotate(${ang.toFixed(2)}deg) scale(${sx.toFixed(4)}, ${sy.toFixed(4)}) rotate(${(-ang).toFixed(2)}deg) scale(${escala.toFixed(4)})`;
// Paralaje: el texto se adelanta un 45 % más que el botón
ctaTexto.style.transform = `translate3d(${(boton.x * 0.45).toFixed(2)}px, ${(boton.y * 0.45).toFixed(2)}px, 0)`;
// El ancla (silueta del sitio de reposo) y la goma que une el botón con ella
const tension = explosion ? 0 : Math.min(1, dist / 46);
ancla.style.opacity = (tension * 0.9).toFixed(3);
gc.clearRect(0, 0, ancho, alto);
if (tension > 0.05) {
const bx = centro.x + boton.x, by = centro.y + boton.y;
// La goma se comba hacia el lado contrario al movimiento (va con retraso) y adelgaza al estirarse
const mx = (centro.x + bx) / 2 - boton.vx * 3, my = (centro.y + by) / 2 - boton.vy * 3;
gc.lineCap = 'round';
gc.strokeStyle = `rgba(0, 255, 127, ${(0.15 + tension * 0.45).toFixed(3)})`;
gc.lineWidth = Math.max(1.5, 9 - dist * 0.07);
gc.shadowColor = 'rgba(0, 255, 127, .8)'; gc.shadowBlur = 14;
gc.beginPath(); gc.moveTo(centro.x, centro.y); gc.quadraticCurveTo(mx, my, bx, by); gc.stroke();
gc.shadowBlur = 0;
}
// El halo persigue al puntero dentro del radio; si no, descansa tras el botón
const hx = cerca > 0 ? puntero.x : centro.x + boton.x;
const hy = cerca > 0 ? puntero.y : centro.y + boton.y;
haloPos.x += (hx - haloPos.x) * 0.14 * f;
haloPos.y += (hy - haloPos.y) * 0.14 * f;
haloPos.o += ((explosion ? 0 : 0.35 + boton.cerca * 0.65) - haloPos.o) * 0.1 * f;
halo.style.transform = `translate3d(${haloPos.x}px, ${haloPos.y}px, 0)`;
halo.style.opacity = haloPos.o.toFixed(3);
}
/* ---------- Fondo: puntos atraídos por el imán + onda de la explosión ---------- */
let onda = null; // { x, y, t } cuando estalla el botón
function pintarCampo(ahora) {
cc.clearRect(0, 0, ancho, alto);
const px = puntero.activo ? puntero.x : centro.x + boton.x;
const py = puntero.activo ? puntero.y : centro.y + boton.y;
const sigma2 = (radio * 1.1) ** 2;
const r = onda ? (ahora - onda.t) / 1000 * 900 : -1; // la onda avanza 900 px/s
if (onda && r > Math.hypot(ancho, alto)) onda = null;
cc.fillStyle = '#00FF7F';
for (const p of puntos) {
let dx = px - p.x, dy = py - p.y;
const d2 = dx * dx + dy * dy;
// Campanas de Gauss: los puntos cercanos se acercan y brillan más
const g = Math.exp(-d2 / sigma2);
let x = p.x + dx * g * 0.22, y = p.y + dy * g * 0.22;
let brillo = 0.07 + g * 0.6;
if (onda) {
const ex = p.x - onda.x, ey = p.y - onda.y;
const de = Math.hypot(ex, ey) || 1;
const banda = Math.exp(-((de - r) ** 2) / 1800); // anillo de unos 40 px
x += ex / de * banda * 16; y += ey / de * banda * 16;
brillo += banda * 0.7;
}
const t = 1.2 + g * 1.4;
cc.globalAlpha = Math.min(1, brillo);
cc.fillRect(x - t / 2, y - t / 2, t, t);
}
cc.globalAlpha = 1;
}
/* ---------- La explosión en partículas ---------- */
let explosion = null; // { t, parts }
const FASES = { estallido: 380, forma: 1350, pausa: 2350, vuelta: 3300, fin: 3700 };
// Lee los píxeles de un dibujo hecho a escondidas y devuelve puntos sobre la forma
function muestrear(w, h, dibujar, paso) {
const c = document.createElement('canvas');
c.width = Math.ceil(w); c.height = Math.ceil(h);
const x = c.getContext('2d', { willReadFrequently: true });
dibujar(x);
const datos = x.getImageData(0, 0, c.width, c.height).data;
const res = [];
for (let y = 0; y < c.height; y += paso)
for (let i = 0; i < c.width; i += paso)
if (datos[(y * c.width + i) * 4 + 3] > 128) res.push({ x: i, y });
return res;
}
function explotar() {
if (explosion) return;
aviso.textContent = '¡Hecho! Plaza reservada (demostración).';
// Versión sin movimiento: cambiamos el texto y listo
if (quieto) {
const original = ctaTexto.innerHTML;
ctaTexto.textContent = '¡Hecho!';
explosion = { quieto: true };
setTimeout(() => { ctaTexto.innerHTML = original; explosion = null; }, 1800);
return;
}
const bw = cta.offsetWidth, bh = cta.offsetHeight;
const fuenteBoton = getComputedStyle(cta).font;
const etiqueta = 'Reserva tu plaza →';
// 1) Puntos del botón: la píldora verde con el texto «recortado» (hueco)
const paso = ancho < 700 ? 3 : 4;
const casa = muestrear(bw, bh, x => {
x.fillStyle = '#fff';
x.beginPath(); x.roundRect(0, 0, bw, bh, bh / 2); x.fill();
x.globalCompositeOperation = 'destination-out';
x.font = fuenteBoton; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText(etiqueta, bw / 2, bh / 2 + 1);
}, paso).map(p => ({ x: centro.x - bw / 2 + p.x, y: centro.y - bh / 2 + p.y }));
// 2) Puntos del texto «¡Hecho!»: tan grande como quepa en pantalla
const medida = document.createElement('canvas').getContext('2d');
medida.font = '800 100px system-ui, sans-serif';
const tam = Math.min(124, (ancho * 0.86) / medida.measureText('¡Hecho!').width * 100);
const tw = ancho, th = tam * 1.3;
const letras = muestrear(tw, th, x => {
x.fillStyle = '#fff';
x.font = `800 ${tam}px system-ui, sans-serif`;
x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText('¡Hecho!', tw / 2, th / 2);
}, ancho < 700 ? 3 : 4).map(p => ({ x: p.x, y: centro.y - th / 2 + p.y }));
// 3) Una partícula por punto (del conjunto más grande); cada una conoce
// su sitio en el botón (casa) y su sitio en las letras (meta)
const n = Math.max(casa.length, letras.length);
const parts = [];
for (let i = 0; i < n; i++) {
const h = casa[i % casa.length];
const m = letras[(i * 7919) % letras.length]; // mezcla para que se crucen
const x = h.x + boton.x, y = h.y + boton.y;
const ang = Math.atan2(y - centro.y, x - centro.x) + (Math.random() - 0.5) * 0.9;
const vel = 4 + Math.random() * 10;
parts.push({
x, y, vx: Math.cos(ang) * vel, vy: Math.sin(ang) * vel - 2,
casa: h, meta: m,
retraso: Math.random() * 260,
clara: Math.random() < 0.22
});
}
explosion = { t: performance.now(), parts };
if (tactil && !demo && navigator.vibrate) navigator.vibrate(14); // un toque en la mano
onda = { x: centro.x, y: centro.y, t: performance.now() };
cta.classList.add('oculto');
pagina.classList.add('estallando');
}
function moverParticulas(ahora, f) {
const e = ahora - explosion.t;
pc.clearRect(0, 0, ancho, alto);
const tamP = ancho < 700 ? 2.2 : 2.8;
let alfa = 1;
if (e > FASES.vuelta) {
cta.classList.remove('oculto'); // el botón reaparece debajo
pagina.classList.remove('estallando');
alfa = Math.max(0, 1 - (e - FASES.vuelta) / (FASES.fin - FASES.vuelta));
}
for (const p of explosion.parts) {
if (e < FASES.estallido) {
// Salen disparadas y frenan con el aire
p.vx *= Math.pow(0.9, f); p.vy *= Math.pow(0.9, f);
} else {
// Muelle hacia las letras o, al final, de vuelta al botón
const destino = e < FASES.pausa ? p.meta : p.casa;
const inicio = e < FASES.pausa ? FASES.estallido : FASES.pausa;
if (e - inicio > p.retraso) {
let tx = destino.x, ty = destino.y;
if (e > FASES.forma && e < FASES.pausa) {
// Respiran un poco mientras forman el texto
tx += Math.sin(ahora / 260 + p.retraso) * 0.8;
ty += Math.cos(ahora / 300 + p.retraso) * 0.8;
}
p.vx = (p.vx + (tx - p.x) * 0.075 * f) * Math.pow(0.8, f);
p.vy = (p.vy + (ty - p.y) * 0.075 * f) * Math.pow(0.8, f);
}
}
p.x += p.vx * f; p.y += p.vy * f;
}
// Dos pasadas (verde y verde claro) para cambiar de color sólo dos veces
for (const clara of [false, true]) {
pc.globalAlpha = alfa;
pc.fillStyle = clara ? '#B8FFD9' : '#00FF7F';
for (const p of explosion.parts)
if (p.clara === clara) pc.fillRect(p.x - tamP / 2, p.y - tamP / 2, tamP, tamP);
}
pc.globalAlpha = 1;
if (e > FASES.fin) { explosion = null; pc.clearRect(0, 0, ancho, alto); }
}
/* ---------- Modo demostración: un cursor fantasma juega con el botón ---------- */
// Guion en segundos y en píxeles relativos al centro del botón
// 1) se acerca y tira del botón · 2) se escapa de golpe (el botón rebota como gelatina)
// 3) vuelve, lo centra y pulsa · 4) se va mientras las partículas escriben «¡Hecho!»
const GUION = [
[0.0, -330, 250], [1.0, -190, 110], [1.6, -80, 40], [2.2, 60, -40],
[2.8, 135, 28], [3.15, 340, -150], [4.4, 320, -170],
[5.2, 90, -50], [5.8, -40, 30], [6.4, 4, 6], [7.0, 4, 6],
[8.4, 150, 170], [10.0, 300, 250], [10.6, 330, 260]
];
const DURACION = 10.6, PULSA = 6.6;
let demo = !quieto, tDemo = 1.6, pulsado = false, reanudar = 0;
// Interpolación Catmull-Rom: curva suave que pasa por todos los puntos del guion
function guion(t) {
let i = 0;
while (i < GUION.length - 2 && GUION[i + 1][0] < t) i++;
const [t1, x1, y1] = GUION[i], [t2, x2, y2] = GUION[i + 1];
const p0 = GUION[Math.max(0, i - 1)], p3 = GUION[Math.min(GUION.length - 1, i + 2)];
let u = Math.min(1, Math.max(0, (t - t1) / (t2 - t1)));
u = u * u * (3 - 2 * u); // arranca y frena suave
const cr = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * u + (2 * a - 5 * b + 4 * c - d) * u * u + (-a + 3 * b - 3 * c + d) * u * u * u);
return [cr(p0[1], x1, x2, p3[1]), cr(p0[2], y1, y2, p3[2])];
}
function moverFantasma(dt) {
tDemo += dt / 1000;
if (tDemo > DURACION) { tDemo = 0; pulsado = false; }
const esc = Math.min(1, Math.max(0.5, ancho / 900));
const [gx, gy] = guion(tDemo);
puntero.x = centro.x + gx * esc;
puntero.y = centro.y + gy * esc;
puntero.activo = true;
// Aparece al principio y se desvanece al final del bucle
const o = Math.min(1, tDemo / 0.4, (DURACION - tDemo) / 0.6);
fantasma.style.opacity = o.toFixed(3);
fantasma.style.transform = `translate3d(${puntero.x}px, ${puntero.y}px, 0)`;
// Pulsa el botón
if (!pulsado && tDemo > PULSA) {
pulsado = true;
fantasma.classList.add('pulsa');
setTimeout(() => fantasma.classList.remove('pulsa'), 180);
explotar();
}
}
// Cualquier interacción real detiene la demo; vuelve tras 7 s sin tocar nada
function tomarControl() {
if (demo) {
demo = false;
fantasma.style.opacity = '0';
puntero.activo = false;
}
reanudar = performance.now() + 7000;
}
/* ---------- Eventos ---------- */
pagina.addEventListener('pointermove', e => {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') {
tomarControl();
puntero.x = e.clientX; puntero.y = e.clientY; puntero.activo = true;
} else if (e.buttons) {
// Con el dedo: sólo mientras se mantiene pulsado
tomarControl();
puntero.x = e.clientX; puntero.y = e.clientY; puntero.activo = true;
}
});
pagina.addEventListener('pointerdown', e => {
tomarControl();
puntero.x = e.clientX; puntero.y = e.clientY; puntero.activo = true;
// Con el dedo no hay «acercarse»: al tocar cerca, el botón da un salto hacia la yema
if (e.pointerType !== 'mouse' && !explosion) {
const dx = e.clientX - centro.x, dy = e.clientY - centro.y;
if (Math.hypot(dx, dy) < radio * 1.3) { boton.vx += dx * 0.09; boton.vy += dy * 0.09; }
}
});
cta.addEventListener('focus', () => { focoTeclado = cta.matches(':focus-visible'); if (focoTeclado) boton.vy -= 5; });
cta.addEventListener('blur', () => { focoTeclado = false; });
const soltar = e => { tomarControl(); if (e.pointerType !== 'mouse') puntero.activo = false; };
pagina.addEventListener('pointerup', soltar);
pagina.addEventListener('pointercancel', soltar);
pagina.addEventListener('pointerleave', () => { puntero.activo = false; });
addEventListener('keydown', tomarControl);
// El botón es un <button> de verdad: ratón, dedo, Intro y Espacio lo pulsan
cta.addEventListener('click', () => { tomarControl(); explotar(); });
addEventListener('resize', medir);
/* ---------- Bucle principal ---------- */
let antes = performance.now();
function bucle(ahora) {
const dt = Math.min(50, ahora - antes); antes = ahora;
const f = dt / 16.67; // 1 = un fotograma a 60 fps
if (!demo && !quieto && reanudar && ahora > reanudar) { demo = true; tDemo = 0; pulsado = false; reanudar = 0; }
if (demo) moverFantasma(dt);
if (quieto) {
// Sin movimiento: sólo el halo quieto detrás del botón y la rejilla plana
halo.style.transform = `translate3d(${centro.x}px, ${centro.y}px, 0)`;
halo.style.opacity = '.6';
pintarCampo(ahora);
return; // no hace falta repetir: sin animación
}
moverBoton(f);
pintarCampo(ahora);
if (explosion) moverParticulas(ahora, f);
requestAnimationFrame(bucle);
}
medir();
haloPos.x = centro.x; haloPos.y = centro.y;
if (quieto) addEventListener('resize', () => bucle(performance.now()));
requestAnimationFrame(bucle);
})();