← AE Lab
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
8:43La web a oscuras con linterna
La web a oscuras con linterna
Una página de lanzamiento totalmente a oscuras donde el ratón (o el dedo) es una linterna con polvo en el aire y borde cálido. Al encontrar las 3 pistas escondidas, la luz se abre en círculo y aparece todo, con el botón «Avísame».
// CÓMO ESTÁ HECHO
- El contenido está siempre pintado; encima va un cuadrado enorme con un radial-gradient que deja un agujero transparente en el centro: esa es la oscuridad.
- En cada requestAnimationFrame la linterna se acerca al puntero con inercia (1 - e^-9·dt) y la oscuridad solo se mueve con transform: translate3d, sin repintar el layout.
- Cada pista mide su distancia a la luz: su opacidad sube al acercarse y, si la alumbras un instante, cuenta como hallada.
- Con 3 pistas, la capa oscura se escala hasta cubrir la diagonal y se desvanece mientras una onda cálida se expande: el revelado en círculo.
- Un canvas pinta motas de polvo solo dentro del haz, y un modo demostración pasea la linterna por las pistas hasta que alguien toca.
// EL CÓDIGO
<!-- 1 · Lo que hay en la habitación (siempre está ahí, la oscuridad lo tapa) -->
<main class="escena" aria-label="Lanzamiento del perfume Lumbre">
<header class="titulo">
<small>Casa Ámbar presenta</small>
<h1>LUMBRE</h1>
<p>Un perfume que solo se descubre a oscuras.</p>
</header>
<div class="frasco" aria-hidden="true">
<svg viewBox="0 0 200 320">
<defs>
<linearGradient id="cristal" x1="0" x2="1">
<stop offset="0" stop-color="#3b2410"/><stop offset=".45" stop-color="#d98a32"/>
<stop offset=".6" stop-color="#ffc677"/><stop offset="1" stop-color="#5a3412"/>
</linearGradient>
<linearGradient id="tapon" x1="0" x2="1">
<stop offset="0" stop-color="#2a2016"/><stop offset=".5" stop-color="#8a6a44"/><stop offset="1" stop-color="#1c150e"/>
</linearGradient>
<radialGradient id="brillo" cx=".5" cy=".55" r=".5">
<stop offset="0" stop-color="#ffb45c" stop-opacity=".55"/><stop offset="1" stop-color="#ffb45c" stop-opacity="0"/>
</radialGradient>
</defs>
<ellipse cx="100" cy="200" rx="120" ry="130" fill="url(#brillo)"/>
<rect x="70" y="18" width="60" height="54" rx="8" fill="url(#tapon)"/>
<rect x="84" y="72" width="32" height="22" fill="#6b4a26"/>
<path d="M40 120 Q40 94 70 94 H130 Q160 94 160 120 V282 Q160 304 136 304 H64 Q40 304 40 282 Z" fill="url(#cristal)" stroke="#ffe2b0" stroke-opacity=".35"/>
<path d="M52 128 Q52 108 70 106 V290 Q52 288 52 276 Z" fill="#fff" opacity=".22"/>
<rect x="58" y="176" width="84" height="64" rx="4" fill="#120c06" opacity=".82"/>
<text x="100" y="206" fill="#ffe2b0" font-family="ui-monospace, monospace" font-size="15" font-weight="800" text-anchor="middle" letter-spacing="2">LUMBRE</text>
<text x="100" y="226" fill="#ffb45c" font-family="ui-monospace, monospace" font-size="9" text-anchor="middle" letter-spacing="1">Nº21 · 50 ML</text>
</svg>
</div>
<button class="pista" data-n="1" type="button">
<span class="tinta"><em>1 · El olor</em><strong>Higo negro, cedro y una chispa de pimienta.</strong></span>
</button>
<button class="pista" data-n="2" type="button">
<span class="tinta"><em>2 · La fecha</em><strong>Se enciende el 21·10·2026 a las 21:00.</strong></span>
</button>
<button class="pista" data-n="3" type="button">
<span class="tinta"><em>3 · El secreto</em><strong><span class="codigo">LUMBRE15</span>−15 % para quien llegue primero.</strong></span>
</button>
</main>
<!-- 2 · La linterna: oscuridad con agujero, borde cálido, onda del revelado y polvo -->
<div class="oscuridad" aria-hidden="true"></div>
<div class="halo" aria-hidden="true"></div>
<div class="onda" aria-hidden="true"></div>
<canvas id="polvo" aria-hidden="true"></canvas>
<!-- 3 · Interfaz siempre visible -->
<div class="barra">
<span class="marca"><b>Casa Ámbar</b> · próximamente</span>
<span class="contador" aria-hidden="true">Pistas <i class="punto"></i><i class="punto"></i><i class="punto"></i></span>
</div>
<div class="mando">
<p class="pistaTexto" aria-live="polite">Mueve el ratón: es una linterna. Hay 3 pistas escondidas.</p>
<div class="botones">
<button class="btn principal" id="avisame" type="button" hidden>Avísame</button>
<button class="btn" id="encender" type="button">Encender la luz</button>
<button class="btn" id="apagar" type="button" hidden>Apagar otra vez</button>
</div>
</div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
--ambar: #FFB45C;
--ambar-claro: #FFE2B0;
}
* { 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;
touch-action: none; /* el dedo mueve la linterna, no la página */
user-select: none;
}
/* ───── La habitación: el contenido que está debajo de la oscuridad ───── */
.escena {
position: fixed; inset: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas: "titulo titulo" "frasco frasco" "p1 p2" "p3 p3";
grid-template-rows: auto 1fr auto auto;
gap: 12px 14px;
padding: 72px 18px 150px;
/* pared cálida con un papel pintado muy sutil: la luz lo descubre */
background:
radial-gradient(60% 50% at 50% 45%, rgba(255, 170, 80, .16), transparent 70%),
repeating-linear-gradient(45deg, rgba(255, 200, 140, .035) 0 2px, transparent 2px 26px),
repeating-linear-gradient(-45deg, rgba(255, 200, 140, .035) 0 2px, transparent 2px 26px),
linear-gradient(180deg, #171009, #0a0806 70%, var(--fondo));
}
.titulo { grid-area: titulo; text-align: center; }
.titulo small { color: var(--ambar); font: 600 12px/1 ui-monospace, monospace; letter-spacing: .3em; text-transform: uppercase; }
.titulo h1 {
font: 800 clamp(48px, 15vw, 132px)/.95 ui-monospace, "JetBrains Mono", monospace;
letter-spacing: -.05em; margin: 6px 0 4px;
background: linear-gradient(180deg, var(--ambar-claro), var(--ambar) 60%, #b5641c);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.titulo p { color: #cdb89b; font-size: 15px; line-height: 1.4; }
.frasco { grid-area: frasco; position: relative; min-height: 0; align-self: stretch; }
.frasco svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* Pistas: botones (teclado y lector) con «tinta» que solo se ve con luz */
.pista {
font: inherit; color: inherit; text-align: left; cursor: default;
background: rgba(255, 200, 140, .04);
border: 1px dashed rgba(255, 190, 120, .28); border-radius: 14px;
padding: 12px 14px; min-width: 0;
}
.pista:focus-visible { outline: 3px solid var(--verde); outline-offset: 4px; }
.pista[data-n="1"] { grid-area: p1; }
.pista[data-n="2"] { grid-area: p2; }
.pista[data-n="3"] { grid-area: p3; text-align: center; }
.tinta { display: block; will-change: opacity, transform; }
.pista em { display: block; font: 600 11px/1.2 ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--ambar); font-style: normal; }
.pista strong { display: block; margin-top: 6px; font-size: 15px; line-height: 1.35; font-weight: 600; color: var(--ambar-claro); }
.pista .codigo { display: block; margin-bottom: 4px; font: 800 clamp(20px, 6vw, 30px)/1.1 ui-monospace, monospace; letter-spacing: .08em; color: var(--verde); text-shadow: 0 0 18px rgba(0, 255, 127, .45); }
.pista.hallada { border-style: solid; border-color: rgba(0, 255, 127, .55); }
.pista.hallada em::after { content: " ✓"; color: var(--verde); }
@media (min-width: 820px) {
.escena {
grid-template-columns: 1fr clamp(220px, 28vw, 420px) 1fr;
grid-template-rows: 1fr 1fr;
grid-template-areas: "titulo frasco p2" "p1 frasco p3";
gap: 18px clamp(24px, 4vw, 64px);
padding: 84px clamp(28px, 5vw, 80px) 120px;
align-items: center;
}
.titulo { text-align: left; align-self: end; }
.titulo p { font-size: 17px; max-width: 26ch; }
.titulo h1 { font-size: clamp(64px, 8.4vw, 132px); }
.pista { max-width: 340px; padding: 16px 18px; }
.pista[data-n="1"] { align-self: start; }
.pista[data-n="2"] { justify-self: end; align-self: end; }
.pista[data-n="3"] { justify-self: end; align-self: start; text-align: left; }
.pista strong { font-size: 17px; }
}
/* ───── La oscuridad: un cuadrado enorme con un agujero que se mueve ───── */
.oscuridad {
position: fixed; left: 0; top: 0;
width: var(--lado); height: var(--lado);
background: radial-gradient(circle at center,
transparent 0, transparent calc(var(--r) * .32),
rgba(2, 3, 5, .45) calc(var(--r) * .6),
rgba(2, 3, 5, .9) calc(var(--r) * .88),
#020305 var(--r));
pointer-events: none; will-change: transform, opacity;
}
/* Borde cálido de la linterna (se suma a lo que hay debajo) */
.halo, .onda {
position: fixed; left: 0; top: 0; width: calc(var(--r) * 2); height: calc(var(--r) * 2);
border-radius: 50%; pointer-events: none; will-change: transform, opacity;
mix-blend-mode: screen;
}
.halo { background: radial-gradient(circle, rgba(255, 236, 200, .13) 0, rgba(255, 190, 110, .06) 40%, rgba(255, 150, 60, .2) 62%, rgba(255, 130, 40, 0) 74%); }
.onda { opacity: 0; background: radial-gradient(circle, transparent 52%, rgba(255, 220, 160, .55) 64%, transparent 72%); }
#polvo { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }
/* ───── Interfaz siempre visible (arriba y abajo) ───── */
.barra {
position: fixed; left: 0; right: 0; top: 0; z-index: 5;
display: flex; justify-content: space-between; align-items: center;
padding: calc(14px + env(safe-area-inset-top)) 18px 14px;
font: 600 12px/1 ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--gris);
}
.marca b { color: var(--blanco); }
.contador { display: flex; align-items: center; gap: 8px; }
.punto { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--gris); }
.punto.lleno { background: var(--verde); border-color: var(--verde); box-shadow: 0 0 12px var(--verde); animation: latido .5s cubic-bezier(.3, 1.6, .5, 1); }
@keyframes latido { from { transform: scale(2.2); } to { transform: scale(1); } }
.mando {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 5;
display: grid; justify-items: center; gap: 10px;
padding: 0 16px calc(18px + env(safe-area-inset-bottom));
text-align: center;
}
.pistaTexto { color: var(--blanco); font-size: 15px; line-height: 1.35; width: min(60ch, 100%); text-shadow: 0 1px 8px #000; }
.botones { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.btn {
font: 600 15px/1 system-ui, sans-serif; cursor: pointer;
min-height: 46px; padding: 0 20px; border-radius: 999px;
border: 1px solid var(--borde); background: rgba(13, 20, 16, .85); color: var(--blanco);
}
.btn:focus-visible { outline: 3px solid var(--verde); outline-offset: 3px; }
.btn.principal { background: var(--verde); color: #04120a; border-color: var(--verde); font-weight: 800; padding: 0 26px; box-shadow: 0 10px 30px rgba(0, 255, 127, .3); }
.btn[hidden] { display: none; }
/* Página encendida: el frasco «respira» */
.encendida .frasco svg { animation: respira 3.2s ease-in-out infinite; }
@keyframes respira { 50% { transform: scale(1.03) translateY(-4px); } }
@media (prefers-reduced-motion: reduce) {
.encendida .frasco svg, .punto.lleno { animation: none; }
}
const $ = s => document.querySelector(s);
const oscuridad = $('.oscuridad'), halo = $('.halo'), onda = $('.onda');
const lienzo = $('#polvo'), ctx = lienzo.getContext('2d');
const texto = $('.pistaTexto'), puntos = [...document.querySelectorAll('.punto')];
const pistas = [...document.querySelectorAll('.pista')].map((el, i) => ({
el, tinta: el.querySelector('.tinta'), base: i === 2 ? 0 : .22, // la 3 es tinta invisible
hallada: false, espera: 0, cx: 0, cy: 0
}));
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const dedo = matchMedia('(pointer: coarse)').matches;
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const suave = t => t * t * (3 - 2 * t);
let W, H, R, LADO, dpr, escalaMax, frasco = { x: 0, y: 0 }, titulo = { x: 0, y: 0 };
const linterna = { x: 0, y: 0 }; // dónde está la luz
const objetivo = { x: 0, y: 0 }; // adónde quiere ir (puntero o paseo)
let estado = 'oscuro', luz = 0; // luz: 0 = a oscuras, 1 = todo encendido
let demo = !quieto, tDemo = 0, halladas = 0;
const AYUDA = dedo ? 'Pasa el dedo: es una linterna. Hay 3 pistas escondidas.'
: 'Mueve el ratón: es una linterna. Hay 3 pistas escondidas.';
texto.textContent = AYUDA;
/* Medidas: radio de la luz, tamaño de la oscuridad y centro de cada pista */
function centro(el) { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }
function medir() {
W = innerWidth; H = innerHeight; dpr = Math.min(devicePixelRatio || 1, 2);
R = clamp(Math.min(W, H) * .26, 120, 230);
LADO = 2 * Math.hypot(W, H) + 2 * R; // cubre la pantalla esté donde esté la luz
escalaMax = Math.hypot(W, H) / (R * .32); // cuánto crece el agujero al revelar
document.body.style.setProperty('--r', R + 'px');
document.body.style.setProperty('--lado', LADO + 'px');
lienzo.width = W * dpr; lienzo.height = H * dpr;
pistas.forEach(p => Object.assign(p, { cx: centro(p.el).x, cy: centro(p.el).y }));
frasco = centro($('.frasco')); titulo = centro($('.titulo h1'));
}
addEventListener('resize', medir);
medir();
Object.assign(linterna, frasco); Object.assign(objetivo, frasco);
/* ───── Bloque 1 · Colocar la oscuridad y el halo (solo transform/opacity) ───── */
function colocarCapas() {
const e = suave(luz);
const escala = 1 + e * e * escalaMax; // el agujero se abre en círculo
oscuridad.style.transform =
`translate3d(${linterna.x - LADO / 2}px, ${linterna.y - LADO / 2}px, 0) scale(${escala})`;
oscuridad.style.opacity = 1 - clamp((luz - .55) / .45);
halo.style.transform = `translate3d(${linterna.x - R}px, ${linterna.y - R}px, 0) scale(${1 + e * 2})`;
halo.style.opacity = 1 - e;
const ola = estado === 'revelando' ? Math.sin(Math.PI * e) : 0; // onda expansiva
onda.style.transform = `translate3d(${linterna.x - R}px, ${linterna.y - R}px, 0) scale(${.6 + e * 9})`;
onda.style.opacity = ola;
}
/* ───── Bloque 2 · Tinta que reacciona a la luz (y cuenta como hallada) ───── */
function reaccionarTinta(dt) {
pistas.forEach((p, i) => {
const d = Math.hypot(linterna.x - p.cx, linterna.y - p.cy);
const cerca = clamp(1 - d / (R * 1.1)); // 1 = la luz encima
const visible = Math.max(cerca, luz, p.hallada ? .7 : 0);
p.tinta.style.opacity = (p.base + (1 - p.base) * visible).toFixed(3);
p.tinta.style.transform = `translateY(${(1 - visible) * 6}px)`;
if (estado === 'oscuro' && !p.hallada && d < R * .55) {
p.espera += dt; // hay que alumbrarla un instante
if (p.espera > .25) encontrar(i);
} else p.espera = 0;
});
}
function encontrar(i) {
const p = pistas[i]; if (p.hallada) return;
p.hallada = true; p.el.classList.add('hallada');
halladas++; puntos[halladas - 1].classList.add('lleno');
texto.textContent = halladas < 3
? `¡Pista ${halladas} de 3! Sigue buscando.` : '¡Las tres! Se enciende la luz…';
if (halladas === 3) setTimeout(encender, 380);
}
/* ───── Bloque 3 · Encender (revelado final) y volver a apagar ───── */
function encender() {
if (estado === 'encendido' || estado === 'revelando') return;
estado = quieto ? 'encendido' : 'revelando';
if (quieto) luz = 1;
document.body.classList.add('encendida');
texto.textContent = 'Llega el 21 de octubre. ¿Te avisamos?';
$('#avisame').hidden = false; $('#encender').hidden = true; $('#apagar').hidden = quieto;
}
function apagar() {
estado = 'apagando'; halladas = 0;
document.body.classList.remove('encendida');
pistas.forEach(p => { p.hallada = false; p.espera = 0; p.el.classList.remove('hallada'); });
puntos.forEach(pt => pt.classList.remove('lleno'));
texto.textContent = AYUDA;
$('#avisame').hidden = true; $('#encender').hidden = false; $('#apagar').hidden = true;
$('#avisame').textContent = 'Avísame';
}
function animarLuz(dt) {
if (estado === 'revelando' && (luz += dt / 1.5) >= 1) { luz = 1; estado = 'encendido'; }
if (estado === 'apagando' && (luz -= dt / 0.9) <= 0) { luz = 0; estado = 'oscuro'; }
}
/* ───── Bloque 4 · Polvo en el aire: solo se ve dentro del haz ───── */
const motas = Array.from({ length: dedo ? 60 : 110 }, () => ({
x: Math.random(), y: Math.random(), z: .4 + Math.random() * .6, fase: Math.random() * 6.3
}));
function pintarPolvo(dt, t) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
for (const m of motas) {
m.x = (m.x + dt * .006 * m.z + 1) % 1; // deriva lenta
m.y = (m.y + Math.sin(t / 1900 + m.fase) * dt * .004 + 1) % 1;
const x = m.x * W, y = m.y * H;
const d = Math.hypot(x - linterna.x, y - linterna.y) / R;
const a = Math.max(clamp(1 - d) ** 1.5, luz * .25) * (.5 + .5 * Math.sin(t / 400 + m.fase));
if (a < .02) continue;
ctx.fillStyle = `rgba(255,228,185,${a * .8})`;
ctx.beginPath(); ctx.arc(x, y, 1.6 * m.z, 0, 6.283); ctx.fill();
}
}
/* ───── Bloque 5 · Modo demostración: la linterna pasea sola ───── */
const P = i => ({ x: pistas[i].cx, y: pistas[i].cy });
// Paradas: [segundo, punto, pista que se da por hallada al llegar (o -1)]
const ruta = () => [
[0, frasco, -1], [0.6, frasco, -1], [1.5, { x: frasco.x + R * .25, y: frasco.y - R * .6 }, -1], [2.2, titulo, -1],
[3.0, P(0), -1], [3.7, P(0), 0], [4.6, P(1), -1], [5.3, P(1), 1],
[6.2, P(2), -1], [6.9, P(2), 2], [8.4, frasco, -1], [12.4, frasco, -1]];
function pasear(dt) {
const antes = tDemo; tDemo = (tDemo + dt) % 12.4;
if (antes < 10.6 && tDemo >= 10.6) apagar(); // vuelve a oscurecer y repite
const r = ruta(); let i = 0;
while (i < r.length - 2 && tDemo >= r[i + 1][0]) i++;
const [t0, A, forzar] = r[i], [t1, B] = r[i + 1], k = suave(clamp((tDemo - t0) / (t1 - t0)));
if (forzar >= 0) encontrar(forzar); // por si la inercia llega tarde
objetivo.x = A.x + (B.x - A.x) * k + Math.sin(tDemo * 2.1) * R * .12; // curva suave
objetivo.y = A.y + (B.y - A.y) * k + Math.cos(tDemo * 1.7) * R * .1;
}
/* ───── Bucle principal: linterna con inercia ───── */
let previo = performance.now();
function cuadro() {
const ahora = performance.now(); // mismo reloj siempre: dt nunca negativo
const dt = clamp((ahora - previo) / 1000, 0, .1); previo = ahora;
if (demo) pasear(dt);
const k = quieto ? 1 : 1 - Math.exp(-dt * 9); // inercia: se acerca un 9/s
linterna.x += (objetivo.x - linterna.x) * k;
linterna.y += (objetivo.y - linterna.y) * k;
animarLuz(dt); colocarCapas(); reaccionarTinta(dt);
if (!quieto) pintarPolvo(dt, ahora);
requestAnimationFrame(cuadro);
}
/* ───── Interacción: ratón, dedo y teclado ───── */
function tomarControl() {
if (!demo) return;
demo = false;
if (estado !== 'oscuro' || halladas) apagar(); // empieza tu partida, a oscuras
}
addEventListener('pointerdown', e => {
if (e.target.closest('.btn')) return;
tomarControl(); objetivo.x = e.clientX; objetivo.y = e.clientY;
});
addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' && !e.buttons) return; // en táctil, solo mientras tocas
if (e.target.closest && e.target.closest('.btn')) return;
tomarControl(); objetivo.x = e.clientX; objetivo.y = e.clientY;
});
pistas.forEach(p => p.el.addEventListener('focus', () => { // Tab lleva la luz a cada pista
tomarControl(); objetivo.x = p.cx; objetivo.y = p.cy;
}));
addEventListener('keydown', e => {
const paso = { ArrowLeft: [-40, 0], ArrowRight: [40, 0], ArrowUp: [0, -40], ArrowDown: [0, 40] }[e.key];
if (!paso) return;
tomarControl(); e.preventDefault();
objetivo.x = clamp(objetivo.x + paso[0], 0, W); objetivo.y = clamp(objetivo.y + paso[1], 0, H);
});
$('#encender').addEventListener('click', () => { demo = false; encender(); });
$('#apagar').addEventListener('click', () => { demo = false; apagar(); });
$('#avisame').addEventListener('click', e => { e.currentTarget.textContent = 'Apuntado ✓'; });
if (quieto) { luz = 1; encender(); } // sin movimiento: todo visible
requestAnimationFrame(cuadro);
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
8:43La web a oscuras con linterna