← AE Lab
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
11:33El titular que se derrite
El titular que se derrite
El titular de una heladería se funde bajo el ratón o el dedo: las letras se escurren, cuelgan gotas que se estiran con un hilo, caen y salpican en un charco, y al irte vuelven a solidificarse solas con un pequeño rebote. Todo en un canvas, sin librerías.
// CÓMO ESTÁ HECHO
- El titular se pinta una vez en un canvas oculto con degradado nata-pistacho; el h1 real queda en el DOM para lectores de pantalla.
- Cada fotograma, una campana de Gauss alrededor del puntero calienta columnas de 3-4 px; un muelle con poco rozamiento convierte ese calor en cuánto se descuelga cada columna (y en un rebote al enfriarse).
- Las columnas calientes se copian con drawImage en franjas que bajan y se alargan más cuanto más cerca del pie de la letra; las frías se copian de un solo trozo, nítidas.
- Del pie de cada letra derretida cuelgan gotas con forma de lágrima (curvas de Bézier) que engordan, se sueltan con un hilo que se rompe, caen con gravedad y salpican en un charco.
- La zona líquida pasa por un filtro SVG «goo» (feGaussianBlur + feColorMatrix) aplicado con ctx.filter solo al tramo caliente, para que gotas y letras se fundan; con prefers-reduced-motion solo cambia el brillo.
// EL CÓDIGO
<canvas id="lienzo" aria-hidden="true"></canvas>
<!-- Filtro «goo»: desenfoca y vuelve a endurecer el borde, así la gota y la letra se funden -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<filter id="goo" color-interpolation-filters="sRGB">
<feGaussianBlur id="goo-blur" in="SourceGraphic" stdDeviation="4" result="borroso"/>
<feColorMatrix in="borroso" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8" result="liquido"/>
<feComposite in="SourceGraphic" in2="liquido" operator="atop"/>
</filter>
</svg>
<div class="app">
<header class="marca">
<div class="logo">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="11" r="8" fill="#00FF7F"/>
<path d="M9 14 L16 30 L23 14 Z" fill="#E8F5EE" opacity=".85"/>
<path d="M11.5 16 L20.5 16 M13 20 L19 20" stroke="#05070A" stroke-width="1.4"/>
</svg>
Gelato Bruma
</div>
<span class="abierto"><b>●</b> Abierto hasta las 23:00</span>
</header>
<main class="heroe">
<p class="antetitulo">Helado artesano · pistacho tostado hoy</p>
<h1 class="titular" id="titular" tabindex="0" aria-describedby="ayuda">
<span class="solo-lector">Se derrite en la boca.</span>
</h1>
<p class="entrada">Pistacho tostado cada mañana, leche fresca y <strong>ni un gramo de prisa</strong>. Pruébalo antes de que se escape.</p>
<div class="acciones">
<button class="boton" id="sabores" type="button">Ver los sabores de hoy</button>
<span class="precio">Cucurucho desde 3,20 €</span>
</div>
<p class="aviso" id="aviso" aria-live="polite"></p>
</main>
<p class="pista" id="pista"><i aria-hidden="true"></i><span id="pista-texto">Pasa el ratón por el titular</span></p>
<p class="solo-lector" id="ayuda">Efecto visual: con las flechas del teclado derrites el titular.</p>
</div>
<div class="fantasma" id="fantasma" aria-hidden="true"></div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
}
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; }
body {
background:
radial-gradient(60% 45% at 50% 42%, rgba(0, 255, 127, .09), transparent 70%),
var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* El lienzo ocupa toda la pantalla, DETRÁS del contenido:
ahí se pinta el titular y caen las gotas hasta el suelo. */
#lienzo { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; }
.app {
position: relative; z-index: 1;
height: 100dvh;
display: grid; grid-template-rows: auto 1fr auto;
padding: 18px 16px calc(14px + env(safe-area-inset-bottom));
}
/* Cabecera de la heladería */
.marca { display: flex; align-items: center; justify-content: space-between; gap: 12px; max-width: 1180px; width: 100%; margin: 0 auto; }
.logo { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 17px; }
.logo svg { width: 28px; height: 28px; flex: none; }
.abierto {
font: 600 12px/1 ui-monospace, "JetBrains Mono", monospace;
color: var(--gris); border: 1px solid var(--borde); background: var(--superficie);
padding: 8px 12px; border-radius: 99px; white-space: nowrap;
}
.abierto b { color: var(--verde); font-weight: 600; }
/* Zona central: titular + texto + botón */
.heroe {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; gap: clamp(14px, 2.6vh, 26px);
touch-action: none;
}
.antetitulo { font: 600 12px/1.2 ui-monospace, "JetBrains Mono", monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--verde); }
/* El h1 real: su texto lo leen los lectores de pantalla y los buscadores.
Por fuera solo reserva el hueco; el dibujo lo hace el lienzo. */
.titular { position: relative; border-radius: 18px; outline: none; cursor: crosshair; }
.titular:focus-visible { outline: 2px dashed var(--verde); outline-offset: 10px; }
.solo-lector { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.entrada { color: var(--gris); max-width: 46ch; line-height: 1.55; font-size: clamp(15px, 1.6vw, 18px); }
.entrada strong { color: var(--blanco); font-weight: 600; }
.acciones { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 18px; }
.boton {
appearance: none; border: 0; cursor: pointer;
font: 700 16px/1 system-ui, sans-serif;
color: var(--fondo); background: var(--verde);
padding: 16px 24px; border-radius: 99px;
transition: transform .2s;
}
.boton:hover { transform: translateY(-2px); }
.boton:focus-visible { outline: 2px solid var(--blanco); outline-offset: 4px; }
.precio { font: 600 13px/1 ui-monospace, monospace; color: var(--gris); }
.aviso { min-height: 1.4em; color: var(--verde); font-size: 14px; }
/* La pista para el novato */
.pista {
justify-self: center;
display: flex; align-items: center; gap: 10px;
font-size: 14px; color: var(--blanco);
background: rgba(13, 20, 16, .85); border: 1px solid var(--borde);
padding: 10px 16px; border-radius: 99px; margin-bottom: 12px;
}
.pista i { width: 10px; height: 10px; border-radius: 50%; background: var(--verde); box-shadow: 0 0 0 4px rgba(0, 255, 127, .18); }
/* El «dedo fantasma» del modo demostración */
.fantasma {
position: fixed; left: 0; top: 0; z-index: 2; pointer-events: none;
width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;
border: 2px solid rgba(232, 245, 238, .75);
background: radial-gradient(circle, rgba(232, 245, 238, .55) 0 18%, rgba(232, 245, 238, .12) 22%);
opacity: 0; will-change: transform, opacity;
}
@media (min-width: 900px) {
.app { padding: 26px 40px 22px; }
.logo { font-size: 19px; }
}
(() => {
const lienzo = document.getElementById('lienzo');
const principal = lienzo.getContext('2d');
const capa = document.createElement('canvas'); // zona del titular: aquí actúa el filtro «goo»
const cctx = capa.getContext('2d');
let ctx = principal; // contexto en el que se pinta ahora mismo
const R = { x: 0, y: 0, w: 0, h: 0 };
const fuente = document.createElement('canvas'); // el titular «sólido», pintado una sola vez
const fctx = fuente.getContext('2d');
const titular = document.getElementById('titular');
const fantasma = document.getElementById('fantasma');
const LINEAS = ['Se derrite', 'en la boca.'];
const FAMILIA = 'system-ui, -apple-system, "Segoe UI", sans-serif';
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const tactil = matchMedia('(hover: none)').matches;
if (tactil) document.getElementById('pista-texto').textContent = 'Pasa el dedo por el titular';
let dpr = 1, W = 0, H = 0;
const T = { x: 0, y: 0, w: 0, h: 0, fs: 0, lh: 0, cw: 3, cols: 0 }; // geometría del titular
let lineas = []; // una por renglón, con su estado por columna
const gotas = [], charcos = [], chispas = [];
const calor = { x: -999, y: -999, activo: false, usuario: false };
const demo = { activo: !quieto, t0: performance.now() - 500, retomar: 0 };
// Ruido suave por columna: unas columnas gotean más que otras (los «dedos» de líquido)
function ruidoSuave(n) {
const nudos = Array.from({ length: Math.ceil(n / 7) + 2 }, () => Math.random());
return Float32Array.from({ length: n }, (_, c) => {
const i = Math.floor(c / 7), f = (c % 7) / 7, s = (1 - Math.cos(f * Math.PI)) / 2;
const v = nudos[i] * (1 - s) + nudos[i + 1] * s;
return 0.25 + 0.75 * v * v;
});
}
/* 1 · Texto en un lienzo: el titular se pinta una vez en un canvas oculto,
con degradado nata → pistacho. Después solo copiamos trozos de él. */
function pintarFuente(ancho, alto) {
fuente.width = Math.ceil(ancho * dpr); fuente.height = Math.ceil(alto * dpr);
fctx.setTransform(dpr, 0, 0, dpr, 0, 0);
fctx.font = `900 ${T.fs}px ${FAMILIA}`;
fctx.textAlign = 'center';
LINEAS.forEach((texto, i) => {
const arriba = i * T.lh;
const grad = fctx.createLinearGradient(0, arriba + T.fs * .12, 0, arriba + T.fs * .86);
grad.addColorStop(0, '#F6FFF9'); grad.addColorStop(.45, '#BFFFD9'); grad.addColorStop(1, '#00FF7F');
fctx.fillStyle = grad;
fctx.fillText(texto, ancho / 2, arriba + T.fs * .82);
});
}
// Coloca el titular según el tamaño de pantalla y prepara las columnas
function medir() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = innerWidth; H = innerHeight;
lienzo.width = Math.round(W * dpr); lienzo.height = Math.round(H * dpr);
fctx.font = `900 100px ${FAMILIA}`;
const anchoMax = Math.max(...LINEAS.map(l => fctx.measureText(l).width));
T.fs = Math.min((Math.min(W, 1180) - 40) / anchoMax * 100, H * 0.17, 180);
T.lh = T.fs * 0.98;
T.cw = T.fs < 110 ? 3 : 4;
const ancho = Math.ceil(anchoMax * T.fs / 100 + T.fs * 0.1);
const alto = Math.ceil(T.lh * LINEAS.length + T.fs * 0.1);
titular.style.width = ancho + 'px'; titular.style.height = alto + 'px';
pintarFuente(ancho, alto);
const r = titular.getBoundingClientRect();
const px = v => Math.round(v * dpr) / dpr; // alinear a píxel real: texto nítido
Object.assign(T, { x: px(r.left), y: px(r.top), w: ancho, h: alto, cols: Math.ceil(ancho / T.cw) });
Object.assign(R, { x: px(T.x - T.fs * .3), y: px(T.y - T.fs * .2), w: ancho + T.fs * .6 });
R.h = Math.min(H - R.y, alto + T.fs * 1.4);
capa.width = Math.ceil(R.w * dpr); capa.height = Math.ceil(R.h * dpr);
document.getElementById('goo-blur').setAttribute('stdDeviation', (Math.max(2, T.fs * .028) * dpr).toFixed(1));
// Para cada columna, dónde termina la letra por abajo (de ahí cuelgan las gotas)
const datos = fctx.getImageData(0, 0, fuente.width, fuente.height).data;
lineas = LINEAS.map((_, i) => {
const top = i * T.lh, h = i === LINEAS.length - 1 ? alto - top : T.lh;
const fondo = new Float32Array(T.cols).fill(-1);
for (let c = 0; c < T.cols; c++) {
const px = Math.min(fuente.width - 1, Math.floor((c + .5) * T.cw * dpr));
for (let y = Math.floor((top + h) * dpr) - 1; y > top * dpr; y--) {
if (datos[(y * fuente.width + px) * 4 + 3] > 150) { fondo[c] = y / dpr - top; break; }
}
}
return { top, h, fondo, ruido: ruidoSuave(T.cols),
m: new Float32Array(T.cols), sag: new Float32Array(T.cols),
vel: new Float32Array(T.cols), espera: new Float32Array(T.cols) };
});
gotas.length = 0;
}
// Cuánto baja cada punto de la letra según su altura (u = 0 arriba, 1 abajo)
const forma = u => u * u * Math.sqrt(u);
/* 2 · El calor del dedo: cada columna se funde según una campana de Gauss
alrededor del puntero; se funde rápido y se enfría despacio. */
function calentar(b, dt) {
const sigma = Math.max(34, T.w * 0.075), centroY = T.y + b.top + b.h / 2;
const pesoY = calor.activo ? Math.exp(-((calor.y - centroY) ** 2) / (2 * (T.lh * .8) ** 2)) : 0;
for (let c = 0; c < T.cols; c++) {
const dx = T.x + (c + .5) * T.cw - calor.x;
const objetivo = pesoY * Math.exp(-dx * dx / (2 * sigma * sigma));
const k = objetivo > b.m[c] ? 9 : 2;
b.m[c] += (objetivo - b.m[c]) * (1 - Math.exp(-k * dt));
// Un muelle con poco rozamiento: al enfriarse la letra sube con un rebote
const meta = b.m[c] * T.fs * 0.62 * b.ruido[c];
b.vel[c] += ((meta - b.sag[c]) * 90 - b.vel[c] * 8) * dt;
b.sag[c] = Math.max(b.sag[c] + b.vel[c] * dt, -T.fs * .06);
}
}
function copiar(sx, sy, sw, sh, dx, dy, dw, dh) {
ctx.drawImage(fuente, sx * dpr, sy * dpr, sw * dpr, sh * dpr, T.x + dx, T.y + dy, dw, dh);
}
/* 3 · Estirar cada columna: la columna caliente se corta en franjas y cada
franja se copia más abajo y más larga cuanto más cerca del pie está. */
function columnaLiquida(b, c, t) {
const x = c * T.cw, sw = Math.min(T.cw, T.w - x), s = b.sag[c];
const K = Math.abs(s) < 3 ? 1 : Math.abs(s) < 14 ? 3 : 6; // más franjas solo si hace falta
let yAntes = b.top;
for (let k = 0; k < K; k++) {
const u = (k + 1) / K, y = b.top + u * b.h + s * forma(u);
const vaiven = Math.sin(t * 4 + c * .07 + k * .6) * b.m[c] * T.fs * .02 * u;
copiar(x, b.top + k * b.h / K, sw, b.h / K, x + vaiven, yAntes, sw + .8, y - yAntes + .6);
yAntes = y;
}
}
// Las columnas frías se pintan al final, nítidas y de un solo trozo, sin filtro
const frias = []; // trozos [renglón, desde, hasta]
const zona = { a: 0, b: 0 }; // tramo horizontal que ocupa la capa con «goo»
const fria = (b, c) => Math.abs(b.sag[c]) < .4 && b.m[c] < .04;
const marcar = x => { zona.a = Math.min(zona.a, x); zona.b = Math.max(zona.b, x); };
function trozo(b, c0, c1) {
const x = c0 * T.cw, w = Math.min(c1 * T.cw, T.w) - x;
copiar(x, b.top, w, b.h, x, b.top, w, b.h);
}
function pintarTexto(t) {
frias.length = 0; zona.a = Infinity; zona.b = -Infinity;
const M = Math.ceil(T.fs * .15 / T.cw); // solape frío dentro de la capa: así no hay muesca
for (const b of lineas) {
let c = 0;
while (c < T.cols) {
if (!fria(b, c)) { columnaLiquida(b, c, t); marcar(T.x + c * T.cw); c++; continue; }
let fin = c;
while (fin < T.cols && fria(b, fin)) fin++;
frias.push([b, c, fin]);
if (c > 0) trozo(b, c, Math.min(fin, c + M));
if (fin < T.cols) trozo(b, Math.max(c, fin - M), fin);
c = fin;
}
}
}
// Versión tranquila (prefers-reduced-motion): solo un brillo suave, sin goteo
function pintarQuieto() {
copiar(0, 0, T.w, T.h, 0, 0, T.w, T.h);
let max = 0;
for (const b of lineas) for (let c = 0; c < T.cols; c++) max = Math.max(max, b.m[c]);
if (max > .01) {
ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = max * .35;
copiar(0, 0, T.w, T.h, 0, 0, T.w, T.h);
ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
}
}
// Punto de anclaje de una gota: el pie de la letra, ya derretido
const ancla = g => T.y + g.b.top + g.b.fondo[g.c] + g.b.sag[g.c] * forma(g.b.fondo[g.c] / g.b.h) - 1;
function crearGotas(b, dt) {
for (let c = 0; c < T.cols; c++) {
b.espera[c] -= dt;
if (b.m[c] < .45 || b.fondo[c] < 0 || b.espera[c] > 0 || gotas.length > 70) continue;
if (Math.random() > b.m[c] * dt * 2.4) continue;
for (let v = Math.max(0, c - 5); v < Math.min(T.cols, c + 6); v++) b.espera[v] = .55 + Math.random() * .5;
gotas.push({ b, c, x: T.x + (c + .5) * T.cw, r: 0, rMax: Math.max(3.2, T.fs * .03) + T.fs * Math.random() * .035,
largo: 0, largoMax: T.fs * (.12 + Math.random() * .28), cae: false, y: 0, vy: 0, cuello: 0 });
}
}
/* 4 · La gota que cuelga: forma de lágrima. Ancha arriba (pegada a la letra),
un cuello que se estrecha y una bola redonda abajo. */
function lagrima(x, ay, largo, r) {
const cy = ay + largo + r, base = r * 1.15, cuello = r * .42;
ctx.beginPath();
ctx.moveTo(x - base, ay - 2);
ctx.bezierCurveTo(x - base * .4, ay + largo * .35, x - cuello, cy - r * .9, x - r, cy);
ctx.arc(x, cy, r, Math.PI, 0, true); // la bola, pasando por abajo
ctx.bezierCurveTo(x + cuello, cy - r * .9, x + base * .4, ay + largo * .35, x + base, ay - 2);
ctx.closePath(); ctx.fill();
brillo(x, cy, r);
}
function brillo(x, y, r) {
ctx.fillStyle = 'rgba(255,255,255,.6)';
ctx.beginPath(); ctx.arc(x - r * .35, y - r * .35, r * .26, 0, 7); ctx.fill();
ctx.fillStyle = '#00FF7F';
}
function salpicar(g) {
charcos.push({ x: g.x, r: g.r, t: 0 });
if (charcos.length > 40) charcos.shift();
for (let i = 0; i < 3; i++) chispas.push({ x: g.x, y: H - 3, r: g.r * (.22 + Math.random() * .2),
vx: (Math.random() - .5) * 420, vy: -(140 + Math.random() * 260) });
}
/* 5 · Gravedad y salpicadura: al soltarse, la gota acelera, se alarga con la
velocidad, deja un hilo que se rompe y revienta en un charco al tocar el suelo. */
function caer(g, dt) {
g.vy += 2200 * dt; // gravedad (px/s²)
g.y += g.vy * dt;
g.cuello *= Math.exp(-9 * dt); // el muñón que queda en la letra se recoge
const estira = 1 + Math.min(g.vy / 1800, .7);
const ay = ancla(g), hilo = g.y - g.r * estira - (ay + g.cuello);
if (hilo < T.fs * .4) { // el hilo, cada vez más fino, hasta romperse
ctx.lineWidth = g.r * .35 * (1 - hilo / (T.fs * .4));
ctx.beginPath(); ctx.moveTo(g.x, ay + g.cuello); ctx.lineTo(g.x, g.y); ctx.stroke();
}
ctx.beginPath(); ctx.ellipse(g.x, g.y, g.r / Math.sqrt(estira), g.r * estira, 0, 0, 7); ctx.fill();
brillo(g.x, g.y, g.r * .9);
return g.y + g.r < H - 3; // false = ha tocado el suelo
}
function pintarGotas(dt) {
ctx.fillStyle = '#00FF7F'; ctx.strokeStyle = '#00FF7F'; ctx.lineCap = 'round';
cctx.fillStyle = '#00FF7F'; cctx.strokeStyle = '#00FF7F'; cctx.lineCap = 'round';
for (let i = gotas.length - 1; i >= 0; i--) {
const g = gotas[i];
// Cerca del titular se pinta en la capa con «goo»; más abajo, directamente
ctx = !g.cae || g.y + g.r * 3 < R.y + R.h ? cctx : principal;
if (ctx === cctx) { marcar(g.x - g.r * 2); marcar(g.x + g.r * 2); }
if (!g.cae) {
// Colgando: la bola engorda y el cuello se alarga mientras haya calor
g.r += (g.rMax - g.r) * (1 - Math.exp(-5 * dt));
g.largo += (T.fs * .18 + g.largo * 1.6) * dt * Math.max(.35, g.b.m[g.c]);
if (g.largo > g.largoMax || g.b.m[g.c] < .12) {
g.cae = true; g.vy = 40; g.cuello = g.largo * .45;
g.y = ancla(g) + g.largo + g.r;
if (g.r > T.fs * .04) gotas.push({ ...g, r: g.r * .38, y: g.y - g.r - g.largo * .4, vy: 10, cuello: 0 });
} else { lagrima(g.x, ancla(g), g.largo, g.r); continue; }
}
if (g.cuello > 1) lagrima(g.x, ancla(g), g.cuello * .6, g.r * .3);
if (!caer(g, dt)) { salpicar(g); gotas.splice(i, 1); }
}
// Charcos que se abren y se evaporan
ctx = principal;
for (let i = charcos.length - 1; i >= 0; i--) {
const p = charcos[i]; p.t += dt;
if (p.t > 1.6) { charcos.splice(i, 1); continue; }
const abre = 1 - (1 - Math.min(p.t / .3, 1)) ** 3;
ctx.globalAlpha = 1 - p.t / 1.6;
ctx.beginPath(); ctx.ellipse(p.x, H - 2, p.r * (1.2 + 3 * abre), p.r * .45, 0, 0, 7); ctx.fill();
}
ctx.globalAlpha = 1;
for (let i = chispas.length - 1; i >= 0; i--) {
const s = chispas[i]; s.vy += 2200 * dt; s.x += s.vx * dt; s.y += s.vy * dt;
if (s.y > H) { chispas.splice(i, 1); continue; }
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 7); ctx.fill();
}
}
// Modo demostración: un «dedo fantasma» recorre el titular cada 5,2 s
function moverFantasma(ahora) {
const p = ((ahora - demo.t0) % 5200) / 2900;
if (p > 1) { fantasma.style.opacity = 0; calor.activo = false; return; }
const e = p < .5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2;
calor.x = T.x + T.w * (.06 + .88 * e);
calor.y = T.y + T.h * .48 + Math.sin(p * Math.PI * 3) * T.lh * .55;
calor.activo = true;
fantasma.style.opacity = Math.min(1, p * 8, (1 - p) * 8) * .9;
fantasma.style.transform = `translate(${calor.x}px, ${calor.y}px)`;
}
function pararDemo() {
demo.activo = false; demo.retomar = performance.now() + 8000;
fantasma.style.opacity = 0;
}
let antes = performance.now();
function fotograma(ahora) {
const dt = Math.min((ahora - antes) / 1000, 1 / 30); antes = ahora;
if (!demo.activo && !quieto && !calor.usuario && ahora > demo.retomar) { demo.activo = true; demo.t0 = ahora; }
if (demo.activo) moverFantasma(ahora);
principal.setTransform(dpr, 0, 0, dpr, 0, 0);
principal.clearRect(0, 0, W, H);
for (const b of lineas) calentar(b, dt);
if (quieto) { ctx = principal; pintarQuieto(); }
else {
cctx.setTransform(1, 0, 0, 1, 0, 0);
cctx.clearRect(0, 0, capa.width, capa.height);
cctx.setTransform(dpr, 0, 0, dpr, -R.x * dpr, -R.y * dpr); // misma escala que la pantalla
ctx = cctx;
pintarTexto(ahora / 1000);
for (const b of lineas) crearGotas(b, dt);
pintarGotas(dt);
// Volcamos solo el tramo líquido de la capa, pasando por el filtro «goo»
if (zona.b > zona.a) {
const m = T.fs * .12 + T.cw;
const sx = Math.max(0, Math.floor((zona.a - m - R.x) * dpr));
const sw = Math.min(capa.width, Math.ceil((zona.b + m - R.x) * dpr)) - sx;
principal.setTransform(1, 0, 0, 1, 0, 0);
principal.filter = 'url(#goo)';
principal.drawImage(capa, sx, 0, sw, capa.height, Math.round(R.x * dpr) + sx, Math.round(R.y * dpr), sw, capa.height);
principal.filter = 'none';
principal.setTransform(dpr, 0, 0, dpr, 0, 0);
}
ctx = principal;
for (const [b, c0, c1] of frias) trozo(b, c0, c1); // lo sólido, encima y nítido
}
requestAnimationFrame(fotograma);
}
// Ratón y dedo: el calor sigue al puntero mientras esté cerca del titular
function dentro(x, y) {
const m = T.fs * .5;
return x > T.x - m && x < T.x + T.w + m && y > T.y - m && y < T.y + T.h + m;
}
function mover(e) {
if (e.pointerType !== 'mouse' && e.buttons === 0 && e.type === 'pointermove') return;
const cerca = dentro(e.clientX, e.clientY);
if (cerca) pararDemo();
calor.usuario = cerca;
if (cerca || !demo.activo) Object.assign(calor, { x: e.clientX, y: e.clientY, activo: cerca });
}
function soltar() { calor.usuario = false; if (!demo.activo) calor.activo = false; }
addEventListener('pointermove', mover);
addEventListener('pointerdown', mover);
addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') soltar(); });
addEventListener('pointercancel', soltar);
document.addEventListener('pointerleave', soltar);
document.documentElement.addEventListener('mouseleave', soltar);
// Teclado: ← → mueven el calor por el titular, ↑ ↓ cambian de renglón
let enfriar = 0;
titular.addEventListener('keydown', e => {
const pasos = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1], Enter: [0, 0], ' ': [0, 0] };
if (!pasos[e.key]) return;
e.preventDefault(); pararDemo();
if (!calor.usuario) Object.assign(calor, { x: T.x + T.w / 2, y: T.y + T.lh * .5 });
calor.x = Math.min(T.x + T.w, Math.max(T.x, calor.x + pasos[e.key][0] * T.w * .07));
calor.y = pasos[e.key][1] ? T.y + T.lh * (pasos[e.key][1] > 0 ? 1.5 : .5) : calor.y;
calor.activo = calor.usuario = true;
clearTimeout(enfriar); enfriar = setTimeout(soltar, 1600);
});
titular.addEventListener('blur', soltar);
document.getElementById('sabores').addEventListener('click', () => {
document.getElementById('aviso').textContent = 'Hoy: pistacho tostado, chocolate negro 70 % y limón con albahaca.';
});
let pendiente = false;
addEventListener('resize', () => {
if (pendiente) return; pendiente = true;
requestAnimationFrame(() => { pendiente = false; medir(); });
});
medir();
requestAnimationFrame(fotograma);
})();
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
11:33El titular que se derrite