← AE Lab
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
11:21La imagen que se monta con píxeles
La imagen que se monta con píxeles
Un escaparate de producto que se construye con cientos de píxeles que vuelan y encajan en su sitio. Pasa el dedo por encima y se apartan como si los empujaras; al cambiar de producto, se desmontan y forman el siguiente.
// CÓMO ESTÁ HECHO
- Cada producto se dibuja con trazos de canvas en un lienzo invisible y se lee con getImageData: cada celdilla opaca se convierte en un píxel con su color y su destino.
- Los píxeles se reutilizan entre productos: se barajan los destinos, faltantes nacen en sitios aleatorios y los que sobran salen volando y se desvanecen.
- Un muelle con rozamiento lleva cada píxel a su destino, con un retraso escalonado de izquierda a derecha y un empujón inicial hacia fuera al desmontar.
- El puntero (ratón o dedo con pointer events) aplica una fuerza de repulsión que cae al cuadrado con la distancia; el muelle los devuelve solos.
- Presupuesto de partículas por pantalla (celdas más grandes si se pasa) para ir a 60 fps en móvil, y un dedo fantasma en curva de Lissajous para el modo demostración.
// EL CÓDIGO
<canvas id="lienzo" aria-hidden="true"></canvas>
<main class="app">
<header class="cabecera">
<span><b>AE</b> Lab · Escaparate de píxeles</span>
<span class="contador" id="contador">0 píxeles</span>
</header>
<div class="escenario" id="escenario" role="img" aria-label="Producto"></div>
<section class="ficha" id="ficha" aria-live="polite">
<span class="categoria" id="categoria"></span>
<h1 class="nombre" id="nombre"></h1>
<p class="texto" id="texto"></p>
<div class="compra">
<span class="precio" id="precio"></span>
<button class="siguiente" id="siguiente" type="button">
Siguiente producto
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</button>
</div>
<div class="puntos" id="puntos" aria-label="Elegir producto"></div>
<div class="progreso" aria-hidden="true"><i id="barraProgreso"></i></div>
<p class="pista">Pasa el <b>ratón</b> o el <b>dedo</b> por encima<span class="solo-teclado"> · <b>← →</b> para cambiar</span></p>
</section>
</main>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
--mono: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace;
}
* { box-sizing: border-box; margin: 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;
}
/* El lienzo ocupa toda la pantalla, detrás de todo: los píxeles pueden volar por cualquier sitio */
#lienzo { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; touch-action: none; }
/* Fondo: rejilla de puntos y un halo verde detrás del producto (estáticos, no se animan) */
.app {
position: relative; z-index: 1;
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
gap: 8px;
touch-action: none; /* el dedo empuja píxeles en vez de hacer scroll */
pointer-events: none; /* sólo los controles reciben clics; el resto va al lienzo */
background:
radial-gradient(circle at 50% 42%, rgba(0, 255, 127, .07), transparent 55%),
radial-gradient(circle, rgba(124, 138, 130, .14) 1px, transparent 1.5px) 0 0 / 22px 22px;
}
.app button { pointer-events: auto; }
.cabecera { display: flex; justify-content: space-between; align-items: center; font: 600 12px/1 var(--mono); color: var(--gris); letter-spacing: .08em; text-transform: uppercase; }
.cabecera b { color: var(--verde); font-weight: 600; }
.contador { font-variant-numeric: tabular-nums; }
/* El escenario es un hueco vacío: sólo marca DÓNDE se dibuja el producto en el lienzo */
.escenario { min-height: 0; }
.ficha { display: grid; gap: 10px; transition: opacity .3s, transform .3s; }
.ficha.saliendo { opacity: 0; transform: translateY(8px); }
.categoria { font: 600 12px/1 var(--mono); color: var(--verde); letter-spacing: .12em; text-transform: uppercase; }
.nombre { font: 800 clamp(28px, 7.4vw, 60px)/1 system-ui, sans-serif; letter-spacing: -.035em; }
.texto { color: var(--gris); line-height: 1.5; max-width: 38ch; font-size: 15px; }
.compra { display: flex; align-items: center; gap: 14px; margin-top: 6px; flex-wrap: wrap; }
.precio { font: 700 26px/1 var(--mono); color: var(--blanco); font-variant-numeric: tabular-nums; }
.siguiente {
appearance: none; font: inherit; font-weight: 700; font-size: 15px;
display: inline-flex; align-items: center; gap: 10px;
padding: 13px 20px; border-radius: 999px; cursor: pointer;
background: var(--verde); color: var(--fondo); border: 0;
transition: transform .2s;
}
.siguiente:hover { transform: translateY(-2px); }
.siguiente:active { transform: scale(.97); }
.siguiente svg { width: 18px; height: 18px; }
button:focus-visible { outline: 2px solid var(--blanco); outline-offset: 3px; }
.puntos { display: flex; gap: 6px; }
.punto {
appearance: none; border: 0; padding: 0; cursor: pointer;
width: 28px; height: 28px; border-radius: 50%; background: none;
display: grid; place-items: center;
}
.punto::after { content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--borde); transition: background .3s, transform .3s; }
.punto[aria-current="true"]::after { background: var(--verde); transform: scale(1.25); }
/* Barra de progreso del modo demostración (se anima con transform: scaleX) */
.progreso { height: 2px; background: var(--borde); border-radius: 2px; overflow: hidden; margin-top: 4px; }
.progreso i { display: block; height: 100%; background: var(--verde); transform-origin: 0 50%; transform: scaleX(0); }
.pista { font: 500 12px/1.3 var(--mono); color: var(--gris); }
.pista b { color: var(--blanco); font-weight: 500; }
/* Escritorio: texto a la izquierda, producto grande a la derecha */
@media (min-width: 900px) {
.app {
grid-template-columns: minmax(340px, .8fr) 1.25fr;
grid-template-rows: auto 1fr;
padding: 32px 48px;
column-gap: 40px;
background:
radial-gradient(circle at 68% 50%, rgba(0, 255, 127, .08), transparent 45%),
radial-gradient(circle, rgba(124, 138, 130, .14) 1px, transparent 1.5px) 0 0 / 24px 24px;
}
.cabecera { grid-column: 1 / -1; }
.ficha { grid-column: 1; grid-row: 2; align-self: center; gap: 16px; }
.escenario { grid-column: 2; grid-row: 2; }
.texto { font-size: 17px; }
.precio { font-size: 30px; }
}
@media (max-width: 899px) { .solo-teclado { display: none; } .ficha .texto { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } }
@media (prefers-reduced-motion: reduce) {
.ficha, .siguiente, .punto::after { transition: none; }
}
.oculto { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ============================================================
1. LOS PRODUCTOS
Cada uno se dibuja con trazos de canvas en un espacio fijo de 400×300.
Luego lo «fotografiamos» y convertimos cada celdilla en un píxel volador.
============================================================ */
const productos = [
{
categoria: 'Tienda · Running', nombre: 'Zapatilla Trail Nube', precio: '89,00 €',
texto: 'Suela de espuma reciclada y malla que respira. Para correr por el monte y volver por la ciudad.',
dibujar(c) {
sombra(c, 210, 248, 168, 10);
// Suela: blanca con franja verde y piso oscuro
c.fillStyle = '#E8F5EE';
c.beginPath(); c.moveTo(48, 236); c.lineTo(342, 236); c.quadraticCurveTo(382, 232, 374, 200); c.lineTo(58, 198); c.quadraticCurveTo(38, 214, 48, 236); c.fill();
c.fillStyle = '#00FF7F'; c.fillRect(56, 214, 310, 9);
c.fillStyle = '#2A3A31';
c.beginPath(); c.moveTo(48, 236); c.lineTo(342, 236); c.quadraticCurveTo(370, 236, 376, 226); c.lineTo(46, 226); c.closePath(); c.fill();
// Empeine
c.fillStyle = '#C9D8D0';
c.beginPath();
c.moveTo(60, 200); c.lineTo(56, 150); c.quadraticCurveTo(58, 112, 94, 106);
c.lineTo(146, 116); c.quadraticCurveTo(170, 118, 182, 100); c.lineTo(204, 82); c.lineTo(224, 90);
c.quadraticCurveTo(268, 146, 328, 164); c.quadraticCurveTo(376, 178, 372, 202); c.closePath(); c.fill();
// Talón reforzado y puntera oscuros
c.fillStyle = '#26352D';
c.beginPath(); c.moveTo(58, 200); c.lineTo(55, 150); c.quadraticCurveTo(57, 116, 92, 108); c.quadraticCurveTo(110, 150, 104, 200); c.fill();
c.beginPath(); c.moveTo(300, 200); c.quadraticCurveTo(300, 168, 326, 163); c.quadraticCurveTo(374, 176, 372, 200); c.fill();
// Onda verde lateral (nuestra «firma» inventada)
c.strokeStyle = '#00FF7F'; c.lineWidth = 13; c.lineCap = 'round';
c.beginPath(); c.moveTo(118, 186); c.bezierCurveTo(170, 150, 210, 200, 290, 150); c.stroke();
// Cordones
c.strokeStyle = '#05070A'; c.lineWidth = 5;
for (let i = 0; i < 5; i++) { const x = 196 + i * 16, y = 104 + i * 14; c.beginPath(); c.moveTo(x - 9, y + 7); c.lineTo(x + 9, y - 5); c.stroke(); }
// Boca de la zapatilla
c.fillStyle = '#05070A'; c.beginPath(); c.ellipse(130, 112, 34, 7, .12, 0, Math.PI * 2); c.fill();
}
},
{
categoria: 'Restaurante · Para llevar', nombre: 'Bowl Poke de Salmón', precio: '12,50 €',
texto: 'Arroz de sushi, salmón marinado, aguacate, edamame y sésamo tostado. Listo en diez minutos.',
dibujar(c) {
sombra(c, 200, 264, 140, 9);
// Palillos detrás
c.strokeStyle = '#C79A5B'; c.lineWidth = 7; c.lineCap = 'round';
c.beginPath(); c.moveTo(250, 120); c.lineTo(372, 40); c.stroke();
c.beginPath(); c.moveTo(262, 128); c.lineTo(380, 62); c.stroke();
// Cuenco
c.fillStyle = '#1F3328';
c.beginPath(); c.moveTo(62, 150); c.bezierCurveTo(66, 236, 128, 258, 200, 258); c.bezierCurveTo(272, 258, 334, 236, 338, 150); c.fill();
c.fillStyle = '#00FF7F'; c.fillRect(70, 186, 260, 7);
// Comida: un montículo dentro del borde
c.save();
c.beginPath(); c.ellipse(200, 150, 136, 22, 0, 0, Math.PI); c.ellipse(200, 150, 132, 64, 0, Math.PI, Math.PI * 2); c.clip();
c.fillStyle = '#F2F4EE'; c.fillRect(60, 80, 280, 100); // arroz
c.fillStyle = '#D9DED6'; for (let i = 0; i < 40; i++) c.fillRect(70 + (i * 53) % 260, 100 + (i * 29) % 60, 6, 3);
// Salmón: tacos naranjas con vetas
for (const [x, y] of [[150, 104], [178, 96], [166, 126], [196, 118], [138, 132]]) {
c.fillStyle = '#FF7A4D'; c.fillRect(x, y, 24, 20);
c.fillStyle = '#FFC0A3'; c.fillRect(x + 3, y + 6, 18, 3); c.fillRect(x + 3, y + 13, 18, 2);
}
// Aguacate en láminas
for (let i = 0; i < 4; i++) {
c.fillStyle = '#4E8F3A'; c.beginPath(); c.ellipse(258 + i * 12, 118 - i * 5, 11, 26, -.5, 0, Math.PI * 2); c.fill();
c.fillStyle = '#B8E07A'; c.beginPath(); c.ellipse(258 + i * 12, 118 - i * 5, 7, 22, -.5, 0, Math.PI * 2); c.fill();
}
// Edamame
c.fillStyle = '#7BE36A'; for (const [x, y] of [[96, 124], [110, 112], [104, 140], [120, 128], [92, 108]]) { c.beginPath(); c.arc(x, y, 7, 0, Math.PI * 2); c.fill(); }
// Rabanito rosa
c.fillStyle = '#FF5C8A'; c.beginPath(); c.arc(224, 92, 13, 0, Math.PI * 2); c.fill();
c.fillStyle = '#FFE1EA'; c.beginPath(); c.arc(224, 92, 8, 0, Math.PI * 2); c.fill();
c.restore();
// Borde del cuenco
c.strokeStyle = '#3B5546'; c.lineWidth = 6; c.beginPath(); c.ellipse(200, 150, 138, 22, 0, 0, Math.PI); c.stroke();
// Sésamo
c.fillStyle = '#05070A'; for (let i = 0; i < 14; i++) c.fillRect(120 + (i * 37) % 160, 96 + (i * 17) % 44, 3, 3);
}
},
{
categoria: 'Cafetería · De especialidad', nombre: 'Flat White de la casa', precio: '3,20 €',
texto: 'Doble de especialidad con leche texturizada. Café tostado esta misma semana, a dos calles.',
dibujar(c) {
sombra(c, 200, 264, 140, 9);
// Vapor
c.strokeStyle = '#7C8A82'; c.lineWidth = 7; c.lineCap = 'round';
for (const x of [165, 200, 235]) { c.beginPath(); c.moveTo(x, 104); c.bezierCurveTo(x - 18, 84, x + 18, 66, x, 40); c.stroke(); }
// Platillo
c.fillStyle = '#B8C7BF'; c.beginPath(); c.ellipse(200, 244, 140, 22, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#E8F5EE'; c.beginPath(); c.ellipse(200, 238, 140, 20, 0, 0, Math.PI * 2); c.fill();
// Asa
c.strokeStyle = '#D5E2DB'; c.lineWidth = 14; c.beginPath(); c.arc(286, 168, 28, -1.3, 1.3); c.stroke();
// Taza con sombreado a la derecha y franja verde
c.fillStyle = '#E8F5EE';
c.beginPath(); c.moveTo(112, 128); c.lineTo(288, 128); c.bezierCurveTo(288, 214, 252, 240, 200, 240); c.bezierCurveTo(148, 240, 112, 214, 112, 128); c.fill();
c.fillStyle = '#C3D2CA';
c.beginPath(); c.moveTo(240, 128); c.lineTo(288, 128); c.bezierCurveTo(288, 214, 252, 240, 214, 240); c.bezierCurveTo(250, 214, 258, 170, 240, 128); c.fill();
c.fillStyle = '#00FF7F'; c.fillRect(116, 160, 168, 10);
// Café y corazón de leche
c.fillStyle = '#6B3B22'; c.beginPath(); c.ellipse(200, 128, 88, 15, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#F0D2AA';
c.beginPath(); c.moveTo(200, 138); c.bezierCurveTo(170, 126, 182, 112, 200, 122); c.bezierCurveTo(218, 112, 230, 126, 200, 138); c.fill();
}
}
];
function sombra(c, x, y, rx, ry) {
c.fillStyle = '#16251C'; c.beginPath(); c.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); c.fill();
}
/* ============================================================
2. PREPARACIÓN: lienzo nítido en pantallas retina y accesos al DOM
============================================================ */
const lienzo = document.getElementById('lienzo');
const ctx = lienzo.getContext('2d');
const escenario = document.getElementById('escenario');
const ficha = document.getElementById('ficha');
const barraProgreso = document.getElementById('barraProgreso');
const contador = document.getElementById('contador');
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
let ancho = 0, alto = 0, caja = null, celdaBase = 8, celda = 8, limites = null;
let actual = 0;
let particulas = [];
function medir() {
const dpr = Math.min(devicePixelRatio || 1, 2);
ancho = innerWidth; alto = innerHeight;
lienzo.width = Math.round(ancho * dpr); lienzo.height = Math.round(alto * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
caja = escenario.getBoundingClientRect();
// Píxeles más pequeños en móvil, pero también menos: así va a 60 fps en cualquier teléfono
celdaBase = ancho < 700 ? 5 : 8;
}
/* ============================================================
3. DE DIBUJO A PÍXELES
Pintamos el producto en un canvas invisible del tamaño del escenario,
leemos sus colores (getImageData) y guardamos un punto por cada celdilla opaca.
============================================================ */
const oculto = document.createElement('canvas');
const octx = oculto.getContext('2d', { willReadFrequently: true });
function muestrear(producto) {
const w = Math.max(1, Math.floor(caja.width)), h = Math.max(1, Math.floor(caja.height));
oculto.width = w; oculto.height = h;
octx.clearRect(0, 0, w, h);
// Encajamos la zona útil del dibujo (360×280, centrada en 205,150) en el escenario
const escala = Math.min(w / 360, h / 280, 2.2);
octx.save();
octx.translate(w / 2 - 205 * escala, h / 2 - 150 * escala);
octx.scale(escala, escala);
producto.dibujar(octx);
octx.restore();
const datos = octx.getImageData(0, 0, w, h).data;
let paso = celdaBase, puntos;
// Si salen demasiados, agrandamos la celdilla hasta caber en el presupuesto
const tope = ancho < 700 ? 1600 : 3200;
do {
puntos = [];
for (let y = paso / 2; y < h; y += paso) {
for (let x = paso / 2; x < w; x += paso) {
const i = ((y | 0) * w + (x | 0)) * 4;
if (datos[i + 3] < 140) continue;
// Pequeña variación de brillo: le da textura de pixel-art
const luz = (Math.random() - .5) * 18;
puntos.push({
x: caja.left + x, y: caja.top + y,
r: limitar(datos[i] + luz), g: limitar(datos[i + 1] + luz), b: limitar(datos[i + 2] + luz)
});
}
}
if (puntos.length > tope) paso += 1;
} while (puntos.length > tope);
celda = paso;
// Rectángulo que ocupa el producto: por ahí pasea el dedo fantasma
limites = puntos.reduce((l, q) => ({ x0: Math.min(l.x0, q.x), y0: Math.min(l.y0, q.y), x1: Math.max(l.x1, q.x), y1: Math.max(l.y1, q.y) }),
{ x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity });
return puntos;
}
const limitar = v => Math.max(0, Math.min(255, v));
/* ============================================================
4. MONTAR UN PRODUCTO
Reutilizamos los píxeles que ya hay: cada uno recibe un destino nuevo.
Si faltan, nacen fuera; si sobran, salen volando y se desvanecen.
============================================================ */
function montar(indice, modo) {
const destinos = barajar(muestrear(productos[indice]));
const ahora = performance.now();
const cx = caja.left + caja.width / 2, cy = caja.top + caja.height / 2;
// Los que sobran: se van a un punto lejano y se apagan
for (let i = destinos.length; i < particulas.length; i++) {
const p = particulas[i];
const ang = Math.random() * Math.PI * 2;
p.tx = cx + Math.cos(ang) * (ancho + alto); p.ty = cy + Math.sin(ang) * (ancho + alto);
p.ta = 0; p.muere = true; p.suelta = ahora + Math.random() * 300;
}
destinos.forEach((d, i) => {
let p = particulas[i];
if (!p) {
// Nace en un sitio aleatorio de la pantalla, invisible
p = { x: Math.random() * ancho, y: Math.random() * alto, vx: 0, vy: 0, r: d.r, g: d.g, b: d.b, a: 0 };
if (quieto) { p.x = d.x; p.y = d.y; }
particulas[i] = p;
}
p.tx = d.x; p.ty = d.y; p.tr = d.r; p.tg = d.g; p.tb = d.b;
p.ta = 1; p.muere = false;
// Retraso escalonado de izquierda a derecha: se «escribe» como un barrido
const barrido = (d.x - caja.left) / caja.width;
if (quieto) { p.x = d.x; p.y = d.y; p.vx = p.vy = 0; p.suelta = ahora; }
else if (modo === 'inicio') p.suelta = ahora + 150 + barrido * 650 + Math.random() * 250;
else if (modo === 'cambio') {
// Desmontar: un empujón hacia fuera del centro antes de volar al nuevo sitio
const ang = Math.atan2(p.y - cy, p.x - cx) + (Math.random() - .5) * 1.2;
const fuerza = 5 + Math.random() * 9;
p.vx += Math.cos(ang) * fuerza; p.vy += Math.sin(ang) * fuerza;
p.suelta = ahora + 260 + barrido * 380 + Math.random() * 160;
} else p.suelta = ahora; // al redimensionar: directos a su sitio
});
// Los que sobran quedan al final del array; se borran cuando ya no se ven
contador.textContent = destinos.length.toLocaleString('es-ES') + ' píxeles';
}
function barajar(a) {
for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [a[i], a[j]] = [a[j], a[i]]; }
return a;
}
/* ============================================================
5. EL PUNTERO (real o «dedo fantasma») que aparta los píxeles
============================================================ */
const puntero = { x: -9999, y: -9999, activo: false };
const fantasma = { x: 0, y: 0, t: 0, visible: 0 };
/* ============================================================
6. EL BUCLE: física de muelle + pintado, 60 veces por segundo
============================================================ */
let ultimo = performance.now();
function cuadro(ahora) {
const dt = Math.min((ahora - ultimo) / 16.667, 3); // 1 = un fotograma a 60 fps
ultimo = ahora;
demo(ahora, dt);
// ¿Quién empuja? El dedo de verdad o, en la demostración, el fantasma
const empuja = puntero.activo ? puntero : (enDemo && !quieto ? fantasma : null);
const radio = ancho < 700 ? 64 : 96;
const muelle = .055, roce = Math.pow(.84, dt), roceLibre = Math.pow(.95, dt);
ctx.clearRect(0, 0, ancho, alto);
const lado = celda - 1;
let vivos = 0;
for (let i = 0; i < particulas.length; i++) {
const p = particulas[i];
if (ahora >= p.suelta) {
// Muelle: aceleración proporcional a la distancia al destino
p.vx += (p.tx - p.x) * muelle * dt;
p.vy += (p.ty - p.y) * muelle * dt;
p.vx *= roce; p.vy *= roce;
// Color y opacidad se acercan poco a poco al destino
p.r += (p.tr - p.r) * .1 * dt; p.g += (p.tg - p.g) * .1 * dt; p.b += (p.tb - p.b) * .1 * dt;
p.a += (p.ta - p.a) * (p.muere ? .05 : .09) * dt;
} else {
p.vx *= roceLibre; p.vy *= roceLibre; // vuelo libre tras el empujón
}
// Empujón del puntero: más fuerte cuanto más cerca, cae al cuadrado
if (empuja && !p.muere) {
const dx = p.x - empuja.x, dy = p.y - empuja.y, d2 = dx * dx + dy * dy;
if (d2 < radio * radio && d2 > .01) {
const d = Math.sqrt(d2), f = (1 - d / radio) ** 2 * 7 * dt;
p.vx += dx / d * f; p.vy += dy / d * f;
}
}
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.muere && p.a < .02) continue; // ya invisible: se descarta abajo
particulas[vivos++] = p;
if (p.a < .01) continue;
ctx.globalAlpha = p.a > 1 ? 1 : p.a;
ctx.fillStyle = `rgb(${p.r | 0},${p.g | 0},${p.b | 0})`;
ctx.fillRect(p.x - lado / 2, p.y - lado / 2, lado, lado);
}
particulas.length = vivos;
ctx.globalAlpha = 1;
if (fantasma.visible > .01) pintarFantasma();
requestAnimationFrame(cuadro);
}
function pintarFantasma() {
const { x, y } = fantasma, a = fantasma.visible;
ctx.globalAlpha = a;
ctx.fillStyle = 'rgba(0,255,127,.12)';
ctx.beginPath(); ctx.arc(x, y, 26, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(232,245,238,.85)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 26, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#E8F5EE';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
}
/* ============================================================
7. MODO DEMOSTRACIÓN: cambia de producto solo y pasea un dedo fantasma.
Se para al primer gesto del usuario y vuelve tras 10 s sin tocar nada.
============================================================ */
const DURACION = 5200;
let enDemo = true, inicioCiclo = performance.now(), ultimoGesto = 0;
function demo(ahora, dt) {
if (!enDemo && ahora - ultimoGesto > 10000 && !puntero.activo) { enDemo = true; inicioCiclo = ahora; }
if (enDemo) {
const avance = (ahora - inicioCiclo) / DURACION;
barraProgreso.style.transform = `scaleX(${Math.min(avance, 1)})`;
if (avance >= 1) { inicioCiclo = ahora; ir(actual + 1); }
if (!quieto) {
// Recorrido en forma de lazo (curva de Lissajous) sobre el producto
fantasma.t += .013 * dt;
const l = limites, mx = (l.x0 + l.x1) / 2, my = (l.y0 + l.y1) / 2;
fantasma.x += (mx + Math.sin(fantasma.t * 1.3) * (l.x1 - l.x0) * .4 - fantasma.x) * .06 * dt;
fantasma.y += (my + Math.sin(fantasma.t * 2.1 + .6) * (l.y1 - l.y0) * .32 - fantasma.y) * .06 * dt;
}
} else barraProgreso.style.transform = 'scaleX(0)';
const objetivo = enDemo && !quieto ? 1 : 0;
fantasma.visible += (objetivo - fantasma.visible) * .08 * dt;
}
function gesto() {
ultimoGesto = performance.now();
enDemo = false;
}
/* ============================================================
8. CAMBIAR DE PRODUCTO (botón, puntos y teclado)
============================================================ */
const puntos = productos.map((p, i) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'punto';
b.setAttribute('aria-label', `Ver ${p.nombre}`);
b.addEventListener('click', () => { gesto(); ir(i); });
document.getElementById('puntos').appendChild(b);
return b;
});
function rellenarFicha(i) {
const p = productos[i];
document.getElementById('categoria').textContent = p.categoria;
document.getElementById('nombre').textContent = p.nombre;
document.getElementById('texto').textContent = p.texto;
document.getElementById('precio').textContent = p.precio;
escenario.setAttribute('aria-label', `${p.nombre}, ${p.precio}, dibujado con píxeles`);
puntos.forEach((b, k) => b.setAttribute('aria-current', k === i));
}
let temporizadorFicha = 0;
function ir(i) {
i = (i + productos.length) % productos.length;
if (i === actual) return;
actual = i;
montar(i, 'cambio');
// El texto sale, cambia y vuelve a entrar
clearTimeout(temporizadorFicha);
ficha.classList.add('saliendo');
temporizadorFicha = setTimeout(() => { rellenarFicha(i); ficha.classList.remove('saliendo'); }, quieto ? 0 : 280);
}
document.getElementById('siguiente').addEventListener('click', () => { gesto(); ir(actual + 1); });
addEventListener('keydown', e => {
if (e.key === 'ArrowRight') { gesto(); ir(actual + 1); }
else if (e.key === 'ArrowLeft') { gesto(); ir(actual - 1); }
else if (e.key === 'Tab') gesto();
});
// Puntero real: ratón, lápiz o dedo (pointer events lo unifican todo)
addEventListener('pointermove', e => { puntero.x = e.clientX; puntero.y = e.clientY; puntero.activo = true; gesto(); });
addEventListener('pointerdown', e => { puntero.x = e.clientX; puntero.y = e.clientY; puntero.activo = true; gesto(); });
const soltar = e => { if (e.pointerType !== 'mouse') puntero.activo = false; };
addEventListener('pointerup', soltar);
addEventListener('pointercancel', soltar);
document.documentElement.addEventListener('pointerleave', () => { puntero.activo = false; });
// Al girar el móvil o cambiar el tamaño de la ventana, recolocamos sin explosión
let temporizadorResize = 0;
addEventListener('resize', () => {
clearTimeout(temporizadorResize);
temporizadorResize = setTimeout(() => { medir(); montar(actual, 'redimensionar'); }, 120);
});
/* Arranque */
medir();
rellenarFicha(0);
montar(0, 'inicio');
requestAnimationFrame(cuadro);
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
11:21La imagen que se monta con píxeles